Merge branch 'xuhd' of http://192.168.0.200/knowledge-base-bj/deerflow-server into liuq
This commit is contained in:
commit
9e4195db36
250 files changed
+81274
-626
No files matched your search
+7
-4
@@ -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/
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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,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<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";
|
||||
@@ -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) {
|
||||
)}
|
||||
|
||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
|
||||
{(canEdit || canDelete || isOwned || isAdmin) && (
|
||||
{(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"
|
||||
@@ -529,7 +583,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<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,
|
||||
@@ -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<string, unknown> }).additional_kwargs;
|
||||
const extra = kwargs?.tool_calls;
|
||||
return Array.isArray(extra) && extra.length > 0;
|
||||
}
|
||||
|
||||
export { ChatBox };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -87,13 +87,14 @@ import {
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "@/core/settings";
|
||||
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";
|
||||
@@ -304,14 +305,14 @@ 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 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) => {
|
||||
@@ -324,12 +325,12 @@ export function InputBox({
|
||||
}
|
||||
setFollowups([]);
|
||||
setFollowupsHidden(false);
|
||||
setFollowupsLoading(false);
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
// Guard against submitting before the initial model auto-selection
|
||||
setFollowupsLoading(false);
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
// Guard against submitting before the initial model auto-selection
|
||||
// effect has flushed thread settings to storage/state.
|
||||
if (resolvedModelName && context.model_name !== resolvedModelName) {
|
||||
onContextChange?.({
|
||||
@@ -351,13 +352,13 @@ export function InputBox({
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
resolvedModelName,
|
||||
ragModeEnabled,
|
||||
selectedModel?.supports_thinking,
|
||||
status,
|
||||
threadId,
|
||||
],
|
||||
);
|
||||
resolvedModelName,
|
||||
ragModeEnabled,
|
||||
selectedModel?.supports_thinking,
|
||||
status,
|
||||
threadId,
|
||||
],
|
||||
);
|
||||
|
||||
const requestFormSubmit = useCallback(() => {
|
||||
const form = promptRootRef.current?.querySelector("form");
|
||||
@@ -559,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(
|
||||
@@ -651,13 +667,13 @@ export function InputBox({
|
||||
}
|
||||
>
|
||||
<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
|
||||
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",
|
||||
)}
|
||||
@@ -1045,6 +1061,7 @@ export function InputBox({
|
||||
)}
|
||||
</PromptInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -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 {
|
||||
@@ -182,10 +183,23 @@ function MessageContent_({
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
|
||||
// Streamdown splits content into per-block processors; calling the factory
|
||||
// once here ensures all blocks share the same transformer instance (and thus
|
||||
// the same citation counter) for this message.
|
||||
const stableExtraPlugins = useMemo(() => {
|
||||
if (!extraRehypePlugins?.length) return [];
|
||||
return extraRehypePlugins.map((factory) => {
|
||||
const transformer = factory();
|
||||
return () => transformer;
|
||||
});
|
||||
}, [extraRehypePlugins]);
|
||||
|
||||
const rehypePlugins = useMemo(
|
||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||
[baseRehypePlugins, extraRehypePlugins],
|
||||
() => (stableExtraPlugins.length ? [...stableExtraPlugins, ...baseRehypePlugins] : baseRehypePlugins),
|
||||
[stableExtraPlugins, baseRehypePlugins],
|
||||
);
|
||||
const askTimes = useChatAskTimes();
|
||||
const isHuman = message.type === "human";
|
||||
const resolveArtifact = useArtifactResolver(threadId);
|
||||
const components = useMemo(
|
||||
@@ -217,6 +231,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);
|
||||
|
||||
@@ -296,6 +315,7 @@ function MessageContent_({
|
||||
{messageResponse}
|
||||
</AIElementMessageContent>
|
||||
)}
|
||||
<MessageTimeStamp time={messageTime} align="right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -308,6 +328,7 @@ function MessageContent_({
|
||||
content={contentToDisplay}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
renderSkillDisplayBlocks={false}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||
components={components}
|
||||
/>
|
||||
@@ -317,10 +338,57 @@ function MessageContent_({
|
||||
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
|
||||
*/
|
||||
@@ -493,24 +561,34 @@ 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),
|
||||
[content, displaySources],
|
||||
() =>
|
||||
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]);
|
||||
}, [displaySources, placedKeys, renderSkillDisplayBlocks]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -555,6 +633,7 @@ type DisplayPart =
|
||||
function splitSkillDisplayPlaceholders(
|
||||
content: string,
|
||||
displaySources: RuntimeDisplaySource[],
|
||||
renderSkillDisplayBlocks: boolean,
|
||||
): { parts: DisplayPart[]; placedKeys: Set<string> } {
|
||||
const pattern = /<skill-display\s+([^>]*?)\/>/g;
|
||||
const parts: DisplayPart[] = [];
|
||||
@@ -569,13 +648,15 @@ function splitSkillDisplayPlaceholders(
|
||||
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,
|
||||
});
|
||||
placedKeys.add(group.key);
|
||||
}
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
|
||||
@@ -1,7 +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,
|
||||
@@ -9,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,
|
||||
@@ -77,6 +78,23 @@ 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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -35,6 +35,7 @@ const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
|
||||
["summaryField", "摘要字段"],
|
||||
["contentField", "正文内容字段"],
|
||||
["sourceField", "来源字段"],
|
||||
["authorField", "作者字段"],
|
||||
["typeField", "类型字段"],
|
||||
["timeField", "时间字段"],
|
||||
["scoreField", "相关度字段"],
|
||||
|
||||
@@ -99,7 +99,7 @@ function SourceDetailDialog({
|
||||
}) {
|
||||
const content = source?.content || source?.snippet;
|
||||
const meta = source
|
||||
? [source.source, source.type, source.time].filter(Boolean)
|
||||
? [source.source, source.author ? `作者 ${source.author}` : null, source.type, source.time].filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
|
||||
@@ -66,11 +66,13 @@ export function SourceCard({
|
||||
: 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 (
|
||||
<>
|
||||
@@ -79,14 +81,14 @@ export function SourceCard({
|
||||
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:bg-primary/5 target:border-primary/60",
|
||||
"target:border-amber-300 target:bg-amber-50/80",
|
||||
highlighted &&
|
||||
"animate-[reference-flash_1.4s_ease-out] border-amber-400 bg-amber-50 shadow-[0_0_0_3px_rgba(251,191,36,0.22)]",
|
||||
"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">
|
||||
{isCitation && (
|
||||
{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>
|
||||
@@ -122,7 +124,7 @@ export function SourceCard({
|
||||
<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">
|
||||
{isCitation && (
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -96,6 +96,26 @@ export async function getAgentSelector(): Promise<AgentSelectorResponse> {
|
||||
return res.json() as Promise<AgentSelectorResponse>;
|
||||
}
|
||||
|
||||
export async function favoriteAgent(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to favorite agent: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function unfavoriteAgent(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to unfavorite agent: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents/featured/order`,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
adminSyncAgentSkills,
|
||||
createAgent,
|
||||
deleteAgent,
|
||||
favoriteAgent,
|
||||
getAgent,
|
||||
getAgentSelector,
|
||||
listAgents,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
reorderFeaturedAgents,
|
||||
setAgentFeatured,
|
||||
setAgentPrivate,
|
||||
unfavoriteAgent,
|
||||
updateAgent,
|
||||
} from "./api";
|
||||
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
@@ -112,6 +114,34 @@ export function useDeleteAgent() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of
|
||||
* ``is_favorite`` across every cached agent listing + detail so the star
|
||||
* reacts instantly before the round-trip completes. */
|
||||
export function useToggleAgentFavorite() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
|
||||
favorite ? favoriteAgent(id) : unfavoriteAgent(id),
|
||||
onMutate: ({ id, favorite }) => {
|
||||
queryClient.setQueriesData<Agent[] | undefined>(
|
||||
{ queryKey: ["agents"] },
|
||||
(old) =>
|
||||
Array.isArray(old)
|
||||
? old.map((a) =>
|
||||
a.id === id ? { ...a, is_favorite: favorite } : a,
|
||||
)
|
||||
: old,
|
||||
);
|
||||
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
|
||||
old ? { ...old, is_favorite: favorite } : old,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentSelector() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", "selector"],
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface Agent {
|
||||
skills?: string[] | null;
|
||||
tags?: Tag[];
|
||||
featured_order?: number | null;
|
||||
/** Whether the current user has favorited (收藏) this agent. */
|
||||
is_favorite?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentSelectorResponse {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* Durable, browser-local record of when each chat question was asked, keyed by
|
||||
* the message id. Captured at send time and reconciled to the server-assigned
|
||||
* message id (see ``useThreadStream``), so the time survives reloads and does
|
||||
* not depend on the backend round-tripping a timestamp on the message.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "deerflow.chat-ask-times";
|
||||
|
||||
type TimeMap = Record<string, string>;
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
let cache: TimeMap | null = null;
|
||||
const EMPTY: TimeMap = {};
|
||||
const listeners = new Set<() => void>();
|
||||
let storageBound = false;
|
||||
|
||||
function readFromStorage(): TimeMap {
|
||||
if (!isBrowser()) return EMPTY;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return parsed && typeof parsed === "object" ? (parsed as TimeMap) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function getSnapshot(): TimeMap {
|
||||
if (cache === null) cache = readFromStorage();
|
||||
return cache;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): TimeMap {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
function commit(next: TimeMap): void {
|
||||
cache = next;
|
||||
if (isBrowser()) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// Keep the in-memory cache even if persistence fails.
|
||||
}
|
||||
}
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
if (isBrowser() && !storageBound) {
|
||||
storageBound = true;
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === STORAGE_KEY || event.key === null) {
|
||||
cache = readFromStorage();
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Record the ask time for a message id. First write wins (the true ask time). */
|
||||
export function rememberAskTime(messageId: string, iso: string): void {
|
||||
if (!messageId) return;
|
||||
const current = getSnapshot();
|
||||
if (current[messageId]) return;
|
||||
commit({ ...current, [messageId]: iso });
|
||||
}
|
||||
|
||||
export function getAskTime(messageId: string): string | undefined {
|
||||
return getSnapshot()[messageId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record several message times at once (first write wins per id). Used to stamp
|
||||
* a freshly loaded/streamed batch of messages in a single commit so a long
|
||||
* conversation doesn't trigger one storage write + re-render per message.
|
||||
*/
|
||||
export function rememberAskTimes(entries: Record<string, string>): void {
|
||||
const current = getSnapshot();
|
||||
let changed = false;
|
||||
const next: TimeMap = { ...current };
|
||||
for (const [id, iso] of Object.entries(entries)) {
|
||||
if (id && !next[id]) {
|
||||
next[id] = iso;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) commit(next);
|
||||
}
|
||||
|
||||
/** Reactive map of messageId → ISO ask time. */
|
||||
export function useChatAskTimes(): TimeMap {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
/** A user-defined reusable prompt, bound to the account (stored server-side). */
|
||||
export interface CustomPrompt {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
sort_order?: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
const QUERY_KEY = ["user-prompts"] as const;
|
||||
const EMPTY_PROMPTS: CustomPrompt[] = [];
|
||||
const baseUrl = () => `${getBackendBaseURL()}/api/user-prompts`;
|
||||
|
||||
async function listUserPrompts(): Promise<CustomPrompt[]> {
|
||||
const res = await apiFetch(baseUrl());
|
||||
if (!res.ok) throw new Error(`Failed to load prompts: ${res.statusText}`);
|
||||
const data = (await res.json()) as { prompts: CustomPrompt[] };
|
||||
return data.prompts;
|
||||
}
|
||||
|
||||
async function createUserPrompt(input: {
|
||||
title: string;
|
||||
content: string;
|
||||
}): Promise<CustomPrompt> {
|
||||
const res = await apiFetch(baseUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to create prompt: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<CustomPrompt>;
|
||||
}
|
||||
|
||||
async function updateUserPrompt(
|
||||
id: string,
|
||||
input: { title?: string; content?: string },
|
||||
): Promise<CustomPrompt> {
|
||||
const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to update prompt: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<CustomPrompt>;
|
||||
}
|
||||
|
||||
async function deleteUserPrompt(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to delete prompt: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reactive list of the current user's saved prompts (server-backed). */
|
||||
export function useCustomPrompts(): CustomPrompt[] {
|
||||
const { data } = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: listUserPrompts,
|
||||
});
|
||||
return data ?? EMPTY_PROMPTS;
|
||||
}
|
||||
|
||||
export function useCreateCustomPrompt() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createUserPrompt,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCustomPrompt() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
input,
|
||||
}: {
|
||||
id: string;
|
||||
input: { title?: string; content?: string };
|
||||
}) => updateUserPrompt(id, input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCustomPrompt() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteUserPrompt,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export type SearchResultItem = {
|
||||
snippet?: string;
|
||||
content?: string;
|
||||
source?: string;
|
||||
author?: string;
|
||||
published_date?: string;
|
||||
publishedDate?: string;
|
||||
date?: string;
|
||||
@@ -111,6 +112,15 @@ const SOURCE_ALIASES = [
|
||||
"kb",
|
||||
] as const;
|
||||
|
||||
const AUTHOR_ALIASES = [
|
||||
"author",
|
||||
"authors",
|
||||
"creator",
|
||||
"writer",
|
||||
"byline",
|
||||
"owner",
|
||||
] as const;
|
||||
|
||||
const DATE_ALIASES = [
|
||||
"published_date",
|
||||
"publishedDate",
|
||||
@@ -131,6 +141,8 @@ const CONTENT_KEY_PATTERN =
|
||||
const URL_KEY_PATTERN = /(?:^|_)(?:url|href|link|uri)(?:$|_)/i;
|
||||
const SOURCE_KEY_PATTERN =
|
||||
/(?:^|_)(?:source|origin|site|publisher|domain|from|channel|database|kb)(?:$|_)/i;
|
||||
const AUTHOR_KEY_PATTERN =
|
||||
/(?:^|_)(?:author|authors|creator|writer|byline|owner)(?:$|_)/i;
|
||||
const DATE_KEY_PATTERN =
|
||||
/(?:^|_)(?:date|time|published|publish|created|updated)(?:$|_)/i;
|
||||
|
||||
@@ -301,6 +313,12 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
excludeUrl: true,
|
||||
maxLength: 120,
|
||||
});
|
||||
const author =
|
||||
pickFromLayers(layers, AUTHOR_ALIASES) ??
|
||||
pickByKeyPattern(layers, AUTHOR_KEY_PATTERN, {
|
||||
excludeUrl: true,
|
||||
maxLength: 120,
|
||||
});
|
||||
const date =
|
||||
pickFromLayers(layers, DATE_ALIASES) ??
|
||||
pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 });
|
||||
@@ -310,7 +328,7 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]);
|
||||
|
||||
const reserved = new Set<string>();
|
||||
for (const value of [title, content, url, source, date]) {
|
||||
for (const value of [title, content, url, source, author, date]) {
|
||||
if (value) {
|
||||
reserved.add(value);
|
||||
}
|
||||
@@ -341,6 +359,7 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
snippet: content,
|
||||
content,
|
||||
source,
|
||||
author,
|
||||
published_date: date,
|
||||
publishedDate: date,
|
||||
date,
|
||||
|
||||
@@ -7,6 +7,8 @@ const CITE_RE = /\[\[cite:(\d+)\]\]/g;
|
||||
|
||||
export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
|
||||
const sourceMap = new Map(sources.map((s) => [s.id, s.title]));
|
||||
const citeCounter = new Map<number, number>();
|
||||
let citeIndex = 0;
|
||||
|
||||
return (tree: Root) => {
|
||||
visit(tree, "text", (node, index, parent) => {
|
||||
@@ -14,8 +16,6 @@ export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
|
||||
CITE_RE.lastIndex = 0;
|
||||
|
||||
const newNodes: ElementContent[] = [];
|
||||
const citeCounter = new Map<number, number>();
|
||||
let citeIndex = 0;
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export async function createScheduledTask(data: {
|
||||
schedule: string;
|
||||
timezone?: string | null;
|
||||
execution_agent_name?: string | null;
|
||||
require_markdown?: boolean;
|
||||
}) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
await apiFetch(baseUrl(), {
|
||||
@@ -210,6 +211,7 @@ export async function updateScheduledTask(
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
execution_agent_name: string | null;
|
||||
require_markdown: boolean;
|
||||
}>,
|
||||
) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
@@ -337,6 +339,7 @@ export async function adminUpdateScheduledTask(
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
execution_agent_name: string | null;
|
||||
require_markdown: boolean;
|
||||
}>,
|
||||
) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
|
||||
@@ -20,6 +20,12 @@ export type ScheduledTask = {
|
||||
cron_expr: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Free-form execution context persisted with the task. ``require_markdown``
|
||||
* lives here (rather than a dedicated column) so the feature needs no schema
|
||||
* migration.
|
||||
*/
|
||||
execution_context?: { require_markdown?: boolean; [key: string]: unknown } | null;
|
||||
next_run_at?: string | null;
|
||||
last_run_at?: string | null;
|
||||
last_status?: string | null;
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface DisplaySourceItem {
|
||||
type: string;
|
||||
snippet: string;
|
||||
source?: string;
|
||||
author?: string;
|
||||
time?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
@@ -25,6 +26,7 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
|
||||
summaryField: null,
|
||||
contentField: null,
|
||||
sourceField: null,
|
||||
authorField: null,
|
||||
typeField: null,
|
||||
timeField: null,
|
||||
scoreField: null,
|
||||
@@ -43,6 +45,7 @@ const candidateFields: Record<string, string[]> = {
|
||||
summaryField: ["summary", "snippet", "content_preview", "description", "abstract"],
|
||||
contentField: ["content", "page_content", "text", "content_preview", "body"],
|
||||
sourceField: ["source", "source_name", "site", "from", "origin"],
|
||||
authorField: ["author", "authors", "creator", "writer", "byline", "publisher", "owner"],
|
||||
typeField: ["type", "category", "source_category", "source1"],
|
||||
timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"],
|
||||
scoreField: ["relevance_score", "rerank_score", "score", "similarity"],
|
||||
@@ -107,6 +110,7 @@ export function inferDisplayConfig(samplePayload: unknown, skillName?: string):
|
||||
inferred.summaryField = inferred.summaryField || "content_preview";
|
||||
inferred.contentField = inferred.contentField || "content_preview";
|
||||
inferred.sourceField = inferred.sourceField || "source";
|
||||
inferred.authorField = inferred.authorField || pickField(sample, candidateFields.authorField);
|
||||
inferred.typeField = inferred.typeField || "source_category";
|
||||
inferred.timeField = inferred.timeField || "publish_time";
|
||||
inferred.scoreField = inferred.scoreField || "relevance_score";
|
||||
@@ -127,6 +131,7 @@ function buildDefaultTableColumns(
|
||||
config.titleField,
|
||||
config.typeField,
|
||||
config.sourceField,
|
||||
config.authorField,
|
||||
config.timeField,
|
||||
config.scoreField,
|
||||
].filter((field): field is string => !!field);
|
||||
@@ -141,6 +146,16 @@ function buildDefaultTableColumns(
|
||||
|
||||
function stringify(value: unknown, fallback = ""): string {
|
||||
if (value === null || value === undefined) return fallback;
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map((item) =>
|
||||
typeof item === "string" || typeof item === "number" || typeof item === "boolean"
|
||||
? String(item).trim()
|
||||
: "",
|
||||
)
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? parts.join("、") : fallback;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
@@ -174,6 +189,7 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
|
||||
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
|
||||
content: config.contentField ? stringify(item[config.contentField]) : undefined,
|
||||
source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
|
||||
author: config.authorField ? stringify(item[config.authorField]) : undefined,
|
||||
time: config.timeField ? stringify(item[config.timeField]) : undefined,
|
||||
score:
|
||||
config.scoreField && typeof item[config.scoreField] === "number"
|
||||
|
||||
@@ -106,23 +106,6 @@ function canInferSkillFromToolArgs(toolName: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function canInspectPayloadForDisplay(toolName: string): boolean {
|
||||
const normalized = normalizeName(toolName);
|
||||
if (
|
||||
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(toolName) ||
|
||||
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(normalized)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
canInferSkillFromToolArgs(toolName) ||
|
||||
normalized.includes("search") ||
|
||||
normalized.includes("retriev") ||
|
||||
normalized.includes("knowledge") ||
|
||||
normalized.includes("query")
|
||||
);
|
||||
}
|
||||
|
||||
function extractSkillNameFromArgs(args: Record<string, unknown>): string | undefined {
|
||||
for (const value of Object.values(args)) {
|
||||
if (typeof value !== "string") continue;
|
||||
@@ -164,9 +147,7 @@ export function collectRuntimeDisplaySources(
|
||||
: null;
|
||||
const sources: RuntimeDisplaySource[] = [];
|
||||
let citationIndex = 0;
|
||||
const displayIndexes = new Map<string, number>();
|
||||
const seenCitationKeys = new Map<string, RuntimeDisplaySource>();
|
||||
const seenDisplayKeys = new Set<string>();
|
||||
const consumedToolMessageIds = new Set<string>();
|
||||
const consumedToolMessages = new Set<Message>();
|
||||
|
||||
@@ -224,9 +205,7 @@ export function collectRuntimeDisplaySources(
|
||||
mode: effectiveMode,
|
||||
tableColumns: match?.display.tableColumns,
|
||||
})),
|
||||
displayIndexes,
|
||||
seenCitationKeys,
|
||||
seenDisplayKeys,
|
||||
() => ++citationIndex,
|
||||
);
|
||||
}
|
||||
@@ -247,7 +226,7 @@ export function collectRuntimeDisplaySources(
|
||||
continue;
|
||||
}
|
||||
const toolName = extractToolMessageName(message);
|
||||
const match = null;
|
||||
const match = findDisplayConfigForPayload(toolName, result, displays);
|
||||
const effectiveMode = match?.display.mode ?? "citation";
|
||||
if (allowedModes && !allowedModes.has(effectiveMode)) {
|
||||
continue;
|
||||
@@ -266,9 +245,7 @@ export function collectRuntimeDisplaySources(
|
||||
mode: effectiveMode,
|
||||
tableColumns: match?.display.tableColumns,
|
||||
})),
|
||||
displayIndexes,
|
||||
seenCitationKeys,
|
||||
seenDisplayKeys,
|
||||
() => ++citationIndex,
|
||||
);
|
||||
}
|
||||
@@ -278,9 +255,7 @@ export function collectRuntimeDisplaySources(
|
||||
function appendDisplaySources(
|
||||
target: RuntimeDisplaySource[],
|
||||
drafts: RuntimeDisplaySourceDraft[],
|
||||
displayIndexes: Map<string, number>,
|
||||
seenCitationKeys: Map<string, RuntimeDisplaySource>,
|
||||
seenDisplayKeys: Set<string>,
|
||||
nextCitationIndex: () => number,
|
||||
) {
|
||||
for (const draft of drafts) {
|
||||
@@ -297,17 +272,9 @@ function appendDisplaySources(
|
||||
continue;
|
||||
}
|
||||
|
||||
const stableKey = nonCitationSourceKey(draft);
|
||||
if (seenDisplayKeys.has(stableKey)) {
|
||||
continue;
|
||||
}
|
||||
seenDisplayKeys.add(stableKey);
|
||||
const displayKey = `${draft.skillName}:${draft.mode}`;
|
||||
const displayIndex = (displayIndexes.get(displayKey) ?? 0) + 1;
|
||||
displayIndexes.set(displayKey, displayIndex);
|
||||
target.push({
|
||||
...draft,
|
||||
index: displayIndex,
|
||||
index: nextCitationIndex(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -323,9 +290,6 @@ export function reindexRuntimeDisplaySources(
|
||||
globalSources.map((source) => [citationSourceKey(source), source]),
|
||||
);
|
||||
return sources.map((source) => {
|
||||
if (source.mode !== "citation") {
|
||||
return source;
|
||||
}
|
||||
const global = globalByKey.get(citationSourceKey(source));
|
||||
return global
|
||||
? {
|
||||
@@ -361,10 +325,6 @@ function citationSourceKey(source: RuntimeDisplaySourceDraft): string {
|
||||
return normalizeCitationKey(fallback) || normalizeCitationKey(JSON.stringify(raw).slice(0, 300));
|
||||
}
|
||||
|
||||
function nonCitationSourceKey(source: RuntimeDisplaySourceDraft): string {
|
||||
return `${source.skillName}:${source.mode}:${citationSourceKey(source)}`;
|
||||
}
|
||||
|
||||
function extractRuntimeToolCalls(message: Message): RuntimeToolCall[] {
|
||||
const calls: RuntimeToolCall[] = [];
|
||||
const direct = (message as { tool_calls?: unknown }).tool_calls;
|
||||
@@ -441,20 +401,10 @@ function normalizeToolArgs(value: unknown): Record<string, unknown> | undefined
|
||||
|
||||
export function findDisplayConfigForPayload(
|
||||
toolName: string,
|
||||
payload: unknown,
|
||||
_payload: unknown,
|
||||
displays: SkillDisplayMap,
|
||||
): { skillName: string; display: SkillDisplayConfig } | null {
|
||||
if (!canInspectPayloadForDisplay(toolName)) {
|
||||
return null;
|
||||
}
|
||||
let best: { skillName: string; display: SkillDisplayConfig; score: number } | null = null;
|
||||
for (const [skillName, display] of displays) {
|
||||
const score = scoreDisplayPayloadMatch(payload, display);
|
||||
if (score >= 3 && (!best || score > best.score)) {
|
||||
best = { skillName, display, score };
|
||||
}
|
||||
}
|
||||
return best ? { skillName: best.skillName, display: best.display } : null;
|
||||
return findDisplayConfigForTool(toolName, displays);
|
||||
}
|
||||
|
||||
export function isUnconfiguredSkillInvocation(
|
||||
@@ -498,33 +448,6 @@ export function isDisplaySamplePayload(payload: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function scoreDisplayPayloadMatch(payload: unknown, display: SkillDisplayConfig): number {
|
||||
const items = getArrayByPath(payload, display.resultPath);
|
||||
const first = items?.find((item): item is Record<string, unknown> => isRecord(item));
|
||||
if (!first) {
|
||||
return 0;
|
||||
}
|
||||
const fields = [
|
||||
display.titleField,
|
||||
display.summaryField,
|
||||
display.contentField,
|
||||
display.sourceField,
|
||||
display.typeField,
|
||||
display.timeField,
|
||||
display.scoreField,
|
||||
display.urlField,
|
||||
display.idField,
|
||||
].filter((field): field is string => !!field);
|
||||
return new Set(fields).size === 0
|
||||
? 0
|
||||
: [...new Set(fields)].filter(
|
||||
(field) =>
|
||||
first[field] !== undefined &&
|
||||
first[field] !== null &&
|
||||
first[field] !== "",
|
||||
).length;
|
||||
}
|
||||
|
||||
function getArrayByPath(payload: unknown, path: string): unknown[] | null {
|
||||
const value = path.split(".").reduce<unknown>((acc, key) => {
|
||||
if (acc && typeof acc === "object") {
|
||||
@@ -576,6 +499,7 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
|
||||
snippet: item.snippet ?? item.content ?? "",
|
||||
content: item.content ?? item.snippet,
|
||||
source: item.source,
|
||||
author: item.author,
|
||||
time: item.published_date ?? item.publishedDate ?? item.date,
|
||||
url: item.url,
|
||||
raw: item as unknown as Record<string, unknown>,
|
||||
@@ -589,14 +513,14 @@ export function applyInlineCitations(
|
||||
if (!content || sources.length === 0) {
|
||||
return content;
|
||||
}
|
||||
const citationSources = sources.filter((source) => source.mode === "citation");
|
||||
if (citationSources.length === 0) {
|
||||
const referenceSources = sources.filter((source) => source.mode !== "none");
|
||||
if (referenceSources.length === 0) {
|
||||
return content;
|
||||
}
|
||||
const byIndex = new Map(citationSources.map((source) => [source.index, source]));
|
||||
const byIndex = new Map(referenceSources.map((source) => [source.index, source]));
|
||||
const byUrl = new Map<string, RuntimeDisplaySource>();
|
||||
const byTitle = new Map<string, RuntimeDisplaySource>();
|
||||
for (const source of citationSources) {
|
||||
for (const source of referenceSources) {
|
||||
if (source.url) {
|
||||
byUrl.set(source.url, source);
|
||||
}
|
||||
@@ -610,7 +534,7 @@ export function applyInlineCitations(
|
||||
byIndex,
|
||||
byUrl,
|
||||
byTitle,
|
||||
citationSources,
|
||||
referenceSources,
|
||||
);
|
||||
|
||||
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
|
||||
@@ -622,7 +546,7 @@ export function applyInlineCitations(
|
||||
const replacedSlice = replaceCitationTokens(
|
||||
normalized.slice(cursor, start),
|
||||
byIndex,
|
||||
citationSources,
|
||||
referenceSources,
|
||||
);
|
||||
if (replacedSlice !== normalized.slice(cursor, start)) {
|
||||
replaced = true;
|
||||
@@ -631,7 +555,7 @@ export function applyInlineCitations(
|
||||
next += match[0];
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
const tail = replaceCitationTokens(normalized.slice(cursor), byIndex, citationSources);
|
||||
const tail = replaceCitationTokens(normalized.slice(cursor), byIndex, referenceSources);
|
||||
if (tail !== normalized.slice(cursor)) {
|
||||
replaced = true;
|
||||
}
|
||||
@@ -639,7 +563,7 @@ export function applyInlineCitations(
|
||||
if (replaced || hasCitationMarkdown(next)) {
|
||||
return next;
|
||||
}
|
||||
return appendCitationSummary(next, citationSources);
|
||||
return appendCitationSummary(next, referenceSources);
|
||||
}
|
||||
|
||||
function replaceCitationTokens(
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface SkillDisplayConfig {
|
||||
summaryField?: string | null;
|
||||
contentField?: string | null;
|
||||
sourceField?: string | null;
|
||||
authorField?: string | null;
|
||||
typeField?: string | null;
|
||||
timeField?: string | null;
|
||||
scoreField?: string | null;
|
||||
|
||||
@@ -89,10 +89,15 @@ export function useNotebookChat(
|
||||
|
||||
const send = async (text: string, selectedSourceIds: number[]) => {
|
||||
if (thread.isLoading || !token) return;
|
||||
// API 需要 document.id,而不是 source.id
|
||||
const toDocIds = (ids: number[]) =>
|
||||
ids
|
||||
.map((id) => sources.find((s) => s.id === id)?.document?.id)
|
||||
.filter((id): id is number => id !== undefined);
|
||||
const sourceIds =
|
||||
selectedSourceIds.length > 0
|
||||
? selectedSourceIds
|
||||
: sources.map((s) => s.id).filter(Boolean);
|
||||
? toDocIds(selectedSourceIds)
|
||||
: sources.map((s) => s.document?.id).filter((id): id is number => id !== undefined);
|
||||
|
||||
await thread.submit(
|
||||
{ messages: [{ type: "human", content: text }] },
|
||||
|
||||
@@ -11,11 +11,12 @@ import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { getAPIClient } from "../api";
|
||||
import { apiFetch } from "../api";
|
||||
import { getStoredAuth } from "../auth";
|
||||
import { rememberAskTime } from "../chat-timestamps";
|
||||
import { getBackendBaseURL } from "../config";
|
||||
import { useI18n } from "../i18n/hooks";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings";
|
||||
import { useUpdateSubtask } from "../tasks/context";
|
||||
import type { UploadedFileInfo } from "../uploads";
|
||||
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||
@@ -343,7 +344,7 @@ export function useThreadStream({
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,正在为你刷新页面…`,
|
||||
);
|
||||
// Prefer staying inside an agent chat context when the user is on an
|
||||
// agent thread URL — otherwise fall back to the global new chat.
|
||||
@@ -352,7 +353,18 @@ export function useThreadStream({
|
||||
const target = agentMatch
|
||||
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
|
||||
: "/page/workspace/chats/new";
|
||||
navigate(target, { replace: true });
|
||||
// First navigate to a safe URL so the post-reload page lands on a
|
||||
// valid route. Then full-reload so TanStack Query cache, context
|
||||
// providers and any other state captured under the previous user's
|
||||
// identity get rebuilt against the current login.
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.hash = target;
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 900);
|
||||
} else {
|
||||
navigate(target, { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
toast.error(message);
|
||||
@@ -367,6 +379,13 @@ export function useThreadStream({
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const sendInFlightRef = useRef(false);
|
||||
// Carries the ask time of an in-flight send until the server-assigned human
|
||||
// message id shows up, so we can bind the timestamp to the real id (see the
|
||||
// reconciliation effect below). ``known`` is the set of human ids that
|
||||
// already existed at send time, used to spot the newly-arrived one.
|
||||
const pendingAskTimeRef = useRef<{ ts: string; known: Set<string> } | null>(
|
||||
null,
|
||||
);
|
||||
// Track message count before sending so we know when server has responded
|
||||
const prevMsgCountRef = useRef(thread.messages.length);
|
||||
|
||||
@@ -418,6 +437,26 @@ export function useThreadStream({
|
||||
}
|
||||
}, [thread.messages.length, optimisticMessages.length]);
|
||||
|
||||
// Bind a just-sent question's ask time to the server-assigned human message
|
||||
// id once it appears. Persists to localStorage so the time shows on every
|
||||
// question and survives reloads, independent of the backend echoing it back.
|
||||
useEffect(() => {
|
||||
const pending = pendingAskTimeRef.current;
|
||||
if (!pending) return;
|
||||
for (const message of thread.messages) {
|
||||
if (
|
||||
message.type === "human" &&
|
||||
message.id &&
|
||||
!message.id.startsWith("opt-") &&
|
||||
!pending.known.has(message.id)
|
||||
) {
|
||||
rememberAskTime(message.id, pending.ts);
|
||||
pendingAskTimeRef.current = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [thread.messages]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
threadId: string,
|
||||
@@ -431,6 +470,20 @@ export function useThreadStream({
|
||||
sendInFlightRef.current = true;
|
||||
|
||||
const text = message.text.trim();
|
||||
// Wall-clock time the question was asked. Stored on the human message so
|
||||
// the chat can show "几点提问" and have it survive a reload (it round-trips
|
||||
// through the checkpoint inside additional_kwargs, same as `files`).
|
||||
const clientTs = new Date().toISOString();
|
||||
// Remember which human messages already exist so the reconciliation
|
||||
// effect can bind ``clientTs`` to the new one once the server returns it.
|
||||
pendingAskTimeRef.current = {
|
||||
ts: clientTs,
|
||||
known: new Set(
|
||||
thread.messages
|
||||
.filter((m) => m.type === "human" && m.id)
|
||||
.map((m) => m.id as string),
|
||||
),
|
||||
};
|
||||
|
||||
// Capture current count before showing optimistic messages
|
||||
prevMsgCountRef.current = thread.messages.length;
|
||||
@@ -447,6 +500,7 @@ export function useThreadStream({
|
||||
const hideFromUI = options?.additionalKwargs?.hide_from_ui === true;
|
||||
const optimisticAdditionalKwargs = {
|
||||
...options?.additionalKwargs,
|
||||
client_ts: clientTs,
|
||||
...(optimisticFiles.length > 0 ? { files: optimisticFiles } : {}),
|
||||
};
|
||||
|
||||
@@ -524,7 +578,10 @@ export function useThreadStream({
|
||||
return [
|
||||
{
|
||||
...humanMessage,
|
||||
additional_kwargs: { files: uploadedFiles },
|
||||
additional_kwargs: {
|
||||
...humanMessage.additional_kwargs,
|
||||
files: uploadedFiles,
|
||||
},
|
||||
},
|
||||
...messages.slice(1),
|
||||
];
|
||||
@@ -555,13 +612,13 @@ export function useThreadStream({
|
||||
}),
|
||||
);
|
||||
|
||||
const ragModeEnabled =
|
||||
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
await thread.submit(
|
||||
const ragModeEnabled =
|
||||
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
await thread.submit(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
@@ -574,6 +631,7 @@ export function useThreadStream({
|
||||
],
|
||||
additional_kwargs: {
|
||||
...options?.additionalKwargs,
|
||||
client_ts: clientTs,
|
||||
...(filesForSubmit.length > 0
|
||||
? { files: filesForSubmit }
|
||||
: {}),
|
||||
@@ -604,10 +662,10 @@ export function useThreadStream({
|
||||
? "low"
|
||||
: undefined),
|
||||
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
||||
rag_mode_enabled: ragModeEnabled,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
rag_mode_enabled: ragModeEnabled,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
} catch (error) {
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function ChatsPage() {
|
||||
return threads?.filter((thread) => {
|
||||
return (
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "roundtable" &&
|
||||
titleOfThread(thread).toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useMemo, useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
|
||||
import { PageSidebar } from "@/components/page-sidebar";
|
||||
import { PageSidebarDark } from "@/components/page-sidebar-dark";
|
||||
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
import WorkspaceRoutes from "./WorkspaceRoutes";
|
||||
import StrategyRoutes from "./StrategyRoutes";
|
||||
@@ -13,16 +15,25 @@ import RoundtableRoutes from "./RoundtableRoutes";
|
||||
function PageLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-muted/20">
|
||||
{sidebarEnabled && <Header collapsed={collapsed} />}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{sidebarEnabled && (
|
||||
<PageSidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
|
||||
/>
|
||||
isDark ? (
|
||||
<PageSidebarDark
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
|
||||
/>
|
||||
) : (
|
||||
<PageSidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<main
|
||||
className={`flex-1 overflow-hidden transition-[padding] duration-300 ${
|
||||
|
||||
@@ -84,6 +84,7 @@ const emptyForm = {
|
||||
timezone: "Asia/Shanghai",
|
||||
prompt: "",
|
||||
execution_agent_name: "",
|
||||
require_markdown: false,
|
||||
};
|
||||
|
||||
export default function ScheduledTasksPage() {
|
||||
@@ -173,6 +174,7 @@ export default function ScheduledTasksPage() {
|
||||
timezone: task.timezone,
|
||||
prompt: task.prompt,
|
||||
execution_agent_name: task.execution_agent_name ?? "",
|
||||
require_markdown: !!task.execution_context?.require_markdown,
|
||||
});
|
||||
// Prefer the canonical cron_expr over the user-typed schedule_text: the
|
||||
// latter may be natural language ("每天9点") that parseCron can't read,
|
||||
@@ -202,6 +204,7 @@ export default function ScheduledTasksPage() {
|
||||
timezone: form.timezone || "Asia/Shanghai",
|
||||
prompt: form.prompt,
|
||||
execution_agent_name: form.execution_agent_name || null,
|
||||
require_markdown: form.require_markdown,
|
||||
});
|
||||
setSelectedTaskId(task.task_id);
|
||||
toast.success("任务已创建");
|
||||
@@ -215,6 +218,7 @@ export default function ScheduledTasksPage() {
|
||||
timezone: form.timezone,
|
||||
prompt: form.prompt,
|
||||
execution_agent_name: form.execution_agent_name || null,
|
||||
require_markdown: form.require_markdown,
|
||||
},
|
||||
});
|
||||
toast.success("任务已更新");
|
||||
@@ -825,6 +829,20 @@ export default function ScheduledTasksPage() {
|
||||
}
|
||||
placeholder="任务 Prompt"
|
||||
/>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="min-w-0 pr-3">
|
||||
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.require_markdown}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((value) => ({ ...value, require_markdown: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
|
||||
@@ -28,151 +28,172 @@ import TagsPage from "./TagsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
import LightAppNoteQAPage from "./LightAppNoteQAPage";
|
||||
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
|
||||
import TaskManagement from "@/strategy-components/components/task-components/TaskManagement";
|
||||
|
||||
export default function StrategyRoutes() {
|
||||
return (
|
||||
<RouteBaseProvider value="/page/strategy">
|
||||
<SidebarProvider>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="qa/general" replace />} />
|
||||
<SidebarProvider>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="qa/general" replace />} />
|
||||
|
||||
{/* 问答中心 */}
|
||||
<Route path="qa/general" element={<GeneralQA />} />
|
||||
{/* 问答中心 */}
|
||||
<Route path="qa/general" element={<GeneralQA />} />
|
||||
{/* 任务管理 */}
|
||||
<Route path="task-management" element={<TaskManagement />}>
|
||||
{/* XK任务 */}
|
||||
<Route
|
||||
path="xk-task-jc"
|
||||
element={
|
||||
<TaskXkFrom
|
||||
gopath={'#/pageIndie/pageHome/pageSwrw'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="xk-task-dispatch"
|
||||
element={
|
||||
<TaskXkFrom
|
||||
gopath={'#/pageIndie/pageJczl'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
{/* 轻应用 */}
|
||||
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
|
||||
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
|
||||
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
|
||||
|
||||
{/* 轻应用 */}
|
||||
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
|
||||
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
|
||||
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
|
||||
{/* 对话 */}
|
||||
<Route path="chats" element={<ChatsPage />} />
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 对话 */}
|
||||
<Route path="chats" element={<ChatsPage />} />
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
{/* 智能体管理 */}
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route path="agents/new" element={<NewAgentPage />} />
|
||||
<Route
|
||||
path="agents/:agent_id/chats"
|
||||
element={<Navigate to="/page/strategy/agents" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<AgentChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 智能体管理 */}
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route path="agents/new" element={<NewAgentPage />} />
|
||||
<Route
|
||||
path="agents/:agent_id/chats"
|
||||
element={<Navigate to="/page/strategy/agents" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<AgentChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
{/* 技能 / 记忆 */}
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
||||
<Route path="memory" element={<MemoryPage />} />
|
||||
|
||||
{/* 技能 / 记忆 */}
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
||||
<Route path="memory" element={<MemoryPage />} />
|
||||
{/* 定时任务 */}
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
|
||||
/>
|
||||
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} />
|
||||
<Route
|
||||
path="scheduled/runs/:run_id"
|
||||
element={<ScheduledTaskRunDetailPage />}
|
||||
/>
|
||||
|
||||
{/* 定时任务 */}
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
|
||||
/>
|
||||
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} />
|
||||
<Route
|
||||
path="scheduled/runs/:run_id"
|
||||
element={<ScheduledTaskRunDetailPage />}
|
||||
/>
|
||||
|
||||
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<RecommendedQuestionsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/prompt-prefix"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<PromptPrefixSettingsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/private-square"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<PrivateSquareSettingsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<LlmMetricsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<UsersPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<AdminScheduledTasksPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-activity"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<AdminUserActivityPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<TagsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
<Toaster position="top-center" />
|
||||
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<RecommendedQuestionsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/prompt-prefix"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<PromptPrefixSettingsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/private-square"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<PrivateSquareSettingsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<LlmMetricsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<UsersPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<AdminScheduledTasksPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-activity"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<AdminUserActivityPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<TagsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
<Toaster position="top-center" />
|
||||
</RouteBaseProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
AlignLeft,
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { MessageList } from "@/components/workspace/messages/message-list";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ChatBox } from "@/components/workspace/chats/chat-box";
|
||||
import { rehypeCitations } from "@/core/rehype";
|
||||
|
||||
// ── 来源卡片图标 ────────────────────────────────────────
|
||||
@@ -189,6 +191,7 @@ export default function StudioNotebookDetailPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [sourcesOpen, setSourcesOpen] = useState(false);
|
||||
const [selectedSourceIds, setSelectedSourceIds] = useState<number[]>([]);
|
||||
const sourcesInitializedRef = useRef(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [input, setInput] = useState("");
|
||||
// null = 显示历史线程列表;string = 显示该线程的聊天
|
||||
@@ -206,6 +209,13 @@ export default function StudioNotebookDetailPage() {
|
||||
const notebook = notebooks.find((nb) => nb.id === Number(notebookId));
|
||||
|
||||
const { sources, isLoading: isLoadingSources } = useNotebookSources(notebook?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (sources.length > 0 && !sourcesInitializedRef.current) {
|
||||
sourcesInitializedRef.current = true;
|
||||
setSelectedSourceIds(sources.map((s) => s.id));
|
||||
}
|
||||
}, [sources]);
|
||||
const citationRehypePlugins = useMemo(() => {
|
||||
const mapped = sources.map((s) => ({
|
||||
id: s.id,
|
||||
@@ -268,6 +278,8 @@ export default function StudioNotebookDetailPage() {
|
||||
const isChatView = activeThreadId !== null || hasMessages;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={chatThreadId ?? ""}>
|
||||
<div className="relative flex h-full flex-col overflow-hidden bg-background">
|
||||
{/* 背景渐变(列表视图时显示) */}
|
||||
{!isChatView && (
|
||||
@@ -490,5 +502,7 @@ export default function StudioNotebookDetailPage() {
|
||||
onToggle={handleToggleSource}
|
||||
/>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export interface StreamRecommendResult {
|
||||
* `done` → picks parsed and validated, ready to populate the dialog as
|
||||
* default-checked selections.
|
||||
* `asking` → recommender failed to emit a usable [RECOMMEND_READY] block,
|
||||
* caller should pick from the live candidate pool as fallback.
|
||||
* caller should apply its fallback list (typically the 6 roundtable-* seats).
|
||||
* `error` → upstream / network failure.
|
||||
*/
|
||||
status: "done" | "asking" | "error";
|
||||
|
||||
@@ -202,6 +202,8 @@ export interface ReasoningBlockProps {
|
||||
/** 用于显示「思考用时」的可选时长(秒)。流式期间不显示。 */
|
||||
durationSec?: number;
|
||||
className?: string;
|
||||
/** 展开内容区额外 class,例如 max-h + overflow-y-auto。 */
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
const REASONING_AUTO_CLOSE_DELAY_MS = 1000;
|
||||
@@ -211,6 +213,7 @@ export function ReasoningBlock({
|
||||
streaming = false,
|
||||
durationSec,
|
||||
className,
|
||||
contentClassName,
|
||||
}: ReasoningBlockProps) {
|
||||
// open 初始值跟 streaming 同步:流式中默认展开方便观察思考过程,完成后会自动收起一次。
|
||||
const [open, setOpen] = useState<boolean>(streaming);
|
||||
@@ -260,7 +263,12 @@ export function ReasoningBlock({
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[11.5px] text-slate-600 leading-relaxed">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[11.5px] text-slate-600 leading-relaxed",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<MarkdownContent>{children}</MarkdownContent>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface MessageStepsCardProps {
|
||||
className?: string;
|
||||
/** 点击 write_file / present_files 等产物相关步骤时打开沙箱。 */
|
||||
onArtifactStepClick?: (step: BubbleStep) => void;
|
||||
/** 把 agent_orchestration 派活的 agent id 解析成中文席位名(用于 chip 显示)。 */
|
||||
resolveAgentName?: (idOrName: string) => string;
|
||||
}
|
||||
|
||||
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
|
||||
@@ -58,6 +60,7 @@ export function MessageStepsCard({
|
||||
streaming,
|
||||
className,
|
||||
onArtifactStepClick,
|
||||
resolveAgentName,
|
||||
}: MessageStepsCardProps) {
|
||||
// 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` /
|
||||
// `lastReasoningStep` 划分对齐。
|
||||
@@ -131,12 +134,14 @@ export function MessageStepsCard({
|
||||
step={s}
|
||||
status="complete"
|
||||
onArtifactStepClick={onArtifactStepClick}
|
||||
resolveAgentName={resolveAgentName}
|
||||
/>
|
||||
))}
|
||||
<ToolStep
|
||||
step={lastToolStep}
|
||||
status={streaming && trailingReasoning.length === 0 ? "active" : "complete"}
|
||||
onArtifactStepClick={onArtifactStepClick}
|
||||
resolveAgentName={resolveAgentName}
|
||||
/>
|
||||
</ChainOfThoughtContent>
|
||||
)}
|
||||
@@ -155,18 +160,23 @@ function ToolStep({
|
||||
step,
|
||||
status,
|
||||
onArtifactStepClick,
|
||||
resolveAgentName,
|
||||
}: {
|
||||
step: BubbleStep;
|
||||
status: "complete" | "active";
|
||||
onArtifactStepClick?: (step: BubbleStep) => void;
|
||||
resolveAgentName?: (idOrName: string) => string;
|
||||
}) {
|
||||
const display = getStepDisplay(step.key, step.label, step.args);
|
||||
const argNode = renderStepArgs(step.key, step.args);
|
||||
const argNode = renderStepArgs(step.key, step.args, resolveAgentName);
|
||||
const [, toolName] = step.key.split(":");
|
||||
// read_file 也可点 —— 验证类「查看文件…」步骤常出现在最后一条,点击后在沙箱里
|
||||
// 查看该文件内容(走虚拟预览或 outputs HTTP)。主聊天只让 write 类可点,这里增强。
|
||||
const isArtifactStep =
|
||||
toolName === "write_file" ||
|
||||
toolName === "str_replace" ||
|
||||
toolName === "present_files";
|
||||
toolName === "present_files" ||
|
||||
toolName === "read_file";
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
|
||||
@@ -62,6 +62,20 @@ export interface RecommendAgentsDialogProps {
|
||||
const MIN_SELECTED = 2;
|
||||
const MAX_SELECTED = 8;
|
||||
|
||||
/**
|
||||
* Default selection when the recommender fails or returns < 2 valid picks.
|
||||
* Intersected with the live candidate pool at runtime so missing built-ins
|
||||
* don't break the dialog.
|
||||
*/
|
||||
const FALLBACK_AGENT_IDS = [
|
||||
"roundtable-intelligence",
|
||||
"roundtable-environment",
|
||||
"roundtable-solution-design",
|
||||
"roundtable-risk-review",
|
||||
"roundtable-execution-plan",
|
||||
"roundtable-summary",
|
||||
];
|
||||
|
||||
type DialogPhase =
|
||||
| "idle"
|
||||
| "loading_agents"
|
||||
@@ -305,17 +319,12 @@ export function RecommendAgentsDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: status === "asking" or picks too few — pick from live pool.
|
||||
const seed = candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
|
||||
if (seed.length < MIN_SELECTED) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "error",
|
||||
streamPhase: null,
|
||||
errorMessage: `候选智能体不足 ${MIN_SELECTED} 个,无法进入圆桌研讨。请先发布更多智能体。`,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
// Fallback: status === "asking" or picks too few. Use the 6 built-in
|
||||
// roundtable seats, intersected against the live pool.
|
||||
const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates);
|
||||
const seed = fallback.length >= MIN_SELECTED
|
||||
? fallback
|
||||
: candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
|
||||
setSelectedIds(new Set(seed));
|
||||
const { rationale } = parseRecommendStreamContent(result.content || "");
|
||||
setState((prev) => ({
|
||||
@@ -325,7 +334,7 @@ export function RecommendAgentsDialog({
|
||||
recommendedIds: seed,
|
||||
reasons,
|
||||
streamPhase: null,
|
||||
warning: "智能推荐未生成有效结果,已为你勾选候选池中的前几位智能体,可手动调整。",
|
||||
warning: "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。",
|
||||
}));
|
||||
} catch (err) {
|
||||
if (myGen !== generationRef.current) return;
|
||||
@@ -421,8 +430,8 @@ export function RecommendAgentsDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col overflow-hidden gap-3">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<SparklesIcon className="size-5 text-purple-500" />
|
||||
选择参会智能体
|
||||
@@ -457,7 +466,7 @@ export function RecommendAgentsDialog({
|
||||
<CheckIcon className="size-3 text-emerald-500" />
|
||||
推荐参会智能体
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar pr-1 -mr-1 space-y-4">
|
||||
{state.phase === "error" ? (
|
||||
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
|
||||
{state.errorMessage ?? "未知错误"}
|
||||
@@ -527,7 +536,7 @@ export function RecommendAgentsDialog({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap">
|
||||
<DialogFooter className="shrink-0 flex-row sm:justify-between items-center gap-2 flex-wrap border-t pt-3 mt-1">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
@@ -635,14 +644,14 @@ function CollapsibleModelOutput({
|
||||
})();
|
||||
|
||||
return (
|
||||
<section className="border rounded-md bg-muted/20 shrink-0">
|
||||
<section className="border rounded-md bg-muted/20 shrink-0 max-h-[38vh] flex flex-col min-h-0 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
userTouchedRef.current = true;
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
|
||||
className="w-full shrink-0 flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="size-3 text-purple-500 shrink-0 animate-spin" />
|
||||
@@ -667,7 +676,11 @@ function CollapsibleModelOutput({
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open && <div className="px-3 pb-3">{children}</div>}
|
||||
{open && (
|
||||
<div className="px-3 pb-3 min-h-0 flex-1 overflow-y-auto custom-scrollbar">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -736,7 +749,12 @@ function RecommenderOutput({ state }: { state: InternalState }) {
|
||||
)}
|
||||
|
||||
{hasReasoning && (
|
||||
<ReasoningBlock streaming={isStreaming}>{reasoning!}</ReasoningBlock>
|
||||
<ReasoningBlock
|
||||
streaming={isStreaming}
|
||||
contentClassName="max-h-48 overflow-y-auto custom-scrollbar"
|
||||
>
|
||||
{reasoning!}
|
||||
</ReasoningBlock>
|
||||
)}
|
||||
|
||||
{!hasRationale && !hasReasoning && isStreaming && (
|
||||
@@ -744,7 +762,7 @@ function RecommenderOutput({ state }: { state: InternalState }) {
|
||||
)}
|
||||
|
||||
{hasRationale && (
|
||||
<div className="max-h-72 overflow-y-auto rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
|
||||
<div className="max-h-40 overflow-y-auto custom-scrollbar rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
|
||||
<MarkdownContent>{cleanedRationale}</MarkdownContent>
|
||||
{isStreaming && <StreamingCursor />}
|
||||
</div>
|
||||
|
||||
@@ -353,7 +353,13 @@ function Step1Message({
|
||||
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md">
|
||||
{introText && <MarkdownContent>{introText}</MarkdownContent>}
|
||||
{parts.summary && <IntentReadySummaryCard summary={parts.summary} />}
|
||||
{parts.after && <MarkdownContent>{parts.after}</MarkdownContent>}
|
||||
{/*
|
||||
parts.after 是模型违反 SOUL §0「硬规则 3」在 [INTENT_READY] 块后
|
||||
继续输出的内容(典型:"那么我先帮你列一下大纲……")。后端 intent.py
|
||||
的终态帧已经把 content 截断到 marker 结尾,但流式期间 LangGraph
|
||||
messages-tuple 还是会把那段废话逐 token 推过来——故意在 UI 层
|
||||
把它丢弃,让用户看到的就是"复述 + 摘要卡",干净结束。
|
||||
*/}
|
||||
{isStreaming && <StreamingCursor />}
|
||||
{isPendingClarification && clarification && (
|
||||
<IntentClarificationCard
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*
|
||||
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
Cpu,
|
||||
FileIcon,
|
||||
@@ -67,6 +67,11 @@ export interface Step2PanelProps {
|
||||
agentConfigs: Record<number, AgentConfig>;
|
||||
selectedAgentId: number | null;
|
||||
roles: RoleEntry[];
|
||||
/** agent id(小写)→ 席位元数据,用于把派活 chip 的 id 显示成中文席位名。 */
|
||||
agentNameToRole: Record<
|
||||
string,
|
||||
{ speakerId: number; avatarType: string; displayName: string }
|
||||
>;
|
||||
selectedModel: string;
|
||||
availableModels: ModelOption[];
|
||||
modelsLoading: boolean;
|
||||
@@ -89,7 +94,7 @@ export interface Step2PanelProps {
|
||||
// ─── Handlers ─────────────────────────────────────────────────
|
||||
onPauseRoundtable: () => void;
|
||||
onResumeRoundtable: () => void;
|
||||
onRecordInjectMessage: () => void;
|
||||
onHandleInjectToRoundtable: () => void;
|
||||
onSubmitClarification: (answer: string) => void;
|
||||
onSavePersona: (id: number) => void;
|
||||
onProceedToStep3: () => void;
|
||||
@@ -109,6 +114,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
agentConfigs,
|
||||
selectedAgentId,
|
||||
roles,
|
||||
agentNameToRole,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
@@ -123,7 +129,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
setSelectedAgentId,
|
||||
onPauseRoundtable,
|
||||
onResumeRoundtable,
|
||||
onRecordInjectMessage,
|
||||
onHandleInjectToRoundtable,
|
||||
onSubmitClarification,
|
||||
onSavePersona,
|
||||
onProceedToStep3,
|
||||
@@ -134,24 +140,20 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
|
||||
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
|
||||
|
||||
// 干预研审 → 暂停会商:与右上角按钮共用 onPauseRoundtable;乐观 UI 避免等父 state 刷新。
|
||||
const [pauseFlash, setPauseFlash] = useState(false);
|
||||
const [optimisticPaused, setOptimisticPaused] = useState(false);
|
||||
const showPausedUi = !isRoundTableRunning || optimisticPaused;
|
||||
const resolveAgentName = useCallback(
|
||||
(idOrName: string) =>
|
||||
agentNameToRole[idOrName.toLowerCase()]?.displayName ?? idOrName,
|
||||
[agentNameToRole],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRoundTableRunning) {
|
||||
setOptimisticPaused(false);
|
||||
}
|
||||
}, [isRoundTableRunning]);
|
||||
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
|
||||
const [pauseFlash, setPauseFlash] = useState(false);
|
||||
|
||||
const handleInject = useCallback(() => {
|
||||
setOptimisticPaused(true);
|
||||
onPauseRoundtable();
|
||||
onRecordInjectMessage();
|
||||
onHandleInjectToRoundtable();
|
||||
setPauseFlash(true);
|
||||
window.setTimeout(() => setPauseFlash(false), 1600);
|
||||
}, [onPauseRoundtable, onRecordInjectMessage]);
|
||||
}, [onHandleInjectToRoundtable]);
|
||||
|
||||
const isSandboxStreaming =
|
||||
isOrchestrating ||
|
||||
@@ -196,7 +198,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{showPausedUi ? (
|
||||
{!isRoundTableRunning ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResumeRoundtable}
|
||||
@@ -255,6 +257,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
<Step2Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
resolveAgentName={resolveAgentName}
|
||||
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
||||
onFileCardClick={(path) => {
|
||||
if (msg.agentThreadId) {
|
||||
@@ -354,10 +357,10 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInject}
|
||||
title="暂停会商并记录干预指令;点击右上角「恢复会商」继续执行"
|
||||
title="记录干预指令并立即恢复会商执行"
|
||||
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
|
||||
>
|
||||
干预研审
|
||||
干预并发送
|
||||
</button>
|
||||
|
||||
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
|
||||
@@ -406,6 +409,8 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
*/
|
||||
interface Step2MessageProps {
|
||||
msg: Step2Dialogue;
|
||||
/** 把派活 chip 的 agent id 解析成中文席位名。 */
|
||||
resolveAgentName?: (idOrName: string) => string;
|
||||
onArtifactStepClick?: (
|
||||
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||
) => void;
|
||||
@@ -415,6 +420,7 @@ interface Step2MessageProps {
|
||||
|
||||
function Step2Message({
|
||||
msg,
|
||||
resolveAgentName,
|
||||
onArtifactStepClick,
|
||||
onFileCardClick,
|
||||
}: Step2MessageProps) {
|
||||
@@ -445,6 +451,7 @@ function Step2Message({
|
||||
steps={msg.steps}
|
||||
streaming={isStreaming}
|
||||
onArtifactStepClick={onArtifactStepClick}
|
||||
resolveAgentName={resolveAgentName}
|
||||
/>
|
||||
)}
|
||||
{reasoning && (
|
||||
@@ -469,8 +476,13 @@ function Step2Message({
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描所有 `tool_calling:present_files` 步骤里的 `filepaths`,按出现顺序去重。
|
||||
* 同一气泡可能多次 present_files(总控分批交付),都聚合到一行卡片里。
|
||||
* 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。
|
||||
*
|
||||
* 三类来源都算:
|
||||
* - `present_files`:总控呈现文件,args.filepaths 是数组(可一次多个);
|
||||
* - `write_file` / `str_replace`:子智能体被后端 excluded_tools 禁用了 present_files,
|
||||
* 只能 write_file 写文件,单文件 path 在 args.path —— 也聚合进卡片,保证"凡是写了
|
||||
* 文件就出可点的卡片",而不是只有 present_files 才出。
|
||||
*/
|
||||
function collectPresentedFiles(
|
||||
steps: Step2Dialogue["steps"],
|
||||
@@ -478,14 +490,22 @@ function collectPresentedFiles(
|
||||
if (!Array.isArray(steps) || steps.length === 0) return [];
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (fp: unknown) => {
|
||||
if (typeof fp !== "string" || !fp || seen.has(fp)) return;
|
||||
seen.add(fp);
|
||||
out.push(fp);
|
||||
};
|
||||
for (const step of steps) {
|
||||
if (step.key !== "tool_calling:present_files") continue;
|
||||
const fps = step.args?.filepaths;
|
||||
if (!Array.isArray(fps)) continue;
|
||||
for (const fp of fps) {
|
||||
if (typeof fp !== "string" || !fp || seen.has(fp)) continue;
|
||||
seen.add(fp);
|
||||
out.push(fp);
|
||||
if (step.key === "tool_calling:present_files") {
|
||||
const fps = step.args?.filepaths;
|
||||
if (Array.isArray(fps)) fps.forEach(add);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
step.key === "tool_calling:write_file" ||
|
||||
step.key === "tool_calling:str_replace"
|
||||
) {
|
||||
add(step.args?.path);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -50,11 +50,12 @@ export function Step2SandboxLayout({
|
||||
}: Step2SandboxLayoutProps) {
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const {
|
||||
artifacts,
|
||||
artifacts: rawArtifacts,
|
||||
open: artifactsOpen,
|
||||
setOpen: setArtifactsOpen,
|
||||
selectedArtifact,
|
||||
} = useArtifacts();
|
||||
const artifacts = rawArtifacts ?? [];
|
||||
|
||||
const artifactPanelOpen = artifactsOpen;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import {
|
||||
DEFAULT_SELECTED_AGENTS,
|
||||
MAX_CYCLES,
|
||||
buildAgentNameToRole,
|
||||
buildRoleTemplates,
|
||||
@@ -98,8 +99,18 @@ export type Step2Snapshot = {
|
||||
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||
};
|
||||
|
||||
/** 席位 Persona 配置;初始为空,用户在 Persona 抽屉保存后写入。 */
|
||||
const DEFAULT_AGENT_CONFIGS: Record<number, AgentConfig> = {};
|
||||
/**
|
||||
* 6 个默认席位的初始 Persona 配置。model 留空 → 运行时 `agentConfigs[seat]?.model
|
||||
* || selectedModel || undefined`,即「席位覆盖优先,否则跟随全局模型」。
|
||||
*/
|
||||
const DEFAULT_AGENT_CONFIGS: Record<number, AgentConfig> = {
|
||||
2: { model: "", weight: 90, temp: 0.2, prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。" },
|
||||
3: { model: "", weight: 85, temp: 0.3, prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。" },
|
||||
4: { model: "", weight: 88, temp: 0.1, prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。" },
|
||||
5: { model: "", weight: 92, temp: 0.4, prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。" },
|
||||
6: { model: "", weight: 95, temp: 0.1, prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。" },
|
||||
7: { model: "", weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" },
|
||||
};
|
||||
|
||||
export interface UseStep2OrchestrationOptions {
|
||||
/** 当前页面 currentStep,===2 触发自动 init。 */
|
||||
@@ -164,11 +175,11 @@ export interface UseStep2OrchestrationReturn {
|
||||
pauseRoundtable: () => void;
|
||||
/** 与 Step2Panel「恢复会商」按钮相同 —— 恢复自动研讨(含待处理的干预指令)。 */
|
||||
resumeRoundtable: () => void;
|
||||
/** 记录干预指令(不暂停);通常由 Step2Panel 在 pauseRoundtable 之后调用。 */
|
||||
/** 记录干预指令(不暂停不恢复);通常由 Step2Panel 在 pauseRoundtable 之后调用。 */
|
||||
recordInjectMessage: () => void;
|
||||
/** 中断所有 in-flight SSE(给新建任务 / 加载草稿用)。 */
|
||||
abortAllInFlight: () => void;
|
||||
/** @deprecated 请用 pauseRoundtable + recordInjectMessage;保留兼容组合调用。 */
|
||||
/** 一键干预:记录指令 + 自动恢复会商 + 触发新一轮编排(替代 pause + record + resume 组合)。 */
|
||||
handleInjectToRoundtable: () => void;
|
||||
/** 总控澄清回复 —— 把回答拼到 prompt 里再起一轮编排。 */
|
||||
submitClarification: (rawAnswer: string) => void;
|
||||
@@ -223,7 +234,7 @@ export function useStep2Orchestration(
|
||||
} = useArtifacts();
|
||||
const artifactPathsRef = useRef<string[]>([]);
|
||||
useEffect(() => {
|
||||
artifactPathsRef.current = artifacts;
|
||||
artifactPathsRef.current = artifacts ?? [];
|
||||
}, [artifacts]);
|
||||
// 用户手动关掉沙箱后,autoOpen 会被 ArtifactsProvider 置 false —— 把它们打到 ref
|
||||
// 上,让 openSeatVirtualArtifact 不用进 useCallback deps,避免每次 toggle 重建函数。
|
||||
@@ -242,7 +253,9 @@ export function useStep2Orchestration(
|
||||
Record<string, StubAiMessage[]>
|
||||
>({});
|
||||
const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({});
|
||||
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>([]);
|
||||
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
|
||||
DEFAULT_SELECTED_AGENTS,
|
||||
);
|
||||
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
|
||||
const [coordinatorName, setCoordinatorName] = useState<string | null>(null);
|
||||
const [isInitializing, setIsInitializing] = useState<boolean>(false);
|
||||
@@ -421,6 +434,51 @@ export function useStep2Orchestration(
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 反查某个产出文件是由哪个席位 thread 写入的。
|
||||
*
|
||||
* 总控(coordinator)常用 `present_files` 呈现「子智能体写的文件」,但文件物理上落在
|
||||
* 子席位 thread 的 outputs 里,coordinator thread 并没有 —— 直接拿总控气泡的
|
||||
* `agentThreadId`(=coordinator thread)走 HTTP 拉会 404(`Thread ... not found`)。
|
||||
* 流式期间子席位 write_file 时,内容已缓存进 seatStubMessages(key=真实写入 thread)。
|
||||
* 这里按 path 反查那条 stub,让关闭沙箱后再点文件卡也能复用虚拟预览(内存内容,
|
||||
* 与流式期间体验一致,且不依赖后端 thread 是否仍可访问)。
|
||||
*/
|
||||
const findSeatArtifactSource = useCallback(
|
||||
(
|
||||
rawPath: string,
|
||||
): { seatThreadId: string; messageId: string; toolCallId: string } | null => {
|
||||
const target = rawPath.trim();
|
||||
if (!target) return null;
|
||||
const targetBase = target.split("/").pop() ?? target;
|
||||
let basenameMatch:
|
||||
| { seatThreadId: string; messageId: string; toolCallId: string }
|
||||
| null = null;
|
||||
for (const [seatThreadId, msgs] of Object.entries(
|
||||
seatStubMessagesRef.current,
|
||||
)) {
|
||||
const msg = msgs?.[0];
|
||||
if (!msg) continue;
|
||||
for (const tc of msg.tool_calls) {
|
||||
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
|
||||
const p =
|
||||
typeof (tc.args as { path?: unknown })?.path === "string"
|
||||
? ((tc.args as { path?: string }).path as string)
|
||||
: "";
|
||||
if (!p) continue;
|
||||
if (p === target) {
|
||||
return { seatThreadId, messageId: msg.id, toolCallId: tc.id };
|
||||
}
|
||||
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
|
||||
basenameMatch = { seatThreadId, messageId: msg.id, toolCallId: tc.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
return basenameMatch;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* `openArtifactPreview` —— 走真实路径(present_files 等已落盘场景),走 HTTP API 加载。
|
||||
* 沙箱 write_file 实时预览请用 `openSeatVirtualArtifact`。
|
||||
@@ -428,7 +486,27 @@ export function useStep2Orchestration(
|
||||
const openArtifactPreview = useCallback(
|
||||
(threadId: string, path: string, options?: { auto?: boolean }) => {
|
||||
const normalized = path.trim();
|
||||
if (!normalized || !threadId) return;
|
||||
if (!normalized) return;
|
||||
|
||||
// present_files 呈现的文件多半是子智能体 write_file 写入的:真实内容/thread 在 stub 里。
|
||||
// 先反查 stub 走虚拟预览(内存内容)—— 关闭沙箱后再点文件卡也能显示,且不受
|
||||
// 总控 coordinator thread 在后端 artifacts 路由下 not found 的影响。
|
||||
const seatSource = findSeatArtifactSource(normalized);
|
||||
if (seatSource) {
|
||||
const virtualUrl = buildWriteFileVirtualUrl(
|
||||
normalized,
|
||||
seatSource.messageId,
|
||||
seatSource.toolCallId,
|
||||
);
|
||||
openSeatVirtualArtifact(
|
||||
seatSource.seatThreadId,
|
||||
virtualUrl,
|
||||
options?.auto ?? false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!threadId) return;
|
||||
|
||||
setActiveArtifactThreadId(threadId);
|
||||
const prev = artifactPathsRef.current;
|
||||
@@ -449,7 +527,14 @@ export function useStep2Orchestration(
|
||||
openPanel();
|
||||
}
|
||||
},
|
||||
[selectArtifact, setArtifacts, setArtifactsOpen],
|
||||
[
|
||||
findSeatArtifactSource,
|
||||
buildWriteFileVirtualUrl,
|
||||
openSeatVirtualArtifact,
|
||||
selectArtifact,
|
||||
setArtifacts,
|
||||
setArtifactsOpen,
|
||||
],
|
||||
);
|
||||
|
||||
const openArtifactFromStep = useCallback(
|
||||
@@ -472,6 +557,16 @@ export function useStep2Orchestration(
|
||||
openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
|
||||
return;
|
||||
}
|
||||
if (toolName === "read_file") {
|
||||
// 验证类「查看文件…」步骤:用 args.path 在沙箱预览该文件。
|
||||
// openArtifactPreview 内部 findSeatArtifactSource 优先按 path 命中流式期间
|
||||
// write_file 缓存的内容(虚拟预览);未命中再走 seat thread 的 outputs HTTP。
|
||||
const path = step.args?.path;
|
||||
if (typeof path === "string" && path) {
|
||||
openArtifactPreview(seatThreadId, path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (toolName === "present_files") {
|
||||
const filepaths = step.args?.filepaths;
|
||||
if (Array.isArray(filepaths)) {
|
||||
@@ -1166,13 +1261,41 @@ export function useStep2Orchestration(
|
||||
confidence: "最高指令",
|
||||
});
|
||||
pendingInjectPromptRef.current = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
|
||||
triggerToast("干预指令已记录,点击「恢复会商」后执行");
|
||||
triggerToast("干预指令已记录,会商已自动恢复执行");
|
||||
}, [appendDialogue, customAgentInput, triggerToast]);
|
||||
|
||||
const handleInjectToRoundtable = useCallback(() => {
|
||||
pauseRoundtable();
|
||||
recordInjectMessage();
|
||||
}, [pauseRoundtable, recordInjectMessage]);
|
||||
const userText = customAgentInput.trim();
|
||||
if (!userText) return;
|
||||
if (!threadIdsRef.current || !coordinatorNameRef.current) {
|
||||
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
|
||||
return;
|
||||
}
|
||||
|
||||
// 中止当前编排(如有)
|
||||
orchestrationCancelRef.current = true;
|
||||
abortAllInFlight();
|
||||
// 强制标记非编排中,使后续 runOrchestration 可立即启动
|
||||
isOrchestratingRef.current = false;
|
||||
setIsOrchestrating(false);
|
||||
|
||||
setCustomAgentInput("");
|
||||
appendDialogue({
|
||||
sender: "用户 (插话干预)",
|
||||
avatarType: "human",
|
||||
content: `💡 (人工指令介入) ${userText}`,
|
||||
confidence: "最高指令",
|
||||
});
|
||||
|
||||
const prompt = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
|
||||
|
||||
// 确保处于运行状态(同步更新按钮 UI)
|
||||
setIsRoundTableRunning(true);
|
||||
triggerToast("干预指令已发送,会商继续执行");
|
||||
|
||||
// 直接启动新一轮编排
|
||||
void runOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
|
||||
}, [customAgentInput, appendDialogue, abortAllInFlight, runOrchestration, triggerToast]);
|
||||
|
||||
const submitClarification = useCallback(
|
||||
(rawAnswer: string) => {
|
||||
|
||||
@@ -24,7 +24,23 @@ export type RoleEntry = {
|
||||
/** 用户在推荐弹窗选定的一个研讨席位。 */
|
||||
export type SelectedAgent = { agent_id: string; name: string };
|
||||
|
||||
/** 研讨席位的头像配色轮换池。 */
|
||||
/**
|
||||
* 6 个内置 roundtable 席位的展示元数据。用户保留默认席位时,
|
||||
* 头像 / 配色 / 中文显示名都按这里走;非内置(用户自建)走 FALLBACK_AVATAR_TYPES。
|
||||
*/
|
||||
export const BUILTIN_AGENT_META: Record<
|
||||
string,
|
||||
{ avatarType: string; displayName: string }
|
||||
> = {
|
||||
"roundtable-intelligence": { avatarType: "purple", displayName: "情报收集" },
|
||||
"roundtable-environment": { avatarType: "emerald", displayName: "环境评估" },
|
||||
"roundtable-solution-design": { avatarType: "human", displayName: "方案设计" },
|
||||
"roundtable-risk-review": { avatarType: "amber", displayName: "风险审查" },
|
||||
"roundtable-execution-plan": { avatarType: "pink", displayName: "执行规划" },
|
||||
"roundtable-summary": { avatarType: "blue", displayName: "总结陈述" },
|
||||
};
|
||||
|
||||
/** 非内置 agent 的头像配色轮换池。 */
|
||||
export const FALLBACK_AVATAR_TYPES = [
|
||||
"blue",
|
||||
"purple",
|
||||
@@ -33,12 +49,28 @@ export const FALLBACK_AVATAR_TYPES = [
|
||||
"pink",
|
||||
] as const;
|
||||
|
||||
export function getAvatarTypeFor(_agentId: string, index: number): string {
|
||||
return FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length];
|
||||
/**
|
||||
* 用户未确认推荐弹窗时,Step 2 默认使用的 6 个内置席位。
|
||||
* 保留默认时页面与原硬编码行为完全一致。
|
||||
*/
|
||||
export const DEFAULT_SELECTED_AGENTS: SelectedAgent[] = [
|
||||
{ agent_id: "roundtable-summary", name: "总结陈述" },
|
||||
{ agent_id: "roundtable-intelligence", name: "情报收集" },
|
||||
{ agent_id: "roundtable-environment", name: "环境评估" },
|
||||
{ agent_id: "roundtable-solution-design", name: "方案设计" },
|
||||
{ agent_id: "roundtable-risk-review", name: "风险审查" },
|
||||
{ agent_id: "roundtable-execution-plan", name: "执行规划" },
|
||||
];
|
||||
|
||||
export function getAvatarTypeFor(agentId: string, index: number): string {
|
||||
return (
|
||||
BUILTIN_AGENT_META[agentId]?.avatarType ??
|
||||
FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length]
|
||||
);
|
||||
}
|
||||
|
||||
export function getDisplayNameFor(agent: SelectedAgent): string {
|
||||
return agent.name;
|
||||
return BUILTIN_AGENT_META[agent.agent_id]?.displayName ?? agent.name;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,14 +135,187 @@ export function newCoordinatorName(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端 intent agent 输出的 `[INTENT_READY]\n\`\`\`json {...}\`\`\`` 块。
|
||||
* 前端用同样形状的正则把摘要从对话气泡里抽出来,渲染成绿色摘要卡片。
|
||||
* 后端 intent agent 输出的**标准** `[INTENT_READY]\n\`\`\`json {...}\`\`\`` 块形状。
|
||||
* 仅作格式参考 / 快速匹配用;实际从气泡正文里抽摘要走 `splitIntentReadyBlock` 的
|
||||
* 容错提取(marker 定位 + 括号配对 + 多级降级 parse),不再依赖这条严格正则。
|
||||
*/
|
||||
export const INTENT_READY_RE = /\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/;
|
||||
|
||||
/**
|
||||
* 把 JSON 文本里**字符串值内部**的字面控制字符(换行 / 回车 / 制表符)转义成合法的
|
||||
* `\n` / `\r` / `\t`。模型偶尔会在 summary 的字符串值里直接敲换行,产出的 JSON 不合法,
|
||||
* `JSON.parse` 会抛 `Bad control character in string literal`。这里只在字符串内替换,
|
||||
* 不动结构性空白,避免破坏本来就合法的 JSON。
|
||||
*/
|
||||
function sanitizeJsonControlChars(raw: string): string {
|
||||
let out = "";
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (const ch of raw) {
|
||||
if (escaped) {
|
||||
out += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
out += ch;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = !inString;
|
||||
out += ch;
|
||||
continue;
|
||||
}
|
||||
if (inString) {
|
||||
if (ch === "\n") {
|
||||
out += "\\n";
|
||||
continue;
|
||||
}
|
||||
if (ch === "\r") {
|
||||
out += "\\r";
|
||||
continue;
|
||||
}
|
||||
if (ch === "\t") {
|
||||
out += "\\t";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 `fromIndex` 起找第一个 `{`,按花括号配对(跳过字符串内部的括号)找到匹配的 `}`,
|
||||
* 返回 JSON 子串及其在原文里的起止下标。
|
||||
*
|
||||
* 比 `\{[\s\S]*?\}` 这种正则更抗模型跑偏:容忍 JSON 内嵌套对象、字符串里的裸换行 /
|
||||
* 花括号,以及模型漏写 ```json 代码围栏(直接 `[INTENT_READY] {...}`)的情况。
|
||||
*/
|
||||
function extractBalancedJson(
|
||||
text: string,
|
||||
fromIndex: number,
|
||||
): { json: string; end: number } | null {
|
||||
const start = text.indexOf("{", fromIndex);
|
||||
if (start < 0) return null;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = start; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) continue;
|
||||
if (ch === "{") depth += 1;
|
||||
else if (ch === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return { json: text.slice(start, i + 1), end: i + 1 };
|
||||
}
|
||||
}
|
||||
return null; // 括号未闭合(流式中段 / 模型截断)
|
||||
}
|
||||
|
||||
/** 去掉对象 / 数组里 `}` `]` 之前的尾随逗号(只在字符串外处理)。 */
|
||||
function stripTrailingCommas(raw: string): string {
|
||||
let out = "";
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
const ch = raw[i];
|
||||
if (escaped) {
|
||||
out += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
out += ch;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = !inString;
|
||||
out += ch;
|
||||
continue;
|
||||
}
|
||||
if (!inString && ch === ",") {
|
||||
let j = i + 1;
|
||||
while (j < raw.length && /\s/.test(raw[j])) j += 1;
|
||||
if (raw[j] === "}" || raw[j] === "]") continue; // 丢弃尾随逗号
|
||||
}
|
||||
out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 把任意值归一成 string[]:数组逐项转字符串、单个字符串包成单元素、其它视为空。 */
|
||||
function toStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((x) => (typeof x === "string" ? x : x == null ? "" : String(x)))
|
||||
.filter((x) => x.trim().length > 0);
|
||||
}
|
||||
if (typeof value === "string" && value.trim().length > 0) return [value];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把解析出的对象归一成 IntentSummary。只要 `objective` 是非空字符串就认。
|
||||
* constraints / assumptions 容忍模型写成字符串 / null / 缺失,统一归到 string[]。
|
||||
*/
|
||||
function normalizeIntentSummary(parsed: unknown): IntentSummary | null {
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const objective =
|
||||
typeof obj.objective === "string" ? obj.objective.trim() : "";
|
||||
if (!objective) return null;
|
||||
return {
|
||||
objective,
|
||||
constraints: toStringArray(obj.constraints),
|
||||
assumptions: toStringArray(obj.assumptions),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 INTENT_READY 的 JSON 块。内网模型输出不可控,这里逐级降级重试:
|
||||
* 1) 原文直接 parse
|
||||
* 2) 转义字符串内的裸控制字符(换行 / 制表符)后再 parse
|
||||
* 3) 再去掉尾随逗号后 parse
|
||||
* 三次都失败才放弃。任一成功都过 `normalizeIntentSummary` 兜字段类型。
|
||||
*/
|
||||
function parseIntentSummary(raw: string): IntentSummary | null {
|
||||
const sanitized = sanitizeJsonControlChars(raw);
|
||||
const candidates = [raw, sanitized, stripTrailingCommas(sanitized)];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const summary = normalizeIntentSummary(JSON.parse(candidate));
|
||||
if (summary) return summary;
|
||||
} catch {
|
||||
// 下一个候选
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const INTENT_READY_MARKER_RE = /\[INTENT_READY\]/i;
|
||||
|
||||
/**
|
||||
* 把含 [INTENT_READY] 标记的气泡正文拆成前 / 摘要 / 后三段。
|
||||
* 摘要 JSON 格式异常时整体保留在 `before`,用户仍能看到原文。
|
||||
*
|
||||
* 提取不再依赖严格的 ```json 围栏正则:先定位 [INTENT_READY](大小写不敏感),
|
||||
* 再用括号配对抓出后面的第一个完整 JSON 对象,最后多级降级 parse。这样模型漏写
|
||||
* 代码围栏、JSON 里有裸换行 / 尾随逗号、字段类型写偏等情况都能尽量恢复。
|
||||
*/
|
||||
export function splitIntentReadyBlock(text: string): {
|
||||
before: string;
|
||||
@@ -118,20 +323,16 @@ export function splitIntentReadyBlock(text: string): {
|
||||
after: string;
|
||||
} {
|
||||
if (!text) return { before: text, summary: null, after: "" };
|
||||
const m = INTENT_READY_RE.exec(text);
|
||||
if (!m) return { before: text, summary: null, after: "" };
|
||||
let summary: IntentSummary | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(m[1]);
|
||||
if (parsed && typeof parsed === "object" && typeof parsed.objective === "string") {
|
||||
summary = parsed as IntentSummary;
|
||||
}
|
||||
} catch {
|
||||
return { before: text, summary: null, after: "" };
|
||||
}
|
||||
const marker = INTENT_READY_MARKER_RE.exec(text);
|
||||
if (!marker) return { before: text, summary: null, after: "" };
|
||||
const block = extractBalancedJson(text, marker.index + marker[0].length);
|
||||
if (!block) return { before: text, summary: null, after: "" };
|
||||
const summary = parseIntentSummary(block.json);
|
||||
if (!summary) return { before: text, summary: null, after: "" };
|
||||
return {
|
||||
before: text.slice(0, m.index).trimEnd(),
|
||||
before: text.slice(0, marker.index).trimEnd(),
|
||||
summary,
|
||||
after: text.slice(m.index + m[0].length).trimStart(),
|
||||
// 丢掉 JSON 块之后残留的代码围栏 ``` 与模型多说的话(SOUL 要求 READY 后即停)。
|
||||
after: text.slice(block.end).replace(/^\s*```+/, "").trimStart(),
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought";
|
||||
import { CodeBlock } from "@/components/ai-elements/code-block";
|
||||
import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble";
|
||||
|
||||
/**
|
||||
* 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。
|
||||
@@ -34,6 +36,7 @@ import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-th
|
||||
export function renderStepArgs(
|
||||
key: string,
|
||||
args?: Record<string, unknown>,
|
||||
resolveAgentName?: (idOrName: string) => string,
|
||||
): React.ReactNode {
|
||||
if (!args) return null;
|
||||
const [, toolName] = key.split(":");
|
||||
@@ -43,15 +46,18 @@ export function renderStepArgs(
|
||||
const agentName = typeof args.agent_name === "string" ? args.agent_name : "";
|
||||
const task = typeof args.task === "string" ? args.task : "";
|
||||
if (!agentName && !task) return null;
|
||||
// 派活 chip 显示中文席位名而非 agent id(客户自建 agent 的 id 常是一串 hex)。
|
||||
const displayName =
|
||||
agentName && resolveAgentName ? resolveAgentName(agentName) : agentName;
|
||||
return (
|
||||
<>
|
||||
{agentName && (
|
||||
<ChainOfThoughtSearchResult>{agentName}</ChainOfThoughtSearchResult>
|
||||
{displayName && (
|
||||
<ChainOfThoughtSearchResult>{displayName}</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
{task && (
|
||||
<span className="block text-[11px] text-slate-600 line-clamp-3 mt-0.5">
|
||||
{task}
|
||||
</span>
|
||||
<div className="mt-1 max-h-64 overflow-y-auto rounded-md border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-600 leading-relaxed">
|
||||
<MarkdownContent>{task}</MarkdownContent>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -75,9 +81,14 @@ export function renderStepArgs(
|
||||
case "bash": {
|
||||
const command = typeof args.command === "string" ? args.command : "";
|
||||
return command ? (
|
||||
<code className="block bg-slate-100 rounded px-2 py-1 text-[11px] font-mono text-slate-800 mt-0.5 line-clamp-2 break-all">
|
||||
{command}
|
||||
</code>
|
||||
<div className="mt-1 max-h-60 overflow-auto rounded-md border border-slate-200 [&_pre]:px-2.5! [&_pre]:py-1.5!">
|
||||
<CodeBlock
|
||||
className="rounded-none border-none"
|
||||
showLineNumbers={false}
|
||||
language="bash"
|
||||
code={command}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
case "write_todos": {
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
import { useStep2Orchestration } from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence";
|
||||
import {
|
||||
BUILTIN_AGENT_META,
|
||||
getAvatarTypeFor,
|
||||
getDisplayNameFor,
|
||||
makeRolesFromTemplates,
|
||||
@@ -259,7 +260,8 @@ export default function RoundtablePlanningPage() {
|
||||
(agents: Array<{ agent_id: string; name: string }>) => {
|
||||
const normalized: SelectedAgent[] = agents.map(({ agent_id, name }) => ({
|
||||
agent_id,
|
||||
name: name ?? agent_id,
|
||||
// 内置 agent 走 BUILTIN_AGENT_META;非内置用候选池传过来的 name。
|
||||
name: BUILTIN_AGENT_META[agent_id]?.displayName ?? name ?? agent_id,
|
||||
}));
|
||||
step2.setSelectedAgents(normalized);
|
||||
step2.resetInitGate();
|
||||
@@ -486,6 +488,7 @@ export default function RoundtablePlanningPage() {
|
||||
agentConfigs={step2.agentConfigs}
|
||||
selectedAgentId={step2.selectedAgentId}
|
||||
roles={roles}
|
||||
agentNameToRole={step2.agentNameToRole}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
@@ -496,7 +499,7 @@ export default function RoundtablePlanningPage() {
|
||||
openArtifactPreview={step2.openArtifactPreview}
|
||||
onPauseRoundtable={step2.pauseRoundtable}
|
||||
onResumeRoundtable={step2.resumeRoundtable}
|
||||
onRecordInjectMessage={step2.recordInjectMessage}
|
||||
onHandleInjectToRoundtable={step2.handleInjectToRoundtable}
|
||||
setClarificationDraft={step2.setClarificationDraft}
|
||||
setCustomAgentInput={step2.setCustomAgentInput}
|
||||
setAgentConfigs={step2.setAgentConfigs}
|
||||
@@ -893,7 +896,9 @@ function LeftSidebar({
|
||||
...selectedAgents.map((agent, i) => ({
|
||||
id: agent.agent_id,
|
||||
name: getDisplayNameFor(agent),
|
||||
role: "圆桌席位",
|
||||
role: BUILTIN_AGENT_META[agent.agent_id]
|
||||
? `${BUILTIN_AGENT_META[agent.agent_id].displayName} 席位`
|
||||
: "圆桌席位",
|
||||
icon: getAvatar(getAvatarTypeFor(agent.agent_id, i), "h-7 w-7"),
|
||||
})),
|
||||
].map((contrib, idx) => (
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
export interface SceneInfo {
|
||||
applicableTask: string;
|
||||
createTime: string;
|
||||
deleted: number;
|
||||
id: number;
|
||||
relatedAgent: string;
|
||||
sceneCode: string;
|
||||
sceneIntro: string;
|
||||
sceneName: string;
|
||||
sceneType: string;
|
||||
status: number;
|
||||
updateTime: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface TaskListItem {
|
||||
createBy: number;
|
||||
createTime: string;
|
||||
deptId: null;
|
||||
id: number;
|
||||
isTop: number;
|
||||
issuingDept: string;
|
||||
keywords: null;
|
||||
overview: string;
|
||||
planTaskTop: number;
|
||||
platform: null;
|
||||
ptaskId: null;
|
||||
recommendedBasis: null;
|
||||
scene: string; // 或 SceneInfo,如果你已经解析
|
||||
sourceType: number;
|
||||
step: null;
|
||||
taskContent: string;
|
||||
taskDirection: string;
|
||||
taskEndDate: string;
|
||||
taskLevel: number;
|
||||
taskName: string;
|
||||
taskNo: string;
|
||||
taskStartDate: string;
|
||||
taskStatus: number;
|
||||
taskSuperiorFlag: number;
|
||||
taskTimeLimit: null;
|
||||
taskType: number;
|
||||
column: string;
|
||||
updateBy: number;
|
||||
updateTime: string;
|
||||
endCount: number;
|
||||
sumAll: number;
|
||||
progress: number;
|
||||
taskStatusData: any;
|
||||
}
|
||||
|
||||
export interface ActionListItem {
|
||||
actionDay: number;
|
||||
actionEndDate: string;
|
||||
actionManager: number;
|
||||
actionName: string;
|
||||
actionNo: any;
|
||||
actionStartDate: string;
|
||||
actionStatus: number;
|
||||
actionTerm: number;
|
||||
actionTitle: any;
|
||||
actorName: any;
|
||||
audienceGroup: any
|
||||
audienceGroupId: any;
|
||||
audienceRecReason: any
|
||||
behavior: any
|
||||
createBy: number
|
||||
createTime: string;
|
||||
deleteFlag: number;
|
||||
deptId: any;
|
||||
deptName: string;
|
||||
drivingFactor: any;
|
||||
expectedBehaviorId: any;
|
||||
id: number
|
||||
keywords: string
|
||||
level: number
|
||||
scene: string
|
||||
sourceType: number
|
||||
step: null
|
||||
taskId: number
|
||||
updateBy: number
|
||||
updateTime: number
|
||||
useActionId: null
|
||||
}
|
||||
|
||||
export interface actionColumnItem {
|
||||
icon: string;
|
||||
columnName: string;
|
||||
actionList: ActionListItem[]
|
||||
}
|
||||
|
||||
export const taskStatusList = {
|
||||
'0': {
|
||||
label: '待签收',
|
||||
color: '#0062d9',
|
||||
icon: '待签收.svg'
|
||||
},
|
||||
'10': {
|
||||
label: '待分析',
|
||||
color: '#2f9acc',
|
||||
icon: '待CEH.svg'
|
||||
},
|
||||
'20': {
|
||||
label: '分析中',
|
||||
color: '#d97b00',
|
||||
icon: '进行中.svg'
|
||||
},
|
||||
'25': {
|
||||
label: '分析完成已提交',
|
||||
color: '#1eb07f',
|
||||
icon: '完成.svg'
|
||||
},
|
||||
'30': {
|
||||
label: '已分析待审核',
|
||||
color: '#1eb07f',
|
||||
icon: '完成.svg'
|
||||
},
|
||||
'40': {
|
||||
label: '分析审核通过',
|
||||
color: '#1eb07f',
|
||||
icon: '完成.svg'
|
||||
},
|
||||
'50': {
|
||||
label: '分析审核未通过',
|
||||
color: '#cf1322',
|
||||
icon: '未通过.svg'
|
||||
},
|
||||
'60': {
|
||||
label: '行动已下发待签收',
|
||||
color: '#1eb07f',
|
||||
icon: '完成.svg'
|
||||
},
|
||||
'70': {
|
||||
label: '行动已签收待策划',
|
||||
color: '#1eb07f',
|
||||
icon: '完成.svg'
|
||||
},
|
||||
'80': {
|
||||
label: '行动策划已提交',
|
||||
color: '#1eb07f',
|
||||
icon: '完成.svg'
|
||||
},
|
||||
'100': {
|
||||
label: '作战方案待审核',
|
||||
color: '#1eb07f',
|
||||
icon: '完成.svg'
|
||||
},
|
||||
'110': {
|
||||
label: '作战方案审核通过',
|
||||
color: '#1eb07f',
|
||||
icon: '完成.svg'
|
||||
},
|
||||
'120': {
|
||||
label: '作战方案未通过',
|
||||
color: '#cf1322',
|
||||
icon: '未通过.svg'
|
||||
}
|
||||
};
|
||||
export default function TaskManagement() {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
const userInfo = localStorage.getItem('userInfo');
|
||||
const xkLinkUrl = import.meta.env.VITE_AIPI_XKLINKTO_URL;
|
||||
interface TaskPgFromProps {
|
||||
gopath: string
|
||||
}
|
||||
const TaskXkFrom: React.FC<TaskPgFromProps> = ({gopath}) => {
|
||||
const [xkUrl, setXkUrl] = useState(xkLinkUrl);
|
||||
useEffect(() => {
|
||||
if(userInfo) {
|
||||
const userData: any = JSON.parse(userInfo);
|
||||
setXkUrl(`${xkLinkUrl}${gopath}?accessToken=${userData.accessToken}`);
|
||||
}
|
||||
}, [gopath]);
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<iframe className="h-[calc(100vh-92px)]" src={xkUrl}></iframe>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default TaskXkFrom
|
||||
@@ -568,19 +568,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reference-flash {
|
||||
0% {
|
||||
background: rgba(254, 243, 199, 0.95);
|
||||
box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.28);
|
||||
}
|
||||
70% {
|
||||
background: rgba(254, 243, 199, 0.58);
|
||||
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.14);
|
||||
}
|
||||
100% {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Tiptap writing editor ─────────────────────────────────────────────── */
|
||||
|
||||
@@ -53,6 +53,7 @@ from app.gateway.routers import (
|
||||
thread_shares,
|
||||
threads,
|
||||
uploads,
|
||||
user_prompts,
|
||||
writing,
|
||||
)
|
||||
from deerflow.config import app_config as deerflow_app_config
|
||||
@@ -502,6 +503,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
except Exception:
|
||||
logger.exception("Skill ownership migration failed (non-fatal)")
|
||||
|
||||
# 一次性把 .deer-flow/agents/{id}/ 目录里历史遗留的 agent 导入 agents 表。
|
||||
# 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB
|
||||
# SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低
|
||||
# /api/agents 列表延迟。
|
||||
try:
|
||||
from app.gateway.routers.agents import (
|
||||
_mark_legacy_sync_fresh,
|
||||
_sync_legacy_agents,
|
||||
)
|
||||
|
||||
agent_store = getattr(app.state, "agent_store", None)
|
||||
if agent_store is not None:
|
||||
await _sync_legacy_agents(agent_store)
|
||||
_mark_legacy_sync_fresh()
|
||||
logger.info("Legacy folder-backed agents synced at startup")
|
||||
except Exception:
|
||||
logger.exception("Legacy agent sync failed at startup (non-fatal)")
|
||||
|
||||
# 启动 skill curator 调度器:常驻后台任务,每小时检查一次需不需要进化
|
||||
# (per-user interval_hours 仍是真正决定要不要跑进化的闸)。
|
||||
try:
|
||||
@@ -806,6 +825,8 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Recommended Questions API is mounted at /api/recommended-questions
|
||||
app.include_router(recommended_questions.router)
|
||||
|
||||
app.include_router(user_prompts.router)
|
||||
|
||||
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
|
||||
app.include_router(suggestions.router)
|
||||
|
||||
|
||||
@@ -148,6 +148,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.memory_config_store = make_user_memory_config_store(sf)
|
||||
|
||||
from deerflow.persistence.user_prompts import make_user_prompt_store
|
||||
|
||||
app.state.user_prompt_store = make_user_prompt_store(sf)
|
||||
|
||||
# Run event store (has its own factory with config-driven backend selection)
|
||||
run_events_config = getattr(config, "run_events", None)
|
||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
||||
|
||||
@@ -406,7 +406,7 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
|
||||
|
||||
> 以下两个示例覆盖**不同任务类型**,目的是教你「思路要呼应任务本身」,**不要**把任何一个示例的具体职能链当模板。
|
||||
|
||||
### 示例 A:合规改造任务(领域候选池)
|
||||
### 示例 A:合规改造任务(职能型候选池)
|
||||
|
||||
**用户输入**:
|
||||
```
|
||||
@@ -415,11 +415,12 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
|
||||
关键假设:现有系统具备基础数字化能力;允许预定窗口停机
|
||||
|
||||
候选智能体池:
|
||||
- compliance-research (合规调研):检索同类历史案例、行业最佳实践与监管口径
|
||||
- infra-capacity (基础设施评估):评估硬件容量与系统量化承载力
|
||||
- solution-architect (方案架构):产出主备方案与关键路径
|
||||
- risk-auditor (风险审查):合规红线检查与收敛建议
|
||||
- execution-planner (执行规划):里程碑与资源排布
|
||||
- roundtable-intelligence (情报收集):检索同类历史案例、行业最佳实践与外部情报
|
||||
- roundtable-environment (环境评估):评估硬件容量与系统量化承载力
|
||||
- roundtable-solution-design (方案设计):产出主备方案与关键路径
|
||||
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
|
||||
- roundtable-execution-plan (执行规划):里程碑与资源排布
|
||||
- roundtable-summary (总结陈述):归纳冲突标记与盖印
|
||||
- my-cost-calc (成本测算):基于预算和资源单价计算总成本
|
||||
```
|
||||
|
||||
@@ -430,10 +431,10 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
|
||||
```json
|
||||
{
|
||||
"picks": [
|
||||
{"agent_id": "compliance-research", "reason": "检索同类等保 2.0 改造案例与监管口径,直接支撑本次合规对标"},
|
||||
{"agent_id": "solution-architect", "reason": "在 6 个月工期约束下产出主备改造方案与关键路径"},
|
||||
{"agent_id": "risk-auditor", "reason": "对照等保 2.0 合规红线审查方案,避免后期返工"},
|
||||
{"agent_id": "execution-planner", "reason": "把改造拆成可在 6 个月内交付的里程碑与资源排布"},
|
||||
{"agent_id": "roundtable-intelligence", "reason": "检索同类等保 2.0 改造案例与监管口径,直接支撑本次合规对标"},
|
||||
{"agent_id": "roundtable-solution-design", "reason": "在 6 个月工期约束下产出主备改造方案与关键路径"},
|
||||
{"agent_id": "roundtable-risk-review", "reason": "对照等保 2.0 合规红线审查方案,避免后期返工"},
|
||||
{"agent_id": "roundtable-execution-plan", "reason": "把改造拆成可在 6 个月内交付的里程碑与资源排布"},
|
||||
{"agent_id": "my-cost-calc", "reason": "校验整体方案落在 500 万元预算硬约束内"}
|
||||
]
|
||||
}
|
||||
@@ -452,7 +453,7 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
|
||||
- channel-planner (渠道规划师):按预算和受众设计跨平台投放组合
|
||||
- audience-insight (受众洞察):画像、关键词、情绪点研究
|
||||
- creative-copy (创意文案):产出落地页与社媒文案变体
|
||||
- ad-compliance (广告合规):审查文案与素材合规风险
|
||||
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
|
||||
```
|
||||
|
||||
**你的输出**:
|
||||
@@ -470,8 +471,8 @@ RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
|
||||
}
|
||||
```
|
||||
|
||||
> **关键观察**:两个示例都从候选池里按任务语义挑选,**不要**套用固定职能链(如"调研→设计→风险→执行")。
|
||||
> 候选池里的智能体能不能匹配任务,只看 description 和任务的实际语义。如果候选池里有"广告设计智能体"且任务是广告设计,**你必须挑它**,而不是机械复制示例 A 的链路。
|
||||
> **关键观察**:示例 A 选了 `roundtable-intelligence / solution-design / execution-plan` 等"职能型"智能体,示例 B **完全没用任何 `roundtable-*` 内置**,而是从用户自建的领域智能体里挑。
|
||||
> 这是因为:**候选池里的智能体能不能匹配任务,只看 description 和任务的实际语义,不看 id 是不是 `roundtable-` 开头**。如果候选池里有"广告设计智能体"且任务是广告设计,**你必须挑它**,而不是套用示例 A 的职能链。
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
@@ -58,6 +59,11 @@ class AgentResponse(BaseModel):
|
||||
description="Admin-curated homepage rank. Null = not featured. Lower sorts first.",
|
||||
)
|
||||
|
||||
is_favorite: bool = Field(
|
||||
default=False,
|
||||
description="Whether the requesting user has favorited (收藏) this agent.",
|
||||
)
|
||||
|
||||
|
||||
class AgentsListResponse(BaseModel):
|
||||
"""Response model for listing all visible agents."""
|
||||
@@ -202,6 +208,11 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
|
||||
|
||||
This preserves old deployments where agents already existed under
|
||||
``.deer-flow/agents/{name}`` before the agents table was introduced.
|
||||
|
||||
Heavy: scans the agents directory + parses each ``config.yaml`` + issues a
|
||||
SELECT-then-INSERT/UPDATE per agent. Call once at startup via the lifespan,
|
||||
and rely on :func:`_maybe_sync_legacy_agents` (TTL-gated) inside request
|
||||
handlers as a safety net for environments that bypassed the lifespan hook.
|
||||
"""
|
||||
try:
|
||||
for agent_cfg in list_custom_agents():
|
||||
@@ -220,6 +231,44 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
|
||||
logger.exception("Failed to sync legacy folder-backed agents")
|
||||
|
||||
|
||||
# Throttle the per-request legacy-sync fallback. The real sync runs once at
|
||||
# startup (lifespan); request handlers used to call ``_sync_legacy_agents`` on
|
||||
# every hit, which scanned the filesystem + did N DB upserts per agent each
|
||||
# time. After the first successful sync we skip the work for ``_LEGACY_SYNC_TTL_SECONDS``
|
||||
# — long enough to be effectively "startup-only" in steady state, short enough
|
||||
# that a freshly dropped agent directory gets picked up on the next bucket.
|
||||
_LEGACY_SYNC_TTL_SECONDS = 3600.0
|
||||
_legacy_sync_state: dict[str, Any] = {"last_at": 0.0, "lock": None}
|
||||
|
||||
|
||||
async def _maybe_sync_legacy_agents(store: AgentStore) -> None:
|
||||
"""TTL-gated wrapper around :func:`_sync_legacy_agents`.
|
||||
|
||||
Becomes a no-op in steady state — the heavy lifting happens in the lifespan
|
||||
hook. Inside the TTL window we don't even take the lock; outside it we
|
||||
serialize callers so a thundering herd doesn't fan out into N filesystem
|
||||
scans + N*M DB queries.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if now - float(_legacy_sync_state["last_at"]) < _LEGACY_SYNC_TTL_SECONDS:
|
||||
return
|
||||
lock = _legacy_sync_state["lock"]
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_legacy_sync_state["lock"] = lock
|
||||
async with lock:
|
||||
# Re-check under the lock — a sibling coroutine may have just refreshed.
|
||||
if time.monotonic() - float(_legacy_sync_state["last_at"]) < _LEGACY_SYNC_TTL_SECONDS:
|
||||
return
|
||||
await _sync_legacy_agents(store)
|
||||
_legacy_sync_state["last_at"] = time.monotonic()
|
||||
|
||||
|
||||
def _mark_legacy_sync_fresh() -> None:
|
||||
"""Called by the lifespan hook after running :func:`_sync_legacy_agents`."""
|
||||
_legacy_sync_state["last_at"] = time.monotonic()
|
||||
|
||||
|
||||
def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None:
|
||||
"""Lazily restore an agent's on-disk artifacts from its DB record.
|
||||
|
||||
@@ -257,14 +306,29 @@ def _record_to_response(
|
||||
record: dict[str, Any],
|
||||
*,
|
||||
include_soul: bool = False,
|
||||
include_extras: bool = True,
|
||||
) -> AgentResponse:
|
||||
_ensure_agent_dir_from_record(record)
|
||||
"""Convert a DB row to an API response.
|
||||
|
||||
Args:
|
||||
include_soul: Read SOUL.md from disk and embed in ``soul``. Set False
|
||||
in list endpoints — the frontend (gallery / recommend / selector)
|
||||
never reads ``soul``, only the edit dialog does. Skipping this
|
||||
avoids one synchronous file read per agent.
|
||||
include_extras: Self-heal a missing agent dir and read deprecated
|
||||
``model`` / ``tool_groups`` / ``skills`` from ``config.yaml``. Set
|
||||
False in list endpoints to skip a stat + yaml parse per agent —
|
||||
those fields are unused by current frontends and the dir restore
|
||||
can happen lazily when the agent is actually opened.
|
||||
"""
|
||||
soul: str | None = None
|
||||
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None}
|
||||
if include_extras:
|
||||
_ensure_agent_dir_from_record(record)
|
||||
extras = _load_config_extras(record["id"])
|
||||
if include_soul:
|
||||
soul = load_agent_soul(record["id"]) or ""
|
||||
|
||||
extras = _load_config_extras(record["id"])
|
||||
|
||||
return AgentResponse(
|
||||
id=record["id"],
|
||||
name=record["name"],
|
||||
@@ -283,6 +347,25 @@ def _record_to_response(
|
||||
)
|
||||
|
||||
|
||||
async def _record_to_response_async(
|
||||
record: dict[str, Any],
|
||||
*,
|
||||
include_soul: bool = False,
|
||||
include_extras: bool = True,
|
||||
) -> AgentResponse:
|
||||
"""Async wrapper: when we actually touch disk, hop to a worker thread so
|
||||
we don't block the asyncio loop. List endpoints (no soul, no extras) skip
|
||||
this overhead and stay on the calling task."""
|
||||
if not include_soul and not include_extras:
|
||||
return _record_to_response(record, include_soul=False, include_extras=False)
|
||||
return await asyncio.to_thread(
|
||||
_record_to_response,
|
||||
record,
|
||||
include_soul=include_soul,
|
||||
include_extras=include_extras,
|
||||
)
|
||||
|
||||
|
||||
async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) -> dict[str, Any]:
|
||||
owned = await store.get_owned(agent_id, user_id)
|
||||
if owned is not None:
|
||||
@@ -344,7 +427,7 @@ async def list_agents(
|
||||
try:
|
||||
store = get_agent_store(request)
|
||||
tag_store: TagStore = get_tag_store(request)
|
||||
await _sync_legacy_agents(store)
|
||||
await _maybe_sync_legacy_agents(store)
|
||||
user_id = _current_user_id(request)
|
||||
if admin:
|
||||
if not _is_admin_user(request):
|
||||
@@ -352,11 +435,20 @@ async def list_agents(
|
||||
agents = await store.list_all()
|
||||
else:
|
||||
agents = await store.list_visible(user_id)
|
||||
responses = [_record_to_response(a, include_soul=True) for a in agents]
|
||||
# List endpoint stays lean: skip per-agent SOUL.md read, config.yaml
|
||||
# parse, and dir self-heal — none of those feed UI fields the gallery /
|
||||
# recommend dialog / selector actually render. Detail endpoints (single
|
||||
# agent + create/update responses) still load them.
|
||||
responses = [
|
||||
_record_to_response(a, include_soul=False, include_extras=False)
|
||||
for a in agents
|
||||
]
|
||||
|
||||
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
|
||||
favorite_ids = await store.list_favorite_ids(user_id)
|
||||
for resp in responses:
|
||||
resp.tags = assignments.get(resp.id, [])
|
||||
resp.is_favorite = resp.id in favorite_ids
|
||||
if search:
|
||||
needle = search.strip().lower()
|
||||
responses = [r for r in responses if needle in r.name.lower()]
|
||||
@@ -396,11 +488,18 @@ async def get_agent_selector(request: Request) -> AgentSelectorResponse:
|
||||
_require_agents_api_enabled()
|
||||
try:
|
||||
store = get_agent_store(request)
|
||||
await _sync_legacy_agents(store)
|
||||
await _maybe_sync_legacy_agents(store)
|
||||
|
||||
user_id = _current_user_id(request)
|
||||
records = await store.list_featured(user_id)
|
||||
return AgentSelectorResponse(agents=[_record_to_response(r) for r in records])
|
||||
# Selector dropdown only renders name/description/featured_order — same
|
||||
# rationale as list_agents: skip soul + extras + self-heal.
|
||||
return AgentSelectorResponse(
|
||||
agents=[
|
||||
_record_to_response(r, include_soul=False, include_extras=False)
|
||||
for r in records
|
||||
]
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -420,12 +519,18 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
|
||||
|
||||
try:
|
||||
store = get_agent_store(request)
|
||||
await _sync_legacy_agents(store)
|
||||
await _maybe_sync_legacy_agents(store)
|
||||
user_id = _current_user_id(request)
|
||||
record = await store.get_visible(agent_id, user_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||
return _record_to_response(record, include_soul=True)
|
||||
# Detail view legitimately reads SOUL.md + config.yaml — hop to a
|
||||
# worker thread so the asyncio loop stays responsive under concurrent
|
||||
# detail fetches.
|
||||
response = await _record_to_response_async(record, include_soul=True, include_extras=True)
|
||||
favorite_ids = await store.list_favorite_ids(user_id)
|
||||
response.is_favorite = agent_id in favorite_ids
|
||||
return response
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -478,7 +583,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
|
||||
}
|
||||
)
|
||||
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
|
||||
return _record_to_response(record, include_soul=True)
|
||||
return await _record_to_response_async(record, include_soul=True, include_extras=True)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -559,7 +664,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||
|
||||
logger.info("Updated agent '%s' (%s)", record["name"], agent_id)
|
||||
return _record_to_response(record, include_soul=True)
|
||||
return await _record_to_response_async(record, include_soul=True, include_extras=True)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -652,6 +757,10 @@ async def delete_agent(agent_id: str, request: Request) -> None:
|
||||
await get_tag_store(request).unassign_all("agent", agent_id)
|
||||
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
|
||||
pass
|
||||
try:
|
||||
await store.delete_favorites_for_agent(agent_id)
|
||||
except Exception: # noqa: BLE001 — favorite cleanup must not block deletion
|
||||
pass
|
||||
logger.info("Deleted agent '%s' from %s", agent_id, agent_dir)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -660,6 +769,53 @@ async def delete_agent(agent_id: str, request: Request) -> None:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") from e
|
||||
|
||||
|
||||
@router.post(
|
||||
"/agents/{agent_id}/favorite",
|
||||
status_code=204,
|
||||
summary="Favorite Agent",
|
||||
description="Bookmark a visible agent into the current user's 我的 tab. Idempotent.",
|
||||
)
|
||||
async def favorite_agent(agent_id: str, request: Request) -> None:
|
||||
_require_agents_api_enabled()
|
||||
agent_id = _validate_storage_id(agent_id)
|
||||
|
||||
try:
|
||||
store = get_agent_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
record = await store.get_visible(agent_id, user_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||
await store.add_favorite(user_id, agent_id)
|
||||
logger.info("User '%s' favorited agent '%s'", user_id, agent_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to favorite agent '%s': %s", agent_id, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to favorite agent: {str(e)}") from e
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/agents/{agent_id}/favorite",
|
||||
status_code=204,
|
||||
summary="Unfavorite Agent",
|
||||
description="Remove an agent from the current user's favorites. No-op when not favorited.",
|
||||
)
|
||||
async def unfavorite_agent(agent_id: str, request: Request) -> None:
|
||||
_require_agents_api_enabled()
|
||||
agent_id = _validate_storage_id(agent_id)
|
||||
|
||||
try:
|
||||
store = get_agent_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
await store.remove_favorite(user_id, agent_id)
|
||||
logger.info("User '%s' unfavorited agent '%s'", user_id, agent_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to unfavorite agent '%s': %s", agent_id, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to unfavorite agent: {str(e)}") from e
|
||||
|
||||
|
||||
@router.put(
|
||||
"/agents/{agent_id}/featured",
|
||||
response_model=AgentResponse,
|
||||
@@ -683,7 +839,7 @@ async def set_agent_featured(
|
||||
|
||||
try:
|
||||
store = get_agent_store(request)
|
||||
await _sync_legacy_agents(store)
|
||||
await _maybe_sync_legacy_agents(store)
|
||||
record = await store.get_any(agent_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||
@@ -695,7 +851,7 @@ async def set_agent_featured(
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||
logger.info("Set agent '%s' featured_order=%s by admin", agent_id, body.order)
|
||||
return _record_to_response(updated, include_soul=True)
|
||||
return await _record_to_response_async(updated, include_soul=True, include_extras=True)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -65,6 +65,7 @@ from fastapi.responses import StreamingResponse
|
||||
# identical in connection handling, payload shape, and error mapping.
|
||||
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_ROUNDTABLE_THREAD_METADATA,
|
||||
_auth_headers,
|
||||
_create_thread,
|
||||
_diagnose_exception,
|
||||
@@ -133,6 +134,70 @@ def _normalize_options(raw: object) -> tuple[str, ...]:
|
||||
return tuple(sorted(o for o in out if o))
|
||||
|
||||
|
||||
def _count_past_clarifications(messages: list[dict[str, Any]]) -> int:
|
||||
"""统计当前 thread 内已经发出过的 ``ask_clarification`` 次数。
|
||||
|
||||
用来给 ``stream_intent`` 做"3 轮硬上限"兜底:
|
||||
- 0-1 次:正常流程,模型自行决定继续问还是收敛
|
||||
- 2 次(即将进入第 3 轮):prompt 追加系统提示,要求本轮必须 INTENT_READY
|
||||
- ≥3 次:工具白名单移除 ``ask_clarification``,模型物理上无法再问
|
||||
|
||||
只数"已经成型"的 tool_call,不区分用户是否回答过——LangGraph 把 ask
|
||||
工具的调用永久记录在 AIMessage.tool_calls 里,数它即可。
|
||||
"""
|
||||
if not isinstance(messages, list):
|
||||
return 0
|
||||
count = 0
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
|
||||
continue
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if (tc.get("name") or "").lower() == "ask_clarification":
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
async def _fetch_thread_messages(
|
||||
client: httpx.AsyncClient,
|
||||
base: str,
|
||||
headers: dict[str, str],
|
||||
thread_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""读取当前 thread checkpoint 的 messages 列表(best-effort)。
|
||||
|
||||
用 ASGITransport loopback 调 ``GET /api/threads/{tid}/state``,失败时返回
|
||||
空列表——计数兜底是个"刹车",失败时退化成原有的纯 SOUL 自律行为,不阻断主流程。
|
||||
"""
|
||||
try:
|
||||
resp = await client.get(f"{base}/api/threads/{thread_id}/state", headers=headers)
|
||||
except Exception as exc:
|
||||
logger.warning("intent: get thread state failed (%s) — falling back to soul-only flow", exc)
|
||||
return []
|
||||
if resp.status_code != 200:
|
||||
logger.warning(
|
||||
"intent: get thread state %s returned %s — falling back to soul-only flow",
|
||||
thread_id,
|
||||
resp.status_code,
|
||||
)
|
||||
return []
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception:
|
||||
return []
|
||||
values = payload.get("values") if isinstance(payload, dict) else None
|
||||
if not isinstance(values, dict):
|
||||
return []
|
||||
messages = values.get("messages")
|
||||
return messages if isinstance(messages, list) else []
|
||||
|
||||
|
||||
# 硬上限:超过这个轮数,无论模型怎么想,都强制移除 ask_clarification 工具。
|
||||
# 与 SOUL.md §0 「硬规则 2」对齐——"绝不存在第 4 轮"。
|
||||
_MAX_CLARIFICATION_ROUNDS = 3
|
||||
|
||||
|
||||
def _is_repeated_clarification(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
@@ -222,7 +287,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
|
||||
# 看不到逐 token 增量。
|
||||
try:
|
||||
async with _make_loopback_client(request) as client:
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
except HTTPException:
|
||||
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
|
||||
raise
|
||||
@@ -279,33 +344,70 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
# 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。
|
||||
async with _make_loopback_client(request) as client:
|
||||
try:
|
||||
# —— 3 轮硬上限兜底 ——
|
||||
# 在发起 run 之前先读一次 thread state,数当前 ask_clarification 已发出次数:
|
||||
# past=0/1 → 正常,不动 message,不动工具白名单
|
||||
# past=2(本轮即将变成第 3 轮)→ 给消息加前缀,强提示"必须 INTENT_READY"
|
||||
# past≥3 → 直接把 ask_clarification 也加进 excluded_tools,工具物理消失
|
||||
# 这一步是个"刹车":SOUL 让模型自律,这里让系统兜底。
|
||||
past_clarifications = _count_past_clarifications(
|
||||
await _fetch_thread_messages(client, base, headers, thread_id)
|
||||
)
|
||||
|
||||
# 1. ask_clarification 的工具白名单——超过硬上限就移除。
|
||||
base_excluded_tools = [
|
||||
"web_search",
|
||||
"present_files",
|
||||
"view_image",
|
||||
"agent_orchestration",
|
||||
"bash",
|
||||
"ls",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"str_replace",
|
||||
"memory",
|
||||
"hindsight_recall",
|
||||
"hindsight_reflect",
|
||||
"hindsight_retain",
|
||||
"task",
|
||||
"write_todos",
|
||||
]
|
||||
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
|
||||
base_excluded_tools.append("ask_clarification")
|
||||
logger.info(
|
||||
"intent stream: past_clarifications=%d >= %d, force-disabling "
|
||||
"ask_clarification to break the loop",
|
||||
past_clarifications,
|
||||
_MAX_CLARIFICATION_ROUNDS,
|
||||
)
|
||||
|
||||
# 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。
|
||||
effective_message = message
|
||||
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
|
||||
effective_message = (
|
||||
f"[系统强制] 已澄清 {past_clarifications} 轮,触发硬上限。"
|
||||
f"本轮请直接输出 [INTENT_READY],缺的字段用 assumptions 兜底,"
|
||||
f"绝对不要再调任何工具,也不要再询问任何问题。\n\n"
|
||||
f"用户最新输入:{message}"
|
||||
)
|
||||
elif past_clarifications >= _MAX_CLARIFICATION_ROUNDS - 1:
|
||||
effective_message = (
|
||||
f"[系统提示] 已澄清 {past_clarifications} 轮,这是最后一轮。"
|
||||
f"本轮请直接输出 [INTENT_READY],未明确的字段用 assumptions 兜底。\n\n"
|
||||
f"用户最新输入:{message}"
|
||||
)
|
||||
|
||||
body = _run_payload(
|
||||
agent_name=INTENT_AGENT_ID,
|
||||
model_name=model_name,
|
||||
thread_id=thread_id,
|
||||
new_message=message,
|
||||
# Intent agent only ever needs ask_clarification. Block
|
||||
new_message=effective_message,
|
||||
# Intent agent only ever needs ask_clarification (and even that
|
||||
# is yanked once past_clarifications hits the cap above). Block
|
||||
# everything else so the model can't drift into solutioning
|
||||
# (e.g. saving a design doc with write_file) — the model
|
||||
# tends to ignore SOUL prose, hard exclusion is reliable.
|
||||
# 仅保留 ask_clarification;其余工具见模块 docstring「工具白名单」。
|
||||
excluded_tools=[
|
||||
"web_search",
|
||||
"present_files",
|
||||
"view_image",
|
||||
"agent_orchestration",
|
||||
"bash",
|
||||
"ls",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"str_replace",
|
||||
"memory",
|
||||
"hindsight_recall",
|
||||
"hindsight_reflect",
|
||||
"hindsight_retain",
|
||||
"task",
|
||||
"write_todos",
|
||||
],
|
||||
# (e.g. saving a design doc with write_file) — SOUL prose is
|
||||
# not reliable on its own, hard exclusion is.
|
||||
excluded_tools=base_excluded_tools,
|
||||
skill_stop_names=[],
|
||||
)
|
||||
|
||||
@@ -338,7 +440,13 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("intent summary JSON parse failed: %s", exc)
|
||||
summary = None
|
||||
ready_text = msg_text
|
||||
# SOUL §0 硬规则 3 要求 ```json``` 反引号闭合后不得再输出任何字符,
|
||||
# 但模型仍偶尔会在后面接 "那么我帮你列一下大纲……" 之类的尾巴。
|
||||
# 终态帧只回到 marker 块结尾 + 一个换行,把废话从 content 里裁掉——
|
||||
# 草稿持久化 / 跨设备重放 / 前端 splitIntentReadyBlock 的 after
|
||||
# 段都因此干净。流式期间用户仍会逐 token 看到那段废话,前端
|
||||
# Step1Panel 已经把 `parts.after` 隐藏掉,体验闭环。
|
||||
ready_text = msg_text[: m.end()].rstrip() + "\n"
|
||||
break
|
||||
if summary and ready_text:
|
||||
final_frame = {
|
||||
|
||||
@@ -53,18 +53,28 @@ import os
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import httpx
|
||||
from deerflow.config import get_app_config
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.utils.time import now_iso
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from langgraph.checkpoint.base import empty_checkpoint
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
|
||||
|
||||
|
||||
# 圆桌(intent / recommend / 席位 / 协调)创建的 thread 都是功能型内部线程,
|
||||
# 不是用户的普通对话。打上这个 metadata,前端「最近的对话」列表(recent-chat-list.tsx)
|
||||
# 据 `metadata.thread_type` 过滤掉,与 scheduler / bootstrap 系统会话同一套机制。
|
||||
_ROUNDTABLE_THREAD_METADATA: dict[str, Any] = {"thread_type": "roundtable", "system": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 共享:异常诊断器(intent/recommend/multi-agent 共用)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -285,9 +295,22 @@ def _auth_headers(request: Request) -> dict[str, str]:
|
||||
return {"Authorization": token, "Content-Type": "application/json"}
|
||||
|
||||
|
||||
async def _create_thread(client: httpx.AsyncClient, base: str, headers: dict[str, str]) -> str:
|
||||
"""``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。"""
|
||||
resp = await client.post(f"{base}/api/threads", json={}, headers=headers)
|
||||
async def _create_thread(
|
||||
client: httpx.AsyncClient,
|
||||
base: str,
|
||||
headers: dict[str, str],
|
||||
*,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。
|
||||
|
||||
``metadata`` 写入 thread 元数据,圆桌调用方传 ``_ROUNDTABLE_THREAD_METADATA``
|
||||
把这些功能型线程标记成可被前端「最近的对话」过滤掉的系统会话。
|
||||
"""
|
||||
body: dict[str, Any] = {}
|
||||
if metadata:
|
||||
body["metadata"] = metadata
|
||||
resp = await client.post(f"{base}/api/threads", json=body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["thread_id"]
|
||||
|
||||
@@ -323,6 +346,181 @@ async def _create_agent(
|
||||
return False, resp.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 同进程直调 helpers(仅供 init_multi_agent 用,不要扩散到其它地方)
|
||||
#
|
||||
# init 阶段串了 N+1 个 thread create + N 个 GET /api/agents + 1 个 POST /api/agents,
|
||||
# 用 HTTP loopback 串走每次都得 TCP + 鉴权中间件 + Pydantic 反序列化一遍。每个
|
||||
# 调用 10-30ms,加起来对前端就是「点进 Step 2 后席位灯一颗一颗亮」的卡顿来源。
|
||||
#
|
||||
# 这里把这三个调用改成直接吃 app.state 里的 store/checkpointer —— 跑在同一个事
|
||||
# 件循环里,省掉 TCP + 序列化 + 中间件链。鉴权 / 校验 / 文件副作用按需在直调
|
||||
# helper 里显式做掉,不要"绕过"任何一项。
|
||||
#
|
||||
# 设计边界:这里只覆盖 init 阶段。SSE 长连接(/runs/stream / state)留在 HTTP
|
||||
# loopback,因为它们本就是别的进程模型(LangGraph 子树),抽出来风险大收益小。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_user_id(request: Request) -> str:
|
||||
"""与 routers/agents._current_user_id 完全等价,直调路径里用。"""
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is not None:
|
||||
return str(user.id)
|
||||
return get_effective_user_id()
|
||||
|
||||
|
||||
async def _create_thread_direct(request: Request, *, metadata: dict[str, Any] | None = None) -> str:
|
||||
"""同进程版的 ``POST /api/threads``。
|
||||
|
||||
完全镜像 ``routers/threads.create_thread`` 的写盘动作(thread_meta +
|
||||
空 checkpoint),但跳过 TCP + Pydantic 反序列化。仅供 init 阶段批量建席位
|
||||
线程用,失败时抛 HTTPException 由 SSE 帧上报。
|
||||
|
||||
``metadata`` 写入 thread 元数据;init 传 ``_ROUNDTABLE_THREAD_METADATA`` 把
|
||||
席位 / 协调线程标记成系统会话,从前端「最近的对话」里隐藏。
|
||||
"""
|
||||
from app.gateway.deps import get_checkpointer, get_thread_store
|
||||
|
||||
checkpointer = get_checkpointer(request)
|
||||
thread_store = get_thread_store(request)
|
||||
thread_id = str(uuid.uuid4())
|
||||
now = now_iso()
|
||||
|
||||
try:
|
||||
await thread_store.create(thread_id, assistant_id=None, metadata=metadata or {})
|
||||
except Exception:
|
||||
logger.exception("[init-direct] thread_meta create failed for %s", thread_id)
|
||||
raise HTTPException(status_code=500, detail="Failed to create thread")
|
||||
|
||||
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
ckpt_metadata = {
|
||||
"step": -1,
|
||||
"source": "input",
|
||||
"writes": None,
|
||||
"parents": {},
|
||||
"created_at": now,
|
||||
}
|
||||
try:
|
||||
await checkpointer.aput(config, empty_checkpoint(), ckpt_metadata, {})
|
||||
except Exception:
|
||||
logger.exception("[init-direct] checkpoint create failed for %s", thread_id)
|
||||
raise HTTPException(status_code=500, detail="Failed to create thread")
|
||||
return thread_id
|
||||
|
||||
|
||||
async def _get_agents_batch_direct(
|
||||
request: Request,
|
||||
agent_ids: list[str],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""同进程版的 N 个并行 ``GET /api/agents/{id}`` 合并为 1 次 DB 查询。
|
||||
|
||||
返回 ``{agent_id: {id, name, description}}``。**严格模式**:任一席位对当前
|
||||
用户不可见就抛 404 —— 与原 HTTP loopback 的 ``_get_agent`` 行为一致(原路径
|
||||
单查 404 会 raise,在外层被 except 捕获并 yield error 帧),不要悄悄回退。
|
||||
"""
|
||||
from app.gateway.deps import get_agent_store
|
||||
|
||||
store = get_agent_store(request)
|
||||
user_id = _resolve_user_id(request)
|
||||
records = await store.list_by_ids(agent_ids, user_id)
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for r in records:
|
||||
rid = str(r.get("id") or "").lower()
|
||||
if not rid:
|
||||
continue
|
||||
out[rid] = {
|
||||
"id": rid,
|
||||
"name": str(r.get("name") or rid),
|
||||
"description": str(r.get("description") or ""),
|
||||
}
|
||||
missing = [sid for sid in agent_ids if sid.lower() not in out]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Agent(s) not visible to current user: {', '.join(missing)}",
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
async def _create_coordinator_direct(
|
||||
request: Request,
|
||||
*,
|
||||
agent_id: str,
|
||||
name: str,
|
||||
description: str,
|
||||
soul: str,
|
||||
model: str | None,
|
||||
skills: list[str] | None,
|
||||
) -> tuple[bool, dict[str, Any] | str]:
|
||||
"""同进程版的协调 agent 创建。
|
||||
|
||||
完整复刻 ``routers/agents.create_agent_endpoint`` 的关键副作用:
|
||||
- ``agents_api.enabled`` master switch
|
||||
- 显示名校验(空白则 422 → 这里转 (False, msg))
|
||||
- 重复 id 检查(409 → (False, msg))
|
||||
- 写 ``config.yaml`` + ``SOUL.md``(用 ``asyncio.to_thread`` 防同步 IO 阻塞)
|
||||
- ``agent_store.create`` 入表
|
||||
|
||||
返回 ``(True, record)`` 或 ``(False, error_text)``;调用方决定要不要发 SSE error 帧。
|
||||
"""
|
||||
from app.gateway.deps import get_agent_store
|
||||
from app.gateway.routers.agents import (
|
||||
_require_agents_api_enabled,
|
||||
_write_agent_config,
|
||||
_write_agent_soul,
|
||||
)
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
_require_agents_api_enabled()
|
||||
normalized_name = (name or "").strip()
|
||||
if not normalized_name:
|
||||
return False, "Agent name cannot be empty"
|
||||
|
||||
store = get_agent_store(request)
|
||||
if await store.get_any(agent_id) is not None:
|
||||
return False, f"Agent id '{agent_id}' already exists"
|
||||
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
if agent_dir.exists():
|
||||
return False, f"Agent id '{agent_id}' already exists"
|
||||
|
||||
try:
|
||||
# config.yaml / SOUL.md 是同步 IO —— 全部丢线程池,不阻塞 init SSE 的发帧节奏。
|
||||
await asyncio.to_thread(
|
||||
_write_agent_config,
|
||||
agent_id,
|
||||
name=normalized_name,
|
||||
description=description or "",
|
||||
skills=skills,
|
||||
model=model,
|
||||
tool_groups=None,
|
||||
)
|
||||
await asyncio.to_thread(_write_agent_soul, agent_id, soul)
|
||||
record = await store.create(
|
||||
{
|
||||
"id": agent_id,
|
||||
"name": normalized_name,
|
||||
"description": description or "",
|
||||
"user_id": _resolve_user_id(request),
|
||||
"published": False,
|
||||
}
|
||||
)
|
||||
return True, record
|
||||
except Exception as exc:
|
||||
# 与 routers/agents 失败回滚一致 —— 文件已写出但 DB 失败时清掉 dir,避免下次
|
||||
# `agent_dir.exists()` 误判为"已存在"导致后续 init 永远 409。
|
||||
try:
|
||||
if agent_dir.exists():
|
||||
import shutil
|
||||
|
||||
await asyncio.to_thread(shutil.rmtree, agent_dir)
|
||||
except Exception:
|
||||
logger.exception("[init-direct] cleanup after coordinator create failure")
|
||||
logger.exception("[init-direct] coordinator create failed")
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
async def _append_thread_message(
|
||||
client: httpx.AsyncClient,
|
||||
base: str,
|
||||
@@ -456,6 +654,8 @@ def _run_payload(
|
||||
new_message: str,
|
||||
excluded_tools: list[str],
|
||||
skill_stop_names: list[str],
|
||||
thinking_enabled: bool = True,
|
||||
reasoning_effort: str = "medium",
|
||||
) -> dict[str, Any]:
|
||||
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
|
||||
|
||||
@@ -463,6 +663,10 @@ def _run_payload(
|
||||
- ``excluded_tools``:按路由裁剪工具面(intent 仅澄清、special 禁派活等)。
|
||||
- ``skill_stop_names``:leader 侧配合 SkillStopMiddleware 在 ``agent_orchestration``
|
||||
调用后截断图,以便网关从 AIMessage.tool_calls 解析派活列表。
|
||||
- ``thinking_enabled`` / ``reasoning_effort``:默认开 CoT + medium,适合 Step 1 澄清
|
||||
与子智能体交付这类需要推理的场景。**推荐弹窗与总控派活**是短决策任务,调用方
|
||||
应传 ``thinking_enabled=False`` + ``reasoning_effort="low"``,省掉模型先吐
|
||||
reasoning tokens 才开口的时间,显著降低前端"调度中"首字节延迟。
|
||||
"""
|
||||
return {
|
||||
"input": {
|
||||
@@ -479,11 +683,17 @@ def _run_payload(
|
||||
"agent_name": agent_name,
|
||||
"model_name": model_name,
|
||||
"mode": "pro",
|
||||
"reasoning_effort": "medium",
|
||||
"thinking_enabled": True,
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"thinking_enabled": thinking_enabled,
|
||||
"is_plan_mode": False,
|
||||
"subagent_enabled": False,
|
||||
"thread_id": thread_id,
|
||||
# 圆桌四个 router(intent / recommend / leader / special)都是临时任务,
|
||||
# 用户个人记忆与本次研讨无关。MemoryMiddleware 的 Hindsight recall 是
|
||||
# 同步阻塞 HTTP 调用(单次 timeout 15s),如果记忆服务慢/不通会让前端
|
||||
# "调度中"卡很久;这里默认跳过,builtin USER.md/MEMORY.md 仍通过系统
|
||||
# 提示注入不受影响。
|
||||
"memory_recall_disabled": True,
|
||||
},
|
||||
"stream_mode": ["messages-tuple", "values"],
|
||||
"stream_subgraphs": True,
|
||||
@@ -534,111 +744,132 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
|
||||
_validate_agent_id(name, "agent_names entry")
|
||||
_validate_agent_id(main_agent_name, "main_agent_name")
|
||||
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
|
||||
def _frame(payload: dict[str, Any]) -> str:
|
||||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
# TCP loopback 到 127.0.0.1:<gateway port>。client 配置见 _make_loopback_client:
|
||||
# trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。
|
||||
async with _make_loopback_client(request) as client:
|
||||
try:
|
||||
# Per-seat prepare task — same body as before, but we no longer
|
||||
# await them with asyncio.gather. Instead, asyncio.as_completed
|
||||
# surfaces them in REAL completion order so we can emit a
|
||||
# seat_ready frame the moment each finishes.
|
||||
async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str, str]:
|
||||
info = await _get_agent(client, base, headers, sub_id)
|
||||
display_name = str(info.get("name") or sub_id)
|
||||
description = info.get("description", "") or ""
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
return sub_id.lower(), thread_id, display_name, description
|
||||
# init 阶段 fast path:全部走同进程直调(_create_thread_direct /
|
||||
# _get_agents_batch_direct / _create_coordinator_direct),不再走 HTTP
|
||||
# loopback —— TCP + 鉴权中间件 + Pydantic 反序列化每次 10-30ms,N+1 个席位
|
||||
# 累计就是"点进 Step 2 后席位一颗颗亮"的卡顿来源。
|
||||
try:
|
||||
t_init_start = time.perf_counter()
|
||||
# 1) 一次 DB 查询拿到所有席位的 name/description,替代之前的 N 个并行
|
||||
# GET /api/agents/{id}。SOUL 拼接需要描述,这里一次性拿齐。
|
||||
agent_meta = await _get_agents_batch_direct(request, agent_names)
|
||||
t_meta_done = time.perf_counter()
|
||||
|
||||
tasks = [asyncio.create_task(_prepare_sub_agent(sid)) for sid in agent_names]
|
||||
prepared: list[tuple[str, str, str, str]] = []
|
||||
try:
|
||||
for fut in asyncio.as_completed(tasks):
|
||||
sid, thread_id, display_name, description = await fut
|
||||
prepared.append((sid, thread_id, display_name, description))
|
||||
yield _frame({
|
||||
"event": "seat_ready",
|
||||
"agent_name": sid,
|
||||
"thread_id": thread_id,
|
||||
"display_name": display_name,
|
||||
"description": description,
|
||||
})
|
||||
except Exception:
|
||||
# Cancel any still-pending sibling so we don't leak tasks
|
||||
# past the AsyncClient close.
|
||||
for t in tasks:
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
raise
|
||||
|
||||
agent_threads: dict[str, str] = {sid: thread_id for sid, thread_id, _, _ in prepared}
|
||||
descriptions: list[str] = [
|
||||
f"- agent_name: {sid}({display_name}):{description}"
|
||||
for sid, _, display_name, description in prepared
|
||||
]
|
||||
|
||||
# 动态 SOUL:把本次 init 的席位列表写进总控 prompt,供 agent_orchestration 派活。
|
||||
soul = (
|
||||
"#核心定位:\n"
|
||||
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
|
||||
"当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
|
||||
"#调度方法:\n"
|
||||
"通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活。"
|
||||
"注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence,"
|
||||
"或形如 02714cbe6e64406a8a4659f0e3d9c0b8 这样的 UUID),"
|
||||
"不要把括号里的中文显示名拼进 agent_name 里。\n"
|
||||
"#关键约束(必读):\n"
|
||||
"1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,"
|
||||
"如果某个子智能体已经针对你正要派的任务完成过交付("
|
||||
"看到形如\"子智能体[名称]完成XX工作,交付内容为:...\"的 human 消息),"
|
||||
"就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题。\n"
|
||||
"2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,"
|
||||
"你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),"
|
||||
"让讨论收敛。\n"
|
||||
"3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论。\n"
|
||||
"#可调度智能体:\n"
|
||||
+ "\n".join(descriptions)
|
||||
# 2) 每个席位 thread 并行创建(直调 thread_store + checkpointer),
|
||||
# 用 asyncio.as_completed 保持"先完先发 seat_ready"的体验。
|
||||
async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str, str]:
|
||||
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
meta = agent_meta.get(sub_id.lower(), {})
|
||||
return (
|
||||
sub_id.lower(),
|
||||
thread_id,
|
||||
str(meta.get("name") or sub_id),
|
||||
str(meta.get("description") or ""),
|
||||
)
|
||||
|
||||
coord_thread = await _create_thread(client, base, headers)
|
||||
agent_threads[main_agent_name] = coord_thread
|
||||
ok, result = await _create_agent(
|
||||
client,
|
||||
base,
|
||||
headers,
|
||||
tasks = [asyncio.create_task(_prepare_sub_agent(sid)) for sid in agent_names]
|
||||
prepared: list[tuple[str, str, str, str]] = []
|
||||
try:
|
||||
for fut in asyncio.as_completed(tasks):
|
||||
sid, thread_id, display_name, description = await fut
|
||||
prepared.append((sid, thread_id, display_name, description))
|
||||
yield _frame({
|
||||
"event": "seat_ready",
|
||||
"agent_name": sid,
|
||||
"thread_id": thread_id,
|
||||
"display_name": display_name,
|
||||
"description": description,
|
||||
})
|
||||
except Exception:
|
||||
# Cancel any still-pending sibling so partially-prepared seats
|
||||
# don't keep running past the stream close.
|
||||
for t in tasks:
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
raise
|
||||
t_seats_done = time.perf_counter()
|
||||
|
||||
agent_threads: dict[str, str] = {sid: thread_id for sid, thread_id, _, _ in prepared}
|
||||
descriptions: list[str] = [
|
||||
f"- agent_name: {sid}({display_name}):{description}"
|
||||
for sid, _, display_name, description in prepared
|
||||
]
|
||||
|
||||
# 动态 SOUL:把本次 init 的席位列表写进总控 prompt,供 agent_orchestration 派活。
|
||||
soul = (
|
||||
"#核心定位:\n"
|
||||
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
|
||||
"当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
|
||||
"#调度方法:\n"
|
||||
"通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活。"
|
||||
"注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence,"
|
||||
"或形如 02714cbe6e64406a8a4659f0e3d9c0b8 这样的 UUID),"
|
||||
"不要把括号里的中文显示名拼进 agent_name 里。\n"
|
||||
"#关键约束(必读):\n"
|
||||
"1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,"
|
||||
"如果某个子智能体已经针对你正要派的任务完成过交付("
|
||||
"看到形如\"子智能体[名称]完成XX工作,交付内容为:...\"的 human 消息),"
|
||||
"就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题。\n"
|
||||
"2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,"
|
||||
"你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),"
|
||||
"让讨论收敛。\n"
|
||||
"3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论。\n"
|
||||
"#可调度智能体:\n"
|
||||
+ "\n".join(descriptions)
|
||||
)
|
||||
|
||||
# 3) coordinator thread + agent 并行创建。thread 与 agent 之间无依赖,
|
||||
# asyncio.gather 比串行省一个直调往返的开销。
|
||||
coord_thread_task = asyncio.create_task(
|
||||
_create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
)
|
||||
coord_agent_task = asyncio.create_task(
|
||||
_create_coordinator_direct(
|
||||
request,
|
||||
agent_id=main_agent_name,
|
||||
name=main_agent_name,
|
||||
description="你是总控控制协调智能体,负责协调其他智能体干活",
|
||||
soul=soul,
|
||||
model=model,
|
||||
skills=["agent_orchestration"],
|
||||
soul=soul,
|
||||
agent_id=main_agent_name,
|
||||
)
|
||||
if not ok and isinstance(result, str) and "exists" not in result:
|
||||
yield _frame({"event": "error", "detail": f"create coordinator agent failed: {result}"})
|
||||
return
|
||||
)
|
||||
coord_thread = await coord_thread_task
|
||||
agent_threads[main_agent_name] = coord_thread
|
||||
ok, result = await coord_agent_task
|
||||
if not ok and isinstance(result, str) and "exists" not in result:
|
||||
yield _frame({"event": "error", "detail": f"create coordinator agent failed: {result}"})
|
||||
return
|
||||
t_coord_done = time.perf_counter()
|
||||
logger.info(
|
||||
"[init-direct] meta=%.3fs seats=%.3fs coord=%.3fs total=%.3fs seats_count=%d",
|
||||
t_meta_done - t_init_start,
|
||||
t_seats_done - t_meta_done,
|
||||
t_coord_done - t_seats_done,
|
||||
t_coord_done - t_init_start,
|
||||
len(prepared),
|
||||
)
|
||||
|
||||
yield _frame({
|
||||
"event": "coordinator_ready",
|
||||
"main_agent_name": main_agent_name,
|
||||
"thread_id": coord_thread,
|
||||
})
|
||||
yield _frame({
|
||||
"event": "init_done",
|
||||
"main_agent_name": main_agent_name,
|
||||
"thread_ids": agent_threads,
|
||||
})
|
||||
except HTTPException as exc:
|
||||
logger.warning("multi-agent init HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
yield _frame({"event": "error", "detail": f"[{exc.status_code}] {exc.detail}"})
|
||||
except Exception as exc:
|
||||
logger.exception("multi-agent init unexpected failure")
|
||||
yield _frame({"event": "error", "detail": f"unexpected: {exc!r}"})
|
||||
yield _frame({
|
||||
"event": "coordinator_ready",
|
||||
"main_agent_name": main_agent_name,
|
||||
"thread_id": coord_thread,
|
||||
})
|
||||
yield _frame({
|
||||
"event": "init_done",
|
||||
"main_agent_name": main_agent_name,
|
||||
"thread_ids": agent_threads,
|
||||
})
|
||||
except HTTPException as exc:
|
||||
logger.warning("multi-agent init HTTPException: %s %s", exc.status_code, exc.detail)
|
||||
yield _frame({"event": "error", "detail": f"[{exc.status_code}] {exc.detail}"})
|
||||
except Exception as exc:
|
||||
logger.exception("multi-agent init unexpected failure")
|
||||
yield _frame({"event": "error", "detail": f"unexpected: {exc!r}"})
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
@@ -825,6 +1056,11 @@ async def _leader_run(
|
||||
new_message=new_message,
|
||||
excluded_tools=["web_search"],
|
||||
skill_stop_names=effective_stop_names,
|
||||
# 总控派活是"下一步派谁、什么任务"的短决策,不需要 CoT。
|
||||
# 关掉 thinking + low reasoning 让"调度中"几乎瞬间转入"正在派活",
|
||||
# 子智能体仍保留 thinking(在 _special_run 里默认 True),保证交付质量。
|
||||
thinking_enabled=False,
|
||||
reasoning_effort="low",
|
||||
)
|
||||
t_payload_built = time.perf_counter()
|
||||
logger.info(
|
||||
|
||||
@@ -49,6 +49,7 @@ from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_ROUNDTABLE_THREAD_METADATA,
|
||||
_auth_headers,
|
||||
_create_thread,
|
||||
_flatten_content,
|
||||
@@ -237,7 +238,7 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
|
||||
async with _make_loopback_client(request) as client:
|
||||
try:
|
||||
# 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
|
||||
body = _run_payload(
|
||||
agent_name=RECOMMENDER_AGENT_ID,
|
||||
@@ -253,6 +254,12 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
|
||||
"ask_clarification",
|
||||
],
|
||||
skill_stop_names=[],
|
||||
# 推荐是"从候选池挑 2-6 个"的短分类任务,不需要 CoT。
|
||||
# 关掉 thinking + 用 low reasoning,避免模型先吐数千 reasoning
|
||||
# tokens 才开始输出 [RECOMMEND_READY],前端"等待推荐结果..."
|
||||
# 从 10-30s 降到 2-5s。
|
||||
thinking_enabled=False,
|
||||
reasoning_effort="low",
|
||||
)
|
||||
|
||||
captured: list[str] = []
|
||||
|
||||
@@ -37,6 +37,10 @@ class ScheduledTaskCreateRequest(BaseModel):
|
||||
timezone: str | None = None
|
||||
execution_agent_name: str | None = None
|
||||
execution_context: dict[str, Any] | None = None
|
||||
require_markdown: bool = Field(
|
||||
default=False,
|
||||
description="Require the result to be Markdown and exceed the minimum length, else mark the run failed.",
|
||||
)
|
||||
|
||||
|
||||
class ScheduledTaskUpdateRequest(BaseModel):
|
||||
@@ -48,6 +52,7 @@ class ScheduledTaskUpdateRequest(BaseModel):
|
||||
enabled: bool | None = None
|
||||
execution_agent_name: str | None = None
|
||||
execution_context: dict[str, Any] | None = None
|
||||
require_markdown: bool | None = None
|
||||
|
||||
|
||||
class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel):
|
||||
@@ -555,6 +560,7 @@ async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Reque
|
||||
timezone=body.timezone,
|
||||
execution_agent_name=body.execution_agent_name,
|
||||
execution_context=body.execution_context,
|
||||
require_markdown=body.require_markdown,
|
||||
)
|
||||
except DuplicateTaskNameError:
|
||||
raise HTTPException(status_code=409, detail="已存在同名定时任务,请换一个名称")
|
||||
|
||||
@@ -19,17 +19,23 @@ from pathlib import Path
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from langgraph.checkpoint.base import empty_checkpoint
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
from app.gateway.deps import get_checkpointer
|
||||
from app.gateway.utils import sanitize_log_param
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from langgraph.checkpoint.base import empty_checkpoint
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.gateway.authz import require_permission
|
||||
from app.gateway.deps import get_checkpointer
|
||||
from app.gateway.utils import sanitize_log_param
|
||||
from deerflow.config.extensions_config import SkillDisplayConfig
|
||||
from deerflow.config.paths import Paths, get_paths
|
||||
from deerflow.persistence.engine import get_engine, get_session_factory
|
||||
from deerflow.persistence.skills.model import SkillDisplayAdapterRow
|
||||
from deerflow.runtime import serialize_channel_values
|
||||
from deerflow.runtime.references import (
|
||||
adapt_reference_batches_with_displays,
|
||||
build_reference_batches as build_runtime_reference_batches,
|
||||
build_reference_debug_payload,
|
||||
build_reference_debug_summary,
|
||||
)
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
@@ -81,11 +87,54 @@ def _rag_debug_file_path() -> str:
|
||||
def _log_rag_debug(message: str, *args: Any) -> None:
|
||||
logger.info(message, *args)
|
||||
_get_rag_debug_logger().info(message, *args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response / request models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _json_text_loads(value: str | None) -> dict[str, Any] | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except Exception:
|
||||
logger.debug("Failed to parse persisted skill display JSON", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def _load_persisted_skill_displays() -> dict[str, SkillDisplayConfig]:
|
||||
engine = get_engine()
|
||||
sf = get_session_factory()
|
||||
if engine is None or sf is None:
|
||||
return {}
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SkillDisplayAdapterRow.__table__.create, checkfirst=True)
|
||||
async with sf() as session:
|
||||
result = await session.execute(select(SkillDisplayAdapterRow))
|
||||
displays: dict[str, SkillDisplayConfig] = {}
|
||||
for row in result.scalars():
|
||||
payload = _json_text_loads(row.display_config)
|
||||
if payload is None:
|
||||
continue
|
||||
try:
|
||||
display = SkillDisplayConfig.model_validate(payload)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to validate persisted skill display for %s",
|
||||
row.skill_name,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
if display.enabled and display.mode != "none":
|
||||
displays[row.skill_name] = display
|
||||
return displays
|
||||
except Exception:
|
||||
logger.debug("Failed to load persisted skill display adapters", exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response / request models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ThreadDeleteResponse(BaseModel):
|
||||
@@ -183,10 +232,12 @@ class ReferenceSourceResponse(BaseModel):
|
||||
type: str = "result"
|
||||
snippet: str = ""
|
||||
source: str | None = None
|
||||
author: str | None = None
|
||||
time: str | None = None
|
||||
url: str | None = None
|
||||
content: str | None = None
|
||||
score: float | None = None
|
||||
tableColumns: list[dict[str, Any]] = Field(default_factory=list)
|
||||
raw: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -794,11 +845,16 @@ async def get_thread_reference_batches(thread_id: str, request: Request) -> Refe
|
||||
_rag_debug_file_path(),
|
||||
)
|
||||
return ReferenceBatchesResponse(batches=[])
|
||||
raw_batches = build_runtime_reference_batches(messages)
|
||||
batches_before_display_adapt = build_runtime_reference_batches(messages)
|
||||
raw_batches = batches_before_display_adapt
|
||||
persisted_displays = await _load_persisted_skill_displays()
|
||||
if persisted_displays:
|
||||
raw_batches = adapt_reference_batches_with_displays(raw_batches, persisted_displays)
|
||||
debug_summary = build_reference_debug_summary(messages, raw_batches)
|
||||
_log_rag_debug(
|
||||
"backend reference-batches thread=%s messages=%s turns=%s tool_messages=%s raw_items=%s "
|
||||
"normalized_items=%s kept_items=%s batches=%s batch_sources=%s batch_ids=%s turns_detail=%s log_file=%s",
|
||||
"normalized_items=%s kept_items=%s batches=%s batch_sources=%s batch_ids=%s "
|
||||
"display_adapters=%s turns_detail=%s log_file=%s",
|
||||
sanitize_log_param(thread_id),
|
||||
debug_summary["messages"],
|
||||
debug_summary["turns"],
|
||||
@@ -809,9 +865,29 @@ async def get_thread_reference_batches(thread_id: str, request: Request) -> Refe
|
||||
len(raw_batches),
|
||||
",".join(str(batch.get("source_count", 0)) for batch in debug_summary["batches"]),
|
||||
",".join(str(batch.get("id") or "") for batch in debug_summary["batches"]),
|
||||
len(persisted_displays),
|
||||
json.dumps(debug_summary["turns_detail"], ensure_ascii=False),
|
||||
_rag_debug_file_path(),
|
||||
)
|
||||
_log_rag_debug(
|
||||
"backend reference-batches-payload thread=%s %s",
|
||||
sanitize_log_param(thread_id),
|
||||
json.dumps(
|
||||
{
|
||||
"display_adapter_names": sorted(persisted_displays.keys()),
|
||||
"before_display_adapt": build_reference_debug_payload(
|
||||
messages=messages,
|
||||
batches=batches_before_display_adapt,
|
||||
),
|
||||
"after_display_adapt": build_reference_debug_payload(
|
||||
messages=messages,
|
||||
batches=raw_batches,
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
),
|
||||
)
|
||||
return ReferenceBatchesResponse(
|
||||
batches=[
|
||||
ReferenceBatchResponse(
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""CRUD API for a user's personal custom prompts (常用提示词).
|
||||
|
||||
Prompts are bound to the requesting user (not stored in the browser), so they
|
||||
follow the account across devices/sessions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/user-prompts", tags=["user-prompts"])
|
||||
|
||||
|
||||
class UserPromptResponse(BaseModel):
|
||||
id: str
|
||||
title: str = ""
|
||||
content: str
|
||||
sort_order: int = 0
|
||||
created_at: datetime | str | None = None
|
||||
updated_at: datetime | str | None = None
|
||||
|
||||
|
||||
class UserPromptsListResponse(BaseModel):
|
||||
prompts: list[UserPromptResponse]
|
||||
|
||||
|
||||
class UserPromptCreateRequest(BaseModel):
|
||||
title: str = Field(default="", max_length=255)
|
||||
content: str = Field(..., min_length=1)
|
||||
sort_order: int = Field(default=0)
|
||||
|
||||
|
||||
class UserPromptUpdateRequest(BaseModel):
|
||||
title: str | None = Field(default=None, max_length=255)
|
||||
content: str | None = Field(default=None, min_length=1)
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
def _current_user_id(request: Request) -> str:
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is not None:
|
||||
return str(user.id)
|
||||
return get_effective_user_id()
|
||||
|
||||
|
||||
def _get_store(request: Request):
|
||||
store = getattr(request.app.state, "user_prompt_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="User prompt store not available")
|
||||
return store
|
||||
|
||||
|
||||
@router.get("", response_model=UserPromptsListResponse)
|
||||
async def list_user_prompts(request: Request) -> UserPromptsListResponse:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
rows = await store.list_for_user(user_id)
|
||||
return UserPromptsListResponse(prompts=[UserPromptResponse(**r) for r in rows])
|
||||
|
||||
|
||||
@router.post("", response_model=UserPromptResponse, status_code=201)
|
||||
async def create_user_prompt(request: Request, body: UserPromptCreateRequest) -> UserPromptResponse:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
row = await store.create(
|
||||
user_id,
|
||||
{
|
||||
"id": uuid4().hex,
|
||||
"title": (body.title or "").strip(),
|
||||
"content": body.content,
|
||||
"sort_order": body.sort_order,
|
||||
},
|
||||
)
|
||||
return UserPromptResponse(**row)
|
||||
|
||||
|
||||
@router.put("/{prompt_id}", response_model=UserPromptResponse)
|
||||
async def update_user_prompt(request: Request, prompt_id: str, body: UserPromptUpdateRequest) -> UserPromptResponse:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
data: dict = {}
|
||||
if body.title is not None:
|
||||
data["title"] = body.title.strip()
|
||||
if body.content is not None:
|
||||
data["content"] = body.content
|
||||
if body.sort_order is not None:
|
||||
data["sort_order"] = body.sort_order
|
||||
row = await store.update(prompt_id, user_id, data)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Prompt not found")
|
||||
return UserPromptResponse(**row)
|
||||
|
||||
|
||||
@router.delete("/{prompt_id}", status_code=204)
|
||||
async def delete_user_prompt(request: Request, prompt_id: str) -> None:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
deleted = await store.delete(prompt_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Prompt not found")
|
||||
+10
@@ -137,7 +137,17 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
|
||||
|
||||
召回结果包在 ``<memory-context>`` 标签里并带系统提示,告诉模型这是
|
||||
背景信息而非用户的新输入。
|
||||
|
||||
**跳过开关**:若 ``runtime.context.memory_recall_disabled`` 为真,直接
|
||||
返回 None。圆桌规划等临时性任务(intent / recommend / leader / special)
|
||||
不需要从用户个人记忆里召回,Hindsight HTTP 同步阻塞会显著增加 LLM 首
|
||||
字节延迟(前端"调度中"卡顿);由路由层通过 ``_run_payload`` 的 context
|
||||
字段统一关闭。
|
||||
"""
|
||||
runtime = getattr(request, "runtime", None)
|
||||
if runtime is not None and getattr(runtime, "context", None):
|
||||
if runtime.context.get("memory_recall_disabled"):
|
||||
return None
|
||||
if not self._injection_enabled:
|
||||
return None
|
||||
config = self._memory_config or get_memory_config()
|
||||
|
||||
+18
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -13,7 +14,11 @@ from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import SystemMessage
|
||||
|
||||
from deerflow.runtime.references import build_latest_reference_prompt, build_reference_debug_summary
|
||||
from deerflow.runtime.references import (
|
||||
build_latest_reference_prompt,
|
||||
build_reference_debug_payload,
|
||||
build_reference_debug_summary,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_RAG_DEBUG_LOGGER_NAME = "rag_citation_debug"
|
||||
@@ -84,6 +89,18 @@ class RagCitationMiddleware(AgentMiddleware[AgentState]):
|
||||
summary["kept_items"],
|
||||
len((batch or {}).get("sources") or []),
|
||||
)
|
||||
_log_rag_debug(
|
||||
"middleware reference-prompt-payload %s",
|
||||
json.dumps(
|
||||
build_reference_debug_payload(
|
||||
messages=messages,
|
||||
batch=batch,
|
||||
prompt=prompt,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
),
|
||||
)
|
||||
patched_messages = [
|
||||
*messages,
|
||||
SystemMessage(content=prompt),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Configuration and loaders for custom agents."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
@@ -13,6 +15,25 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
SOUL_FILENAME = "SOUL.md"
|
||||
AGENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
# Per-file mtime caches. These are read on every /api/agents list/detail request
|
||||
# (one read per agent) — caching keyed by (path, mtime_ns, size) is correct
|
||||
# because any edit to the file updates mtime, and an external tool re-writing
|
||||
# the same bytes doesn't actually change the result we'd return. Cache size is
|
||||
# unbounded but lookup is O(1) and entries are tiny (one parsed dict / string
|
||||
# per agent file); for the order of N≤10⁴ agents the memory cost is negligible.
|
||||
_config_cache: dict[str, tuple[tuple[int, int], "AgentConfig | None"]] = {}
|
||||
_soul_cache: dict[str, tuple[tuple[int, int], str | None]] = {}
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def _file_stat_key(path) -> tuple[int, int] | None:
|
||||
"""Return (mtime_ns, size) for cache invalidation, or None when missing."""
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
return None
|
||||
return (st.st_mtime_ns, st.st_size)
|
||||
# Backward-compatible alias. Historically this validated the display name;
|
||||
# after the DB-backed agent table, display names can be Chinese and duplicated.
|
||||
# The pattern now applies only to storage/runtime ids.
|
||||
@@ -94,6 +115,18 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None:
|
||||
)
|
||||
return None
|
||||
|
||||
# mtime-based cache. The config file is rewritten every time the agent is
|
||||
# edited via PUT /api/agents/{id}, which bumps mtime — so the next read
|
||||
# picks up the change automatically. Missing config_file = key None below,
|
||||
# bypassing the cache (cheap path: just return AgentConfig with id/name).
|
||||
cache_path_key = str(config_file)
|
||||
stat_key = _file_stat_key(config_file) if config_file.exists() else None
|
||||
if stat_key is not None:
|
||||
with _cache_lock:
|
||||
cached = _config_cache.get(cache_path_key)
|
||||
if cached is not None and cached[0] == stat_key:
|
||||
return cached[1]
|
||||
|
||||
data: dict[str, Any] = {}
|
||||
if config_file.exists():
|
||||
try:
|
||||
@@ -112,7 +145,11 @@ def load_agent_config(agent_id: str | None) -> AgentConfig | None:
|
||||
known_fields = set(AgentConfig.model_fields.keys())
|
||||
data = {k: v for k, v in data.items() if k in known_fields}
|
||||
|
||||
return AgentConfig(**data)
|
||||
result = AgentConfig(**data)
|
||||
if stat_key is not None:
|
||||
with _cache_lock:
|
||||
_config_cache[cache_path_key] = (stat_key, result)
|
||||
return result
|
||||
|
||||
|
||||
def load_agent_soul(agent_id: str | None) -> str | None:
|
||||
@@ -131,8 +168,21 @@ def load_agent_soul(agent_id: str | None) -> str | None:
|
||||
soul_path = agent_dir / SOUL_FILENAME
|
||||
if not soul_path.exists():
|
||||
return None
|
||||
# mtime-based cache, same rationale as load_agent_config: SOUL.md is
|
||||
# rewritten on edit (PUT /api/agents/{id}), which bumps mtime.
|
||||
cache_path_key = str(soul_path)
|
||||
stat_key = _file_stat_key(soul_path)
|
||||
if stat_key is not None:
|
||||
with _cache_lock:
|
||||
cached = _soul_cache.get(cache_path_key)
|
||||
if cached is not None and cached[0] == stat_key:
|
||||
return cached[1]
|
||||
content = soul_path.read_text(encoding="utf-8").strip()
|
||||
return content or None
|
||||
result = content or None
|
||||
if stat_key is not None:
|
||||
with _cache_lock:
|
||||
_soul_cache[cache_path_key] = (stat_key, result)
|
||||
return result
|
||||
|
||||
|
||||
def list_custom_agents() -> list[AgentConfig]:
|
||||
|
||||
@@ -75,6 +75,7 @@ class SkillDisplayConfig(BaseModel):
|
||||
summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField")
|
||||
content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField")
|
||||
source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField")
|
||||
author_field: str | None = Field(default=None, description="Field used as author/byline label", alias="authorField")
|
||||
type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField")
|
||||
time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField")
|
||||
score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField")
|
||||
|
||||
+2
-1
@@ -4,10 +4,11 @@ from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.agents.base import AgentStore
|
||||
from deerflow.persistence.agents.memory import MemoryAgentStore
|
||||
from deerflow.persistence.agents.model import AgentRow
|
||||
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
|
||||
from deerflow.persistence.agents.sql import AgentRepository
|
||||
|
||||
__all__ = [
|
||||
"AgentFavoriteRow",
|
||||
"AgentRepository",
|
||||
"AgentRow",
|
||||
"AgentStore",
|
||||
|
||||
@@ -117,3 +117,25 @@ class AgentStore(ABC):
|
||||
``False`` if the agent does not exist.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
# --- Favorites ("收藏") ---
|
||||
|
||||
@abstractmethod
|
||||
async def add_favorite(self, user_id: str, agent_id: str) -> None:
|
||||
"""Bookmark ``agent_id`` for ``user_id``. Idempotent."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def remove_favorite(self, user_id: str, agent_id: str) -> bool:
|
||||
"""Remove a bookmark. Returns ``False`` when none existed."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_favorite_ids(self, user_id: str) -> set[str]:
|
||||
"""Return the set of agent ids ``user_id`` has favorited."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_favorites_for_agent(self, agent_id: str) -> None:
|
||||
"""Drop every user's favorite of ``agent_id`` (called on agent delete)."""
|
||||
raise NotImplementedError
|
||||
+18
@@ -21,6 +21,7 @@ def _copy(data: dict[str, Any]) -> dict[str, Any]:
|
||||
class MemoryAgentStore(AgentStore):
|
||||
def __init__(self) -> None:
|
||||
self._agents: dict[str, dict[str, Any]] = {}
|
||||
self._favorites: set[tuple[str, str]] = set()
|
||||
|
||||
@staticmethod
|
||||
def _is_visible(agent: dict[str, Any], user_id: str) -> bool:
|
||||
@@ -164,3 +165,20 @@ class MemoryAgentStore(AgentStore):
|
||||
return False
|
||||
self._agents.pop(agent_id, None)
|
||||
return True
|
||||
|
||||
# --- Favorites ("收藏") ---
|
||||
|
||||
async def add_favorite(self, user_id: str, agent_id: str) -> None:
|
||||
self._favorites.add((user_id, agent_id))
|
||||
|
||||
async def remove_favorite(self, user_id: str, agent_id: str) -> bool:
|
||||
if (user_id, agent_id) in self._favorites:
|
||||
self._favorites.discard((user_id, agent_id))
|
||||
return True
|
||||
return False
|
||||
|
||||
async def list_favorite_ids(self, user_id: str) -> set[str]:
|
||||
return {agent_id for (uid, agent_id) in self._favorites if uid == user_id}
|
||||
|
||||
async def delete_favorites_for_agent(self, agent_id: str) -> None:
|
||||
self._favorites = {(uid, aid) for (uid, aid) in self._favorites if aid != agent_id}
|
||||
+18
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, String
|
||||
from sqlalchemy import BigInteger, Boolean, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
@@ -35,3 +35,20 @@ class AgentRow(Base):
|
||||
featured_order: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class AgentFavoriteRow(Base):
|
||||
"""A user's bookmark of a (typically public-square) agent.
|
||||
|
||||
Lets a user collect agents from the 广场 (square) into their "我的" tab
|
||||
without owning them. One row per (user, agent); the unique constraint makes
|
||||
favoriting idempotent.
|
||||
"""
|
||||
|
||||
__tablename__ = "agent_favorites"
|
||||
__table_args__ = (UniqueConstraint("user_id", "agent_id", name="uq_agent_favorites_user_agent"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
+48
-2
@@ -4,12 +4,14 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy import and_, delete, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.agents.base import AgentStore
|
||||
from deerflow.persistence.agents.model import AgentRow
|
||||
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
|
||||
|
||||
|
||||
def _row_to_dict(row: AgentRow) -> dict[str, Any]:
|
||||
@@ -230,3 +232,47 @@ class AgentRepository(AgentStore):
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
# --- Favorites ("收藏") ---
|
||||
|
||||
async def add_favorite(self, user_id: str, agent_id: str) -> None:
|
||||
async with self._sf() as session:
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(AgentFavoriteRow.id)
|
||||
.where(AgentFavoriteRow.user_id == user_id, AgentFavoriteRow.agent_id == agent_id)
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return
|
||||
session.add(AgentFavoriteRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id))
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
# Lost a race against a concurrent favorite of the same agent —
|
||||
# the unique constraint already guarantees the desired state.
|
||||
await session.rollback()
|
||||
|
||||
async def remove_favorite(self, user_id: str, agent_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
delete(AgentFavoriteRow).where(
|
||||
AgentFavoriteRow.user_id == user_id,
|
||||
AgentFavoriteRow.agent_id == agent_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return (result.rowcount or 0) > 0
|
||||
|
||||
async def list_favorite_ids(self, user_id: str) -> set[str]:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id)
|
||||
)
|
||||
return {row[0] for row in result.all()}
|
||||
|
||||
async def delete_favorites_for_agent(self, agent_id: str) -> None:
|
||||
async with self._sf() as session:
|
||||
await session.execute(delete(AgentFavoriteRow).where(AgentFavoriteRow.agent_id == agent_id))
|
||||
await session.commit()
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
"""Add ``agent_favorites`` for the agent collection (收藏) feature.
|
||||
|
||||
Lets a user bookmark agents from the public square (广场) so they surface in
|
||||
their "我的" tab without owning them. One row per (user, agent).
|
||||
|
||||
Revision ID: 20260528_01
|
||||
Revises: 20260526_01
|
||||
Create Date: 2026-05-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260528_01"
|
||||
down_revision = "20260526_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_table("agent_favorites"):
|
||||
op.create_table(
|
||||
"agent_favorites",
|
||||
sa.Column("id", sa.String(64), primary_key=True),
|
||||
sa.Column("user_id", sa.String(64), nullable=False),
|
||||
sa.Column("agent_id", sa.String(64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint("user_id", "agent_id", name="uq_agent_favorites_user_agent"),
|
||||
)
|
||||
op.create_index("ix_agent_favorites_user_id", "agent_favorites", ["user_id"])
|
||||
op.create_index("ix_agent_favorites_agent_id", "agent_favorites", ["agent_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_table("agent_favorites"):
|
||||
op.drop_table("agent_favorites")
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
"""Add ``user_custom_prompts`` for per-user saved prompts (常用提示词).
|
||||
|
||||
Bound to the user (not the browser) so prompts follow the account.
|
||||
|
||||
Revision ID: 20260528_02
|
||||
Revises: 20260528_01
|
||||
Create Date: 2026-05-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260528_02"
|
||||
down_revision = "20260528_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_table("user_custom_prompts"):
|
||||
op.create_table(
|
||||
"user_custom_prompts",
|
||||
sa.Column("id", sa.String(64), primary_key=True),
|
||||
sa.Column("user_id", sa.String(64), nullable=False),
|
||||
sa.Column("title", sa.String(255), nullable=False, server_default=""),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_user_custom_prompts_user_id", "user_custom_prompts", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_table("user_custom_prompts"):
|
||||
op.drop_table("user_custom_prompts")
|
||||
+4
-1
@@ -15,7 +15,7 @@ its storage implementation lives in ``deerflow.runtime.events.store.db`` and
|
||||
there is no matching entity directory.
|
||||
"""
|
||||
|
||||
from deerflow.persistence.agents.model import AgentRow
|
||||
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
|
||||
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
|
||||
from deerflow.persistence.feedback.model import FeedbackRow
|
||||
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||
@@ -36,9 +36,11 @@ from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.persistence.thread_shares.model import ThreadShareRow
|
||||
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
|
||||
from deerflow.persistence.user.model import UserRow
|
||||
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
|
||||
|
||||
__all__ = [
|
||||
"FeedbackRow",
|
||||
"AgentFavoriteRow",
|
||||
"AgentRow",
|
||||
"LlmCallMetricRow",
|
||||
"NotificationRow",
|
||||
@@ -57,6 +59,7 @@ __all__ = [
|
||||
"ThreadMetaRow",
|
||||
"ThreadShareRow",
|
||||
"ToolCallMetricRow",
|
||||
"UserCustomPromptRow",
|
||||
"UserMemoryConfigRow",
|
||||
"UserRow",
|
||||
]
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
"""Per-user custom prompt persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
|
||||
from deerflow.persistence.user_prompts.sql import UserCustomPromptRepository
|
||||
|
||||
__all__ = [
|
||||
"UserCustomPromptRepository",
|
||||
"UserCustomPromptRow",
|
||||
"make_user_prompt_store",
|
||||
]
|
||||
|
||||
|
||||
def make_user_prompt_store(session_factory):
|
||||
if session_factory is None:
|
||||
return None
|
||||
return UserCustomPromptRepository(session_factory)
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
"""ORM model for per-user custom prompts (常用提示词)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime
|
||||
|
||||
|
||||
class UserCustomPromptRow(Base):
|
||||
__tablename__ = "user_custom_prompts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
"""SQLAlchemy-backed per-user custom prompt repository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
|
||||
|
||||
|
||||
def _row_to_dict(row: UserCustomPromptRow) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
for key in ("created_at", "updated_at"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, datetime):
|
||||
data[key] = value.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
class UserCustomPromptRepository:
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def list_for_user(self, user_id: str) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(UserCustomPromptRow)
|
||||
.where(UserCustomPromptRow.user_id == user_id)
|
||||
.order_by(UserCustomPromptRow.sort_order.asc(), UserCustomPromptRow.created_at.asc())
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_row_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def create(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = UserCustomPromptRow(
|
||||
id=data["id"],
|
||||
user_id=user_id,
|
||||
title=data.get("title") or "",
|
||||
content=data["content"],
|
||||
sort_order=data.get("sort_order", 0),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
async def update(self, prompt_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(UserCustomPromptRow, prompt_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
for key in ("title", "content", "sort_order"):
|
||||
if key in data:
|
||||
setattr(row, key, data[key])
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _row_to_dict(row)
|
||||
|
||||
async def delete(self, prompt_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(UserCustomPromptRow, prompt_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -3,7 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from deerflow.config.extensions_config import SkillDisplayConfig, get_extensions_config
|
||||
|
||||
|
||||
_SKILL_PATH_RE = re.compile(r"[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)")
|
||||
|
||||
|
||||
def build_reference_batches(messages: list[Any]) -> list[dict[str, Any]]:
|
||||
@@ -16,12 +23,10 @@ def build_reference_batches(messages: list[Any]) -> list[dict[str, Any]]:
|
||||
if _message_type(messages[cursor]) == "human":
|
||||
end = cursor
|
||||
break
|
||||
assistant = _find_last_assistant_answer(messages, index + 1, end)
|
||||
if assistant is None:
|
||||
continue
|
||||
sources = _collect_sources(messages[index + 1 : end])
|
||||
if sources:
|
||||
message_id = _message_id(assistant) or str(index)
|
||||
anchor = _find_reference_batch_anchor(messages, index + 1, end)
|
||||
message_id = _message_id(anchor) or _message_id(message) or str(index)
|
||||
batches.append(
|
||||
{
|
||||
"id": message_id,
|
||||
@@ -39,7 +44,7 @@ def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, A
|
||||
sources = _collect_sources(messages[human_index + 1 :])
|
||||
if not sources:
|
||||
return "", None
|
||||
assistant = _find_last_assistant_answer(messages, human_index + 1, len(messages))
|
||||
assistant = _find_reference_batch_anchor(messages, human_index + 1, len(messages))
|
||||
message_id = _message_id(assistant) or _message_id(messages[human_index]) or str(human_index)
|
||||
batch = {
|
||||
"id": message_id,
|
||||
@@ -55,7 +60,7 @@ def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, A
|
||||
title = source.get("title") or f"参考文献 {source.get('index')}"
|
||||
meta = " · ".join(
|
||||
str(item)
|
||||
for item in (source.get("source"), source.get("time"))
|
||||
for item in (source.get("source"), source.get("author"), source.get("time"))
|
||||
if item
|
||||
)
|
||||
content = str(source.get("content") or source.get("snippet") or "").strip()
|
||||
@@ -76,6 +81,7 @@ def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, A
|
||||
"3. 只能引用上方存在的编号,不要编造编号。",
|
||||
"4. 不要使用工具原始编号,也不要重新排序编号。",
|
||||
"5. 不要在正文末尾堆完整参考文献列表,前端会展示右侧参考文献。",
|
||||
"6. 参考文献模式下不要输出 <skill-display .../> 占位符,也不要在正文里额外渲染卡片、表格或列表;前端会在右侧参考文献区域展示。",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines).strip(), batch
|
||||
@@ -152,14 +158,191 @@ def build_reference_debug_summary(messages: list[Any], batches: list[dict[str, A
|
||||
}
|
||||
|
||||
|
||||
def build_reference_debug_payload(
|
||||
*,
|
||||
messages: list[Any] | None = None,
|
||||
batch: dict[str, Any] | None = None,
|
||||
batches: list[dict[str, Any]] | None = None,
|
||||
prompt: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a JSON-safe payload for comparing model prompts and UI batches."""
|
||||
payload: dict[str, Any] = {}
|
||||
if messages is not None:
|
||||
payload["message_trace"] = [_message_debug_item(index, message) for index, message in enumerate(messages)]
|
||||
if prompt is not None:
|
||||
payload["model_reference_prompt"] = prompt
|
||||
if batch is not None:
|
||||
payload["batch"] = _batch_debug_item(batch)
|
||||
if batches is not None:
|
||||
payload["batches"] = [_batch_debug_item(item) for item in batches]
|
||||
return payload
|
||||
|
||||
|
||||
def adapt_reference_batches_with_displays(
|
||||
batches: list[dict[str, Any]],
|
||||
displays: dict[str, SkillDisplayConfig | dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Apply persisted skill display configs to already-built reference batches.
|
||||
|
||||
The reference builder can run before the gateway has a fresh extensions
|
||||
cache, while the skill adapter editor persists the user's latest mapping in
|
||||
the database. This pass lets the API response use that DB-backed mapping
|
||||
without changing citation indexes.
|
||||
"""
|
||||
normalized_displays: dict[str, SkillDisplayConfig] = {}
|
||||
for skill_name, display in displays.items():
|
||||
try:
|
||||
config = (
|
||||
display
|
||||
if isinstance(display, SkillDisplayConfig)
|
||||
else SkillDisplayConfig.model_validate(display)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if not config.enabled or config.mode == "none":
|
||||
continue
|
||||
normalized_displays[skill_name] = config
|
||||
|
||||
if not normalized_displays:
|
||||
return batches
|
||||
|
||||
displays_by_normalized_name = {
|
||||
_normalize_name(name): (name, display)
|
||||
for name, display in normalized_displays.items()
|
||||
}
|
||||
adapted_batches: list[dict[str, Any]] = []
|
||||
for batch in batches:
|
||||
sources: list[dict[str, Any]] = []
|
||||
for source in batch.get("sources", []):
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
skill_name = str(source.get("skillName") or "")
|
||||
display = normalized_displays.get(skill_name)
|
||||
if display is None:
|
||||
normalized_source = _normalize_name(skill_name)
|
||||
normalized_match = displays_by_normalized_name.get(normalized_source)
|
||||
if normalized_match is None:
|
||||
for normalized_skill, candidate in displays_by_normalized_name.items():
|
||||
if (
|
||||
normalized_source.endswith(normalized_skill)
|
||||
or normalized_skill in normalized_source
|
||||
):
|
||||
normalized_match = candidate
|
||||
break
|
||||
if normalized_match is not None:
|
||||
skill_name, display = normalized_match
|
||||
raw = source.get("raw")
|
||||
if display is not None and isinstance(raw, dict):
|
||||
index_value = int(source.get("index") or len(sources) + 1)
|
||||
configured = _normalize_with_display_config(
|
||||
raw,
|
||||
index_value,
|
||||
skill_name,
|
||||
display,
|
||||
)
|
||||
if configured is not None:
|
||||
sources.append(
|
||||
{
|
||||
**source,
|
||||
**configured,
|
||||
"index": index_value,
|
||||
"skillName": skill_name,
|
||||
"raw": raw,
|
||||
}
|
||||
)
|
||||
continue
|
||||
sources.append(source)
|
||||
adapted_batches.append({**batch, "sources": sources})
|
||||
return adapted_batches
|
||||
|
||||
|
||||
def _message_debug_item(index: int, message: Any) -> dict[str, Any]:
|
||||
content = _message_content(message)
|
||||
tool_calls = _extract_tool_calls(message)
|
||||
return {
|
||||
"position": index,
|
||||
"type": _message_type(message),
|
||||
"id": _message_id(message),
|
||||
"name": _message_name(message) if _message_type(message) == "tool" else None,
|
||||
"tool_call_id": _message_tool_call_id(message) if _message_type(message) == "tool" else None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": call.get("id"),
|
||||
"name": call.get("name"),
|
||||
"args": call.get("args"),
|
||||
}
|
||||
for call in tool_calls
|
||||
],
|
||||
"content_length": len(content) if isinstance(content, str) else None,
|
||||
}
|
||||
|
||||
|
||||
def _batch_debug_item(batch: dict[str, Any]) -> dict[str, Any]:
|
||||
sources = [source for source in batch.get("sources", []) if isinstance(source, dict)]
|
||||
return {
|
||||
"id": batch.get("id"),
|
||||
"assistant_message_id": batch.get("assistant_message_id"),
|
||||
"source_count": len(sources),
|
||||
"sources": [_source_debug_item(source) for source in sources],
|
||||
}
|
||||
|
||||
|
||||
def _source_debug_item(source: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = source.get("raw")
|
||||
raw_record = raw if isinstance(raw, dict) else {}
|
||||
return {
|
||||
"index": source.get("index"),
|
||||
"id": source.get("id"),
|
||||
"skillName": source.get("skillName"),
|
||||
"mode": source.get("mode"),
|
||||
"title": source.get("title"),
|
||||
"source": source.get("source"),
|
||||
"author": source.get("author"),
|
||||
"time": source.get("time"),
|
||||
"url": source.get("url"),
|
||||
"snippet": source.get("snippet"),
|
||||
"content": source.get("content"),
|
||||
"raw_id_candidates": {
|
||||
"recUuid": raw_record.get("recUuid"),
|
||||
"rec_uuid": raw_record.get("rec_uuid"),
|
||||
"uuid": raw_record.get("uuid"),
|
||||
"docId": raw_record.get("docId"),
|
||||
"doc_id": raw_record.get("doc_id"),
|
||||
"id": raw_record.get("id"),
|
||||
"url": raw_record.get("url"),
|
||||
},
|
||||
"raw": raw,
|
||||
}
|
||||
|
||||
|
||||
def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
|
||||
sources: list[dict[str, Any]] = []
|
||||
pending_tool_contexts: list[dict[str, Any]] = []
|
||||
tool_contexts_by_id: dict[str, dict[str, Any]] = {}
|
||||
for message in messages:
|
||||
if _message_type(message) != "tool":
|
||||
message_type = _message_type(message)
|
||||
if message_type == "ai":
|
||||
for tool_call in _extract_tool_calls(message):
|
||||
context = _build_tool_context(tool_call)
|
||||
pending_tool_contexts.append(context)
|
||||
if context.get("id"):
|
||||
tool_contexts_by_id[str(context["id"])] = context
|
||||
continue
|
||||
if message_type != "tool":
|
||||
continue
|
||||
tool_context = _resolve_tool_context(
|
||||
message,
|
||||
pending_tool_contexts,
|
||||
tool_contexts_by_id,
|
||||
)
|
||||
payload = _extract_tool_payload(message)
|
||||
for raw_item in _first_list(payload):
|
||||
normalized = _normalize_reference_item(raw_item, len(sources) + 1)
|
||||
normalized = _normalize_reference_item(
|
||||
raw_item,
|
||||
len(sources) + 1,
|
||||
payload=payload,
|
||||
tool_context=tool_context,
|
||||
)
|
||||
if not normalized:
|
||||
continue
|
||||
normalized["index"] = len(sources) + 1
|
||||
@@ -167,6 +350,17 @@ def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
|
||||
return sources
|
||||
|
||||
|
||||
def _find_reference_batch_anchor(messages: list[Any], start: int, end: int) -> Any | None:
|
||||
answer = _find_last_assistant_answer(messages, start, end)
|
||||
if answer is not None:
|
||||
return answer
|
||||
for index in range(end - 1, start - 1, -1):
|
||||
candidate = messages[index]
|
||||
if _message_type(candidate) == "ai" and _message_id(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _find_last_assistant_answer(messages: list[Any], start: int, end: int) -> Any | None:
|
||||
for index in range(end - 1, start - 1, -1):
|
||||
candidate = messages[index]
|
||||
@@ -174,7 +368,6 @@ def _find_last_assistant_answer(messages: list[Any], start: int, end: int) -> An
|
||||
_message_type(candidate) == "ai"
|
||||
and _message_id(candidate)
|
||||
and _has_answer_content(candidate)
|
||||
and not _has_tool_calls(candidate)
|
||||
):
|
||||
return candidate
|
||||
return None
|
||||
@@ -195,6 +388,20 @@ def _message_content(message: Any) -> Any:
|
||||
return message.get("content") if isinstance(message, dict) else getattr(message, "content", None)
|
||||
|
||||
|
||||
def _message_name(message: Any) -> str:
|
||||
if isinstance(message, dict):
|
||||
return str(message.get("name") or (message.get("additional_kwargs") or {}).get("name") or "tool")
|
||||
return str(getattr(message, "name", None) or "tool")
|
||||
|
||||
|
||||
def _message_tool_call_id(message: Any) -> str | None:
|
||||
if isinstance(message, dict):
|
||||
value = message.get("tool_call_id") or message.get("toolCallId")
|
||||
else:
|
||||
value = getattr(message, "tool_call_id", None) or getattr(message, "toolCallId", None)
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _has_tool_calls(message: Any) -> bool:
|
||||
if isinstance(message, dict):
|
||||
direct = message.get("tool_calls")
|
||||
@@ -221,6 +428,102 @@ def _has_answer_content(message: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _extract_tool_calls(message: Any) -> list[dict[str, Any]]:
|
||||
candidates: list[Any] = []
|
||||
if isinstance(message, dict):
|
||||
for key in ("tool_calls", "tool_call_chunks"):
|
||||
value = message.get(key)
|
||||
if isinstance(value, list):
|
||||
candidates.extend(value)
|
||||
extra = (message.get("additional_kwargs") or {}).get("tool_calls")
|
||||
if isinstance(extra, list):
|
||||
candidates.extend(extra)
|
||||
else:
|
||||
for key in ("tool_calls", "tool_call_chunks"):
|
||||
value = getattr(message, key, None)
|
||||
if isinstance(value, list):
|
||||
candidates.extend(value)
|
||||
extra = (getattr(message, "additional_kwargs", None) or {}).get("tool_calls")
|
||||
if isinstance(extra, list):
|
||||
candidates.extend(extra)
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
for raw in candidates:
|
||||
call = _normalize_tool_call(raw)
|
||||
if not call:
|
||||
continue
|
||||
call_id = str(call.get("id") or "")
|
||||
if call_id and call_id in seen_ids:
|
||||
continue
|
||||
if call_id:
|
||||
seen_ids.add(call_id)
|
||||
calls.append(call)
|
||||
return calls
|
||||
|
||||
|
||||
def _normalize_tool_call(raw: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
fn = raw.get("function") if isinstance(raw.get("function"), dict) else {}
|
||||
name = raw.get("name") or fn.get("name") or raw.get("tool_name")
|
||||
if not name:
|
||||
return None
|
||||
return {
|
||||
"id": raw.get("id") or raw.get("tool_call_id") or raw.get("call_id"),
|
||||
"name": str(name),
|
||||
"args": _normalize_tool_args(raw.get("args") or raw.get("arguments") or fn.get("arguments")),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_tool_args(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except Exception:
|
||||
pass
|
||||
return {"command": value}
|
||||
return {}
|
||||
|
||||
|
||||
def _build_tool_context(tool_call: dict[str, Any]) -> dict[str, Any]:
|
||||
tool_name = str(tool_call.get("name") or "tool")
|
||||
args = tool_call.get("args") if isinstance(tool_call.get("args"), dict) else {}
|
||||
return {
|
||||
"id": tool_call.get("id"),
|
||||
"tool_name": tool_name,
|
||||
"args": args,
|
||||
"skill_name": _infer_skill_name(tool_name, args),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_tool_context(
|
||||
message: Any,
|
||||
pending_tool_contexts: list[dict[str, Any]],
|
||||
tool_contexts_by_id: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
tool_call_id = _message_tool_call_id(message)
|
||||
if tool_call_id and tool_call_id in tool_contexts_by_id:
|
||||
context = tool_contexts_by_id[tool_call_id]
|
||||
pending_tool_contexts[:] = [
|
||||
item for item in pending_tool_contexts if str(item.get("id") or "") != tool_call_id
|
||||
]
|
||||
return context
|
||||
if pending_tool_contexts:
|
||||
return pending_tool_contexts.pop(0)
|
||||
tool_name = _message_name(message)
|
||||
return {
|
||||
"id": tool_call_id,
|
||||
"tool_name": tool_name,
|
||||
"args": {},
|
||||
"skill_name": _infer_skill_name(tool_name, {}),
|
||||
}
|
||||
|
||||
|
||||
def _extract_tool_payload(message: Any) -> Any:
|
||||
content = _message_content(message)
|
||||
if isinstance(content, dict):
|
||||
@@ -313,17 +616,189 @@ def _pick(layers: list[dict[str, Any]], keys: tuple[str, ...]) -> str | None:
|
||||
value = layer.get(key)
|
||||
if isinstance(value, (str, int, float)) and str(value).strip():
|
||||
return str(value).strip()
|
||||
if isinstance(value, list):
|
||||
text = _stringify(value, "")
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_reference_item(raw: Any, fallback_index: int) -> dict[str, Any] | None:
|
||||
def _enabled_skill_displays() -> dict[str, SkillDisplayConfig]:
|
||||
try:
|
||||
return {
|
||||
name: state.display
|
||||
for name, state in get_extensions_config().skills.items()
|
||||
if state.display is not None
|
||||
and state.enabled
|
||||
and state.display.enabled
|
||||
and state.display.mode != "none"
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _infer_skill_name(tool_name: str, args: dict[str, Any]) -> str | None:
|
||||
displays = _enabled_skill_displays()
|
||||
if tool_name in displays:
|
||||
return tool_name
|
||||
normalized_tool = _normalize_name(tool_name)
|
||||
for skill_name in displays:
|
||||
normalized_skill = _normalize_name(skill_name)
|
||||
if (
|
||||
normalized_tool == normalized_skill
|
||||
or normalized_tool.endswith(normalized_skill)
|
||||
or normalized_skill in normalized_tool
|
||||
):
|
||||
return skill_name
|
||||
for value in args.values():
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
match = _SKILL_PATH_RE.search(value)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_display_match(
|
||||
raw: dict[str, Any],
|
||||
payload: Any,
|
||||
tool_context: dict[str, Any] | None,
|
||||
) -> tuple[str, SkillDisplayConfig] | None:
|
||||
displays = _enabled_skill_displays()
|
||||
if not displays:
|
||||
return None
|
||||
skill_name = str((tool_context or {}).get("skill_name") or "")
|
||||
display = displays.get(skill_name)
|
||||
if display is not None:
|
||||
return skill_name, display
|
||||
tool_name = str((tool_context or {}).get("tool_name") or "")
|
||||
inferred = _infer_skill_name(tool_name, (tool_context or {}).get("args") or {})
|
||||
if inferred and inferred in displays:
|
||||
return inferred, displays[inferred]
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_with_display_config(
|
||||
raw: dict[str, Any],
|
||||
fallback_index: int,
|
||||
skill_name: str,
|
||||
display: SkillDisplayConfig,
|
||||
) -> dict[str, Any] | None:
|
||||
title = _stringify(_get_by_path(raw, display.title_field), f"结果 {fallback_index}")
|
||||
snippet = _stringify(_get_by_path(raw, display.summary_field), "")
|
||||
content = _stringify(_get_by_path(raw, display.content_field), snippet)
|
||||
source = _stringify(_get_by_path(raw, display.source_field), "") or None
|
||||
author = _stringify(_get_by_path(raw, display.author_field), "") or None
|
||||
item_type = _stringify(_get_by_path(raw, display.type_field), "result") or "result"
|
||||
time = _stringify(_get_by_path(raw, display.time_field), "") or None
|
||||
item_id = _stringify(_get_by_path(raw, display.id_field), "") or None
|
||||
score = _number_or_none(_get_by_path(raw, display.score_field))
|
||||
url = _resolve_display_link(raw, display)
|
||||
if not title and not content and not url:
|
||||
return None
|
||||
return {
|
||||
"id": item_id or url,
|
||||
"skillName": skill_name,
|
||||
"mode": display.mode,
|
||||
"title": title,
|
||||
"type": item_type,
|
||||
"snippet": snippet,
|
||||
"content": content,
|
||||
"source": source,
|
||||
"author": author,
|
||||
"time": time,
|
||||
"url": url,
|
||||
"score": score,
|
||||
"tableColumns": [column.model_dump(by_alias=True) for column in display.table_columns],
|
||||
"raw": raw,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_display_link(raw: dict[str, Any], display: SkillDisplayConfig) -> str | None:
|
||||
url = _stringify(_get_by_path(raw, display.url_field), "")
|
||||
item_id = _stringify(_get_by_path(raw, display.id_field), "")
|
||||
if display.link_strategy == "url":
|
||||
return url or None
|
||||
if display.link_strategy == "id":
|
||||
return _render_template(display.link_template, raw) if item_id and display.link_template else None
|
||||
return url or (_render_template(display.link_template, raw) if item_id and display.link_template else None) or None
|
||||
|
||||
|
||||
def _render_template(template: str | None, raw: dict[str, Any]) -> str | None:
|
||||
if not template:
|
||||
return None
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
value = _get_by_path(raw, match.group(1).strip())
|
||||
return quote(_stringify(value, ""), safe="")
|
||||
|
||||
return re.sub(r"\{([^}]+)\}", replace, template)
|
||||
|
||||
|
||||
def _get_by_path(value: Any, path: str | None) -> Any:
|
||||
if not path:
|
||||
return None
|
||||
cursor = value
|
||||
for part in path.split("."):
|
||||
if isinstance(cursor, dict):
|
||||
cursor = cursor.get(part)
|
||||
else:
|
||||
return None
|
||||
return cursor
|
||||
|
||||
|
||||
def _stringify(value: Any, fallback: str = "") -> str:
|
||||
if value is None:
|
||||
return fallback
|
||||
if isinstance(value, (str, int, float)):
|
||||
text = str(value).strip()
|
||||
return text if text else fallback
|
||||
if isinstance(value, list):
|
||||
parts = [
|
||||
str(item).strip()
|
||||
for item in value
|
||||
if isinstance(item, (str, int, float)) and str(item).strip()
|
||||
]
|
||||
return "、".join(parts) if parts else fallback
|
||||
return fallback
|
||||
|
||||
|
||||
def _number_or_none(value: Any) -> float | None:
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_name(name: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "", name.lower())
|
||||
|
||||
|
||||
def _normalize_reference_item(
|
||||
raw: Any,
|
||||
fallback_index: int,
|
||||
*,
|
||||
payload: Any = None,
|
||||
tool_context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
display_match = _resolve_display_match(raw, payload, tool_context)
|
||||
if display_match is not None:
|
||||
skill_name, display = display_match
|
||||
configured = _normalize_with_display_config(raw, fallback_index, skill_name, display)
|
||||
if configured is not None:
|
||||
return configured
|
||||
layers = _nested_records(raw)
|
||||
title = _pick(layers, ("title", "m_title", "name", "question", "keyword", "query", "text"))
|
||||
content = _pick(layers, ("content", "page_content", "summary", "snippet", "description", "content_preview", "abstract", "body"))
|
||||
url = _pick(layers, ("url", "link", "href", "source_url", "web_url"))
|
||||
source = _pick(layers, ("source", "source1", "site", "provider", "from"))
|
||||
author = _pick(layers, ("author", "authors", "creator", "writer", "byline", "publisher", "owner"))
|
||||
time = _pick(layers, ("time", "date", "published_date", "publishedDate", "m_publish", "publish_time", "created_at"))
|
||||
item_id = _pick(layers, ("recUuid", "rec_uuid", "uuid", "docId", "doc_id", "id", "recordId", "taskId"))
|
||||
score_raw = _pick(layers, ("score", "relevance", "similarity"))
|
||||
@@ -341,13 +816,14 @@ def _normalize_reference_item(raw: Any, fallback_index: int) -> dict[str, Any] |
|
||||
return None
|
||||
return {
|
||||
"id": item_id or url,
|
||||
"skillName": "backend-reference",
|
||||
"skillName": str((tool_context or {}).get("skill_name") or (tool_context or {}).get("tool_name") or "backend-reference"),
|
||||
"mode": "citation",
|
||||
"title": title,
|
||||
"type": "result",
|
||||
"snippet": content,
|
||||
"content": content,
|
||||
"source": source,
|
||||
"author": author,
|
||||
"time": time,
|
||||
"url": url,
|
||||
"score": score,
|
||||
|
||||
+8
-2
@@ -1,4 +1,10 @@
|
||||
from .registry import get_scheduled_task_service, set_scheduled_task_service
|
||||
from .service import DuplicateTaskNameError, ScheduledTaskService
|
||||
from .service import DuplicateTaskNameError, MarkdownRequirementError, ScheduledTaskService
|
||||
|
||||
__all__ = ["DuplicateTaskNameError", "ScheduledTaskService", "get_scheduled_task_service", "set_scheduled_task_service"]
|
||||
__all__ = [
|
||||
"DuplicateTaskNameError",
|
||||
"MarkdownRequirementError",
|
||||
"ScheduledTaskService",
|
||||
"get_scheduled_task_service",
|
||||
"set_scheduled_task_service",
|
||||
]
|
||||
+77
@@ -36,6 +36,27 @@ _RUN_ATTEMPT_TIMEOUT_SECONDS = 30 * 60 # 30 minutes
|
||||
# Retries allowed after the first attempt before the run is marked "failed".
|
||||
_MAX_RUN_RETRIES = 3
|
||||
|
||||
# Minimum length (in characters) the generated Markdown file's content must
|
||||
# reach when a task has ``require_markdown`` enabled. The model is NOT told about
|
||||
# this threshold — it is enforced silently by the service.
|
||||
_MARKDOWN_MIN_CHARS = 100
|
||||
|
||||
# Instruction appended to the prompt when ``require_markdown`` is on. It only
|
||||
# asks the model to produce a ``.md`` file; the length requirement is enforced
|
||||
# in code (not surfaced to the model).
|
||||
_MARKDOWN_INSTRUCTION = (
|
||||
"\n\n【输出要求】请将本次任务的最终结果生成为一个 Markdown(.md) 格式的文件,"
|
||||
"保存到 /mnt/user-data/outputs 目录,并使用 present_files 展示该文件。"
|
||||
)
|
||||
|
||||
|
||||
class MarkdownRequirementError(Exception):
|
||||
"""Raised when a ``require_markdown`` task did not produce a valid .md file.
|
||||
|
||||
The run is marked failed (no retry) so the user can see the output did not
|
||||
meet the requirement (a Markdown file longer than the minimum length).
|
||||
"""
|
||||
|
||||
|
||||
class DuplicateTaskNameError(Exception):
|
||||
"""A user already owns a scheduled task with the requested name.
|
||||
@@ -166,6 +187,42 @@ def _looks_like_markdown(content: str) -> bool:
|
||||
return any(re.search(pattern, content, flags=re.MULTILINE) for pattern in markdown_patterns)
|
||||
|
||||
|
||||
def _task_requires_markdown(task: dict[str, Any]) -> bool:
|
||||
"""Whether a task has the ``require_markdown`` flag set.
|
||||
|
||||
The flag lives inside ``execution_context`` (no dedicated column) so the
|
||||
feature works without a schema migration on existing databases.
|
||||
"""
|
||||
ctx = task.get("execution_context")
|
||||
return bool(isinstance(ctx, dict) and ctx.get("require_markdown"))
|
||||
|
||||
|
||||
def _has_valid_markdown_file(result_files: list[dict[str, Any]] | None) -> bool:
|
||||
"""True when the run produced a ``.md`` file whose text exceeds the minimum.
|
||||
|
||||
For tasks with ``require_markdown`` enabled the run only succeeds if at least
|
||||
one generated file is Markdown (``.md``/``.markdown``) and its content is
|
||||
longer than :data:`_MARKDOWN_MIN_CHARS` characters. The file is read from its
|
||||
archived path; binary/undecodable files never qualify.
|
||||
"""
|
||||
for item in result_files or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name") or "")
|
||||
if not name.lower().endswith((".md", ".markdown")):
|
||||
continue
|
||||
path = item.get("path")
|
||||
if not isinstance(path, str) or not path:
|
||||
continue
|
||||
try:
|
||||
text = Path(path).read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception:
|
||||
continue
|
||||
if len(text.strip()) > _MARKDOWN_MIN_CHARS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _build_result(
|
||||
task: dict[str, Any],
|
||||
*,
|
||||
@@ -451,6 +508,7 @@ class ScheduledTaskService:
|
||||
source_agent_name: str | None = None,
|
||||
execution_agent_name: str | None = None,
|
||||
execution_context: dict[str, Any] | None = None,
|
||||
require_markdown: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
tz = _normalize_timezone(timezone)
|
||||
cron_expr = _schedule_to_cron(schedule)
|
||||
@@ -461,6 +519,7 @@ class ScheduledTaskService:
|
||||
context = dict(execution_context or {})
|
||||
if execution_agent_name:
|
||||
context["agent_name"] = execution_agent_name
|
||||
context["require_markdown"] = bool(require_markdown)
|
||||
normalized_description = description.strip() if isinstance(description, str) else None
|
||||
task = await self.store.create_task(
|
||||
{
|
||||
@@ -519,6 +578,18 @@ class ScheduledTaskService:
|
||||
update_data["next_run_at"] = _next_run_at(cron_expr, tz)
|
||||
if data.get("execution_context") is not None:
|
||||
update_data["execution_context"] = data["execution_context"]
|
||||
# ``require_markdown`` is persisted inside execution_context (no dedicated
|
||||
# column), so merge it into the context being written — preferring a
|
||||
# context supplied in the same request, else the task's existing one.
|
||||
if data.get("require_markdown") is not None:
|
||||
base_ctx = (
|
||||
update_data["execution_context"]
|
||||
if "execution_context" in update_data
|
||||
else (existing or {}).get("execution_context")
|
||||
)
|
||||
ctx = dict(base_ctx or {})
|
||||
ctx["require_markdown"] = bool(data["require_markdown"])
|
||||
update_data["execution_context"] = ctx
|
||||
# Re-enabling a task must also schedule it forward from now, otherwise a
|
||||
# paused task with a stale/empty next_run_at would never fire again.
|
||||
if update_data.get("enabled") is True and "next_run_at" not in update_data:
|
||||
@@ -831,6 +902,10 @@ class ScheduledTaskService:
|
||||
run_id=run_id,
|
||||
output_snapshot=output_snapshot,
|
||||
)
|
||||
if _task_requires_markdown(task) and not _has_valid_markdown_file(result_files):
|
||||
raise MarkdownRequirementError(
|
||||
f"未满足输出要求:需生成 Markdown(.md) 文件,且文件正文不少于 {_MARKDOWN_MIN_CHARS} 字"
|
||||
)
|
||||
return _build_result(task, scheduled_for=scheduled_for, content=result_text, files=result_files)
|
||||
|
||||
async def _ensure_thread_meta(self, user_id: str, thread_id: str) -> None:
|
||||
@@ -895,6 +970,8 @@ class ScheduledTaskService:
|
||||
f"计划时间:{scheduled_for.astimezone(ZoneInfo(task.get('timezone') or 'UTC')).isoformat()}\n\n"
|
||||
f"{task['prompt']}"
|
||||
)
|
||||
if execution_context.get("require_markdown"):
|
||||
content += _MARKDOWN_INSTRUCTION
|
||||
body = RunCreateRequest(
|
||||
assistant_id="lead_agent",
|
||||
input={"messages": [{"type": "human", "content": content}]},
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ def notebook_search_tool(query: str, top_k: int = 10) -> str:
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"query": query,
|
||||
"source_ids": [source_ids],
|
||||
"source_ids": source_ids,
|
||||
"search_space_id": search_space_id,
|
||||
"top_k": top_k,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
#root {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* 深色主题(默认) */
|
||||
--bg: #0f1117;
|
||||
--surface: #161b22;
|
||||
--card: #1c2128;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-muted: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--green: #3fb950;
|
||||
--yellow: #d29922;
|
||||
--red: #f85149;
|
||||
--purple: #8957e5;
|
||||
|
||||
--phase-starting: var(--accent);
|
||||
--phase-diverging: var(--yellow);
|
||||
--phase-closing: var(--green);
|
||||
--phase-done: var(--purple);
|
||||
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
/* font-size: 14px; */
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
|
||||
/* 多智能体检索 */
|
||||
/* 浅色主题 */
|
||||
[data-theme="light"] {
|
||||
--bg: #f5f7fa;
|
||||
--surface: #ffffff;
|
||||
--card: #f0f2f5;
|
||||
--border: #e2e8f0;
|
||||
--text: #1a202c;
|
||||
--text-muted: #718096;
|
||||
--accent: #3182ce;
|
||||
--green: #38a169;
|
||||
--yellow: #d69e2e;
|
||||
--red: #e53e3e;
|
||||
--purple: #805ad5;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* App layout */
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Idle: centered form */
|
||||
.app-idle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.app-idle-header {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.app-idle-scroll {
|
||||
align-items: flex-start;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Running / done: two-column */
|
||||
.app-running {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-header-logo {
|
||||
font-weight: 700;
|
||||
/* font-size: 15px; */
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.app-header-task {
|
||||
flex: 1;
|
||||
color: var(--text-muted);
|
||||
/* font-size: 13px; */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 主题切换按钮 */
|
||||
.theme-toggle {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
/* font-size: 12px; */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-muted);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.app-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.app-progress-sidebar {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 7px 16px;
|
||||
/* font-size: 13px; */
|
||||
font-family: inherit;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
color: var(--red);
|
||||
border: 1px solid var(--red);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
/* font-size: 12px; */
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
/* font-size: 11px; */
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.badge-starting { background: rgba(88, 166, 255, 0.15); color: var(--accent); }
|
||||
.badge-diverging { background: rgba(210, 153, 34, 0.15); color: var(--yellow); }
|
||||
.badge-closing { background: rgba(63, 185, 80, 0.15); color: var(--green); }
|
||||
.badge-done { background: rgba(137, 87, 229, 0.15); color: var(--purple); }
|
||||
.badge-default { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); }
|
||||
|
||||
/* Spinner */
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Reference cite */
|
||||
.ref-cite {
|
||||
display: inline-block;
|
||||
background: rgba(88, 166, 255, 0.2);
|
||||
color: var(--accent);
|
||||
border-radius: 4px;
|
||||
padding: 0 3px;
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
/* font-size: 11px; */
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ref-cite:hover::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
/* font-size: 11px; */
|
||||
white-space: nowrap;
|
||||
z-index: 100;
|
||||
font-family: inherit;
|
||||
pointer-events: none;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Error banner */
|
||||
.error-banner {
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
border: 1px solid var(--red);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
color: var(--red);
|
||||
/* font-size: 13px; */
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.app-progress-sidebar {
|
||||
width: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.app-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-sidebar,
|
||||
.app-progress-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes qa-typing-dot {
|
||||
0%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateY(-5px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,798 @@
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
useLocation,
|
||||
useParams,
|
||||
Navigate,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import "./App.css";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import { ChatProvider } from "./contexts/ChatContext";
|
||||
import Sidebar, { PageType } from "./components/Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import SceneList from "./pages/SceneList";
|
||||
import SceneDetail from "./pages/SceneDetail";
|
||||
import AgentList from "./pages/AgentList";
|
||||
import AgentDetail from "./pages/AgentDetail";
|
||||
import AgentTrial from "./pages/AgentTrial";
|
||||
import TaskList from "./components/taskManagement/TaskList";
|
||||
import TaskCreate from "./components/taskManagement/TaskCreate";
|
||||
import TaskSingle from "./components/taskManagement/TaskSingle";
|
||||
import TaskWorkspace from "./components/taskManagement/TaskWorkspace";
|
||||
import TaskConsultCreate from "./components/taskManagement/TaskConsultCreate";
|
||||
import TaskConsult from "./components/taskManagement/TaskConsult";
|
||||
import TaskConsultResult from "./components/taskManagement/TaskConsultResult";
|
||||
import StrategyResult from "./pages/StrategyResult";
|
||||
import FinalPlan from "./pages/FinalPlan";
|
||||
import SystemSettings from "./pages/SystemSettings";
|
||||
import TaskDashboard from "./components/taskManagement/TaskDashboard";
|
||||
import TaskListPage from "./components/taskManagement/TaskListPage";
|
||||
import QA from "./pages/QA";
|
||||
import QARecords from "./pages/QARecords";
|
||||
import ResultManagement from "./pages/ResultManagement";
|
||||
import ResultDetail from "./pages/ResultDetail";
|
||||
import ZZResourceManagement from "./pages/ZZResourceManagement";
|
||||
import NetworkActorResourceManagement from "./pages/NetworkActorResourceManagement";
|
||||
import KnowledgeBaseResourceManagement from "./pages/KnowledgeBaseResourceManagement";
|
||||
import EvaluationIndexResourceManagement from "./pages/EvaluationIndexResourceManagement";
|
||||
import LightAppSquare from "./pages/LightAppSquare";
|
||||
import LightAppRun from "./pages/LightAppRun";
|
||||
import LightAppRecords from "./pages/LightAppRecords";
|
||||
import { LightAppType } from "./pages/LightAppSquare";
|
||||
import { MessagePlugin, Button, Space } from "tdesign-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import TaskManagement from "./pages/TaskManagement";
|
||||
import TaskAnalyzes from "./components/taskManagement/TaskAnalyzes";
|
||||
import SentimentAnalyzes from "./components/taskManagement/SentimentAnalyzes";
|
||||
import { ActionAnalyzes } from "./components/taskManagement/ActionAnalyzes";
|
||||
import MopeChat from "./components/qa/MopeChat";
|
||||
|
||||
import QACenterPage from "./components/qa/QACenterPage";
|
||||
import GeneralQA from "./components/qa/GeneralQa";
|
||||
import AgentQA from "./components/qa/AgentQA";
|
||||
import CollaborativeQA from "./components/qa/CollaborativeQA";
|
||||
import ResultManagementLayout from "./components/result/ResultManagementLayout";
|
||||
import ResultPlanning from "./components/result/ResultPlanning";
|
||||
import ResultMnty from "./components/result/ResultMnty";
|
||||
import ResultEffectPg from "./components/result/ResultEffectPg";
|
||||
import ResultXk from "./components/result/ResultXk";
|
||||
import ResultDq from "./components/result/ResultDq";
|
||||
import ResultOther from "./components/result/ResultOther";
|
||||
import ExpertsAgentsPage from "./pages/AgentListExperts";
|
||||
import AgentExpertCenterPage from "./components/agent/AgentExpertCenterPage";
|
||||
import AgentExpertEditPage from "./components/agent/AgentExpertEditPage";
|
||||
import AgentChatPage from "./components/agent/AgentChatPage";
|
||||
import SkillsWorkshop from "./components/agent/SkillsWorkshop";
|
||||
import SkillCreatorPage from "./components/agent/SkillCreatorPage";
|
||||
import TemplateEditor from "./components/qa/TemplateEditor";
|
||||
import Template6BEditor from "./components/qa/Template6BEditor";
|
||||
import SixStepChatPage from "./components/angentchat/SixStepChatPage";
|
||||
import TopicResearch from "./components/qa/TopicResearch";
|
||||
// import Login from './pages/Login';
|
||||
import TaskChat from "./components/qa/TaskChat";
|
||||
import ActionChat from "./components/qa/ActionChat";
|
||||
import GAFromQA from "./components/qa/GAFrom";
|
||||
import XingdongTyPage from "./components/taskManagement/XingdongTy";
|
||||
import FanganTyPage from "./components/taskManagement/FanganTy";
|
||||
import XXTyPage from "./components/taskManagement/XxTy";
|
||||
import ObservabilityPage from './pages/ObservabilityPage';
|
||||
import AgentCardList from './components/angentchat/AgentCardList';
|
||||
import AgentEditPage from './components/angentchat/AgentEditPage';
|
||||
import FileSkillsWorkshop from './components/agent/FileSkillsWorkshop';
|
||||
import TaskPgFrom from './components/taskManagement/TaskPgFrom';
|
||||
import Login from './pages/Login';
|
||||
import TaskXkFrom from "./components/taskManagement/TaskXkFrom";
|
||||
import GAFromNewQA from "./components/qa/GAFromNew";
|
||||
|
||||
function AppContent() {
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// 从查询参数中获取appId
|
||||
const appIdFromQuery = searchParams.get("appId");
|
||||
|
||||
// 页面初始化时打印params
|
||||
useEffect(() => {
|
||||
console.log("页面初始化,当前params:", params);
|
||||
console.log("查询参数appId:", appIdFromQuery);
|
||||
}, []);
|
||||
|
||||
// 从路径中提取当前页面ID,忽略层级结构
|
||||
const getCurrentPageFromPath = (path: string): PageType => {
|
||||
console.log(path, "path");
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
if (parts.length === 0) return "dashboard";
|
||||
|
||||
// 提取最后一个部分作为页面ID
|
||||
let pageId = parts[parts.length - 1];
|
||||
|
||||
// 处理特殊情况
|
||||
if (pageId === "index") {
|
||||
pageId = parts[parts.length - 2] || "dashboard";
|
||||
}
|
||||
|
||||
return pageId as PageType;
|
||||
};
|
||||
|
||||
const currentPage = getCurrentPageFromPath(location.pathname);
|
||||
|
||||
const getTaskMenuConfig = (page: PageType) => {
|
||||
const configs: Record<
|
||||
string,
|
||||
{ title: string; category: string; subCategory: string }
|
||||
> = {
|
||||
"xk-task-jc": {
|
||||
title: "任务JC",
|
||||
category: "XK任务",
|
||||
subCategory: "XK任务",
|
||||
},
|
||||
"xk-task-dispatch": {
|
||||
title: "任务调度",
|
||||
category: "XK任务",
|
||||
subCategory: "XK任务",
|
||||
},
|
||||
"plan-task-fx": {
|
||||
title: "任务FX",
|
||||
category: "规划任务",
|
||||
subCategory: "规划任务",
|
||||
},
|
||||
"plan-task-action": {
|
||||
title: "行动CE",
|
||||
category: "规划任务",
|
||||
subCategory: "规划任务",
|
||||
},
|
||||
"mnty-task-mechanism": {
|
||||
title: "XDTY任务",
|
||||
category: "MNTY任务",
|
||||
subCategory: "MNTY任务",
|
||||
},
|
||||
"mnty-task-plan": {
|
||||
title: "方案TY任务",
|
||||
category: "MNTY任务",
|
||||
subCategory: "MNTY任务",
|
||||
},
|
||||
"mnty-task-zsty": {
|
||||
title: "XXTY任务",
|
||||
category: "MNTY任务",
|
||||
subCategory: "MNTY任务",
|
||||
},
|
||||
"effect-task-pg": {
|
||||
title: "任务PG",
|
||||
category: "效果PG任务",
|
||||
subCategory: "效果PG任务",
|
||||
},
|
||||
"effect-action-pg": {
|
||||
title: "行动PG",
|
||||
category: "效果PG任务",
|
||||
subCategory: "效果PG任务",
|
||||
},
|
||||
"effect-info-pg": {
|
||||
title: "信息PG",
|
||||
category: "效果PG任务",
|
||||
subCategory: "效果PG任务",
|
||||
},
|
||||
"effect-account-pg": {
|
||||
title: "账号PG",
|
||||
category: "效果PG任务",
|
||||
subCategory: "效果PG任务",
|
||||
},
|
||||
};
|
||||
return (
|
||||
configs[page] || { title: "任务管理", category: "", subCategory: "" }
|
||||
);
|
||||
};
|
||||
|
||||
const isTaskSubMenuPage = (page: PageType): boolean => {
|
||||
return [
|
||||
"xk-task-jc",
|
||||
"xk-task-dispatch",
|
||||
"plan-task-fx",
|
||||
"plan-task-action",
|
||||
"mnty-task-mechanism",
|
||||
"mnty-task-plan",
|
||||
"mnty-task-zsty",
|
||||
"effect-task-pg",
|
||||
"effect-action-pg",
|
||||
"effect-info-pg",
|
||||
"effect-account-pg",
|
||||
].includes(page);
|
||||
};
|
||||
|
||||
const handleNavigate = (page: PageType, appId?: string) => {
|
||||
console.log("导航到:", page, appId);
|
||||
|
||||
// 根据页面ID构建正确的导航路径
|
||||
let path = `/${page}`;
|
||||
|
||||
// 任务管理下的子页面
|
||||
if (
|
||||
page.startsWith("xk-task") ||
|
||||
page.startsWith("plan-task") ||
|
||||
page.startsWith("mnty-task") ||
|
||||
page.startsWith("effect-task")
|
||||
) {
|
||||
const taskType = page.split("-")[0] + "-" + page.split("-")[1];
|
||||
path = `/task-management/${taskType}/${page}`;
|
||||
}
|
||||
// 任务管理下的其他页面
|
||||
else if (
|
||||
[
|
||||
"dashboard",
|
||||
"task-create",
|
||||
"task-single",
|
||||
"task-scene",
|
||||
"task-consult-create",
|
||||
"task-consult",
|
||||
"task-consult-result",
|
||||
"task-list",
|
||||
].includes(page)
|
||||
) {
|
||||
path = `/task-management/${page}`;
|
||||
}
|
||||
// 问答中心下的子页面
|
||||
else if (
|
||||
[
|
||||
"new-qa",
|
||||
"general-qa",
|
||||
"agent-qa",
|
||||
"multi-agent-qa",
|
||||
"qa-records",
|
||||
].includes(page)
|
||||
) {
|
||||
path = `/qa-center/${page}`;
|
||||
}
|
||||
// 轻应用中心下的子页面
|
||||
else if (
|
||||
["light-app-square", "light-app-records", "light-app-run"].includes(page)
|
||||
) {
|
||||
path = `/light-app-center/${page}`;
|
||||
// 如果是light-app-run且有appId,添加到路径
|
||||
if (page === "light-app-run" && appId) {
|
||||
path = `/light-app-center/light-app-run?appId=${appId}`;
|
||||
}
|
||||
}
|
||||
// 智能体管理下的子页面
|
||||
else if (["agent-trial"].includes(page)) {
|
||||
path = `/agent-list/${page}`;
|
||||
}
|
||||
// 结果管理下的子页面
|
||||
else if (
|
||||
[
|
||||
"result-planning",
|
||||
"result-mnty",
|
||||
"result-effect-pg",
|
||||
"result-xk",
|
||||
"result-dq",
|
||||
"result-other",
|
||||
"result-detail",
|
||||
].includes(page)
|
||||
) {
|
||||
path = `/result-management/${page}`;
|
||||
}
|
||||
// 资源管理下的子页面
|
||||
else if (
|
||||
[
|
||||
"resource-zz",
|
||||
"resource-network-actor",
|
||||
"resource-knowledge-base",
|
||||
"resource-evaluation-index",
|
||||
].includes(page)
|
||||
) {
|
||||
path = `/resource-management/${page}`;
|
||||
}
|
||||
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen theme-bg theme-transition">
|
||||
<Sidebar currentPage={currentPage} />
|
||||
<div className="ml-64">
|
||||
<Header />
|
||||
<main className="p-6 theme-transition">
|
||||
{appIdFromQuery}
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
{/* 问答中心 */}
|
||||
{/* <Route path="/qa-center" element={<QA onNavigate={handleNavigate} initialMode="new" />} />
|
||||
<Route path="/qa-center/new-qa" element={<QA onNavigate={handleNavigate} initialMode="new" />} />
|
||||
<Route path="/qa-center/general-qa" element={<QA onNavigate={handleNavigate} />} />
|
||||
<Route path="/qa-center/agent-qa" element={<QA onNavigate={handleNavigate} />} />
|
||||
<Route path="/qa-center/multi-agent-qa" element={<QA onNavigate={handleNavigate} initialMode="multi-agent" />} />
|
||||
<Route path="/qa-center/qa-records" element={<QARecords onNavigate={handleNavigate} />} /> */}
|
||||
{/* 问答中心 - 嵌套路由 */}
|
||||
<Route path="/qa-center" element={<QACenterPage />}>
|
||||
{/* 默认重定向到通用问答页面 */}
|
||||
<Route
|
||||
index
|
||||
element={<Navigate to="/qa-center/general-qa" replace />}
|
||||
/>
|
||||
{/* 新建问答页面 - 初始化模式为新建 */}
|
||||
<Route
|
||||
path="new-qa"
|
||||
element={
|
||||
<GeneralQA onNavigate={handleNavigate} initialMode="new" />
|
||||
}
|
||||
/>
|
||||
{/* 通用问答页面 - 初始化模式为通用 */}
|
||||
<Route
|
||||
path="general-qa"
|
||||
element={
|
||||
<GeneralQA
|
||||
onNavigate={handleNavigate}
|
||||
initialMode="general"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* 智能体问答页面 - 基于AI智能体的问答功能 */}
|
||||
<Route
|
||||
path="agent-qa"
|
||||
element={<AgentQA onNavigate={handleNavigate} />}
|
||||
/>
|
||||
{/* 多智能体协作问答页面 - 多个AI智能体协同回答问题 */}
|
||||
<Route
|
||||
path="multi-agent-qa"
|
||||
element={<CollaborativeQA onNavigate={handleNavigate} />}
|
||||
/>
|
||||
{/* 问答记录 */}
|
||||
<Route
|
||||
path="qa-records"
|
||||
element={<QARecords onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route path="mope-chat" element={<MopeChat />} />
|
||||
<Route path="template-editor" element={<TemplateEditor />} />
|
||||
<Route path="template-6b-editor" element={<Template6BEditor />} />
|
||||
<Route path="sixstep-chat" element={<SixStepChatPage />} />
|
||||
{/* <Route
|
||||
path="topic-research"
|
||||
element={
|
||||
<TopicResearch
|
||||
onNavigate={handleNavigate}
|
||||
initialMode="new"
|
||||
/>
|
||||
}
|
||||
/> */}
|
||||
<Route
|
||||
path="topic-research"
|
||||
element={
|
||||
<GAFromNewQA gopath="/page/canvas"/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="task-chat"
|
||||
element={
|
||||
<TaskChat onNavigate={handleNavigate} initialMode="new" />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="action-chat"
|
||||
element={
|
||||
<ActionChat onNavigate={handleNavigate} initialMode="new" />
|
||||
}
|
||||
/>
|
||||
<Route path="sixstep-chat" element={<SixStepChatPage />} />
|
||||
<Route path="qa-from" element={<GAFromQA gopath="/page/workspace/chats/new"/>} />
|
||||
<Route path="qa-from-6bf" element={<GAFromQA gopath="/page/strategy/agents/6bf/chats/new"/>} />
|
||||
<Route path="skills-workshop" element={<SkillsWorkshop/>} />
|
||||
<Route path="skill-creator" element={<SkillCreatorPage />} />
|
||||
</Route>
|
||||
{/* 轻应用中心 */}
|
||||
<Route
|
||||
path="/light-app-center"
|
||||
element={<LightAppSquare onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="/light-app-center/light-app-square"
|
||||
element={<LightAppSquare onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="/light-app-center/light-app-records"
|
||||
element={<LightAppRecords onNavigate={handleNavigate} />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/light-app-center/light-app-run"
|
||||
element={
|
||||
<LightAppRun
|
||||
onNavigate={handleNavigate}
|
||||
appId={appIdFromQuery as any}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* 任务管理 */}
|
||||
<Route path="/task-management" element={<TaskManagement />}>
|
||||
<Route
|
||||
path="dashboard"
|
||||
element={<TaskDashboard onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="task-create"
|
||||
element={<TaskCreate onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="task-single"
|
||||
element={<TaskSingle onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="task-scene/:taskId"
|
||||
element={<TaskWorkspace onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="task-scene"
|
||||
element={<TaskWorkspace onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="task-consult-create"
|
||||
element={<TaskConsultCreate onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="task-consult"
|
||||
element={<TaskConsult onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="task-consult-result"
|
||||
element={<TaskConsultResult onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="task-list"
|
||||
element={
|
||||
<TaskList
|
||||
onViewWorkspace={(id) =>
|
||||
navigate(`/task-management/task-scene/${id}`)
|
||||
}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* XK任务 */}
|
||||
<Route
|
||||
path="xk-task/xk-task-jc"
|
||||
element={
|
||||
<TaskXkFrom
|
||||
gopath={'#/pageIndie/pageHome/pageSwrw'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="xk-task/xk-task-dispatch"
|
||||
element={
|
||||
<TaskXkFrom
|
||||
gopath={'#/pageIndie/pageJczl'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 规划任务 */}
|
||||
<Route
|
||||
path="plan-task/plan-task-fx"
|
||||
element={
|
||||
<TaskAnalyzes
|
||||
title="任务FX"
|
||||
category="规划任务"
|
||||
subCategory="规划任务"
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="plan-task/plan-task-sentiment"
|
||||
element={
|
||||
<SentimentAnalyzes
|
||||
title="任务FX"
|
||||
category="规划任务"
|
||||
subCategory="规划任务"
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="plan-task/plan-task-action"
|
||||
element={
|
||||
<ActionAnalyzes
|
||||
title="行动CE"
|
||||
category="规划任务"
|
||||
subCategory="规划任务"
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* MNTY任务 */}
|
||||
<Route
|
||||
path="mnty-task/mnty-task-mechanism"
|
||||
element={
|
||||
<XingdongTyPage
|
||||
title="XDTY任务"
|
||||
category="MNTY任务"
|
||||
subCategory="MNTY任务"
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="mnty-task/mnty-task-plan"
|
||||
element={
|
||||
<FanganTyPage
|
||||
title="方案TY任务"
|
||||
category="MNTY任务"
|
||||
subCategory="MNTY任务"
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="mnty-task/mnty-task-zsty"
|
||||
element={
|
||||
<XXTyPage
|
||||
title="XXTY任务"
|
||||
category="MNTY任务"
|
||||
subCategory="MNTY任务"
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 效果PG任务 */}
|
||||
<Route
|
||||
path="effect-task/effect-task-pg"
|
||||
element={
|
||||
<TaskPgFrom
|
||||
gopath="task"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="effect-task/effect-action-pg"
|
||||
element={
|
||||
<TaskPgFrom
|
||||
gopath="xd"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="effect-task/effect-info-pg"
|
||||
element={
|
||||
<TaskPgFrom
|
||||
gopath="info"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="effect-task/effect-account-pg"
|
||||
element={
|
||||
<TaskListPage
|
||||
title="账号PG"
|
||||
category="效果PG任务"
|
||||
subCategory="效果PG任务"
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* 场景管理 */}
|
||||
<Route
|
||||
path="/scene-list"
|
||||
element={
|
||||
<SceneList
|
||||
onViewDetail={(id) => navigate(`/scene-detail/${id}`)}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/scene-detail/:sceneId"
|
||||
element={
|
||||
<SceneDetail
|
||||
sceneId={params.sceneId || null}
|
||||
onBack={() => navigate("/scene-list")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 智能体管理 */}
|
||||
<Route
|
||||
path="/agent-list"
|
||||
element={
|
||||
<AgentList
|
||||
onViewDetail={(id) => navigate(`/agent-detail/${id}`)}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/agent-list/agent-trial"
|
||||
element={<AgentTrial onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="/agent-detail/:agentId"
|
||||
element={
|
||||
<AgentDetail
|
||||
agentId={params.agentId || null}
|
||||
onBack={() => navigate("/agent-list")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/agent-list/agent-experts"
|
||||
element={<ExpertsAgentsPage />}
|
||||
></Route>
|
||||
{/* <GAFromQA gopath="/page/strategy/agents/6bf/chats/new"/> */}
|
||||
{/* <Route
|
||||
path="/agent-expert-center"
|
||||
element={<AgentExpertCenterPage />}
|
||||
/> */}
|
||||
<Route
|
||||
path="/agent-expert-center"
|
||||
element={<GAFromQA gopath="/page/strategy/agents"/>}
|
||||
/>
|
||||
<Route
|
||||
path="/agent-expert-edit"
|
||||
element={<AgentExpertEditPage agentId={searchParams.get("id")} />}
|
||||
/>
|
||||
<Route path="/agent-chat" element={<AgentChatPage />} />
|
||||
<Route path="/skills-workshop" element={<SkillsWorkshop />} />
|
||||
<Route path="/skill-creator" element={<SkillCreatorPage />} />
|
||||
<Route
|
||||
path="sixstep-agents"
|
||||
element={<AgentCardList onEdit={(key) => navigate(`/sixstep-agent-edit?key=${key}`)} />}
|
||||
/>
|
||||
<Route
|
||||
path="sixstep-agent-edit"
|
||||
element={
|
||||
<AgentEditPage
|
||||
agentKey={searchParams.get('key') || 'purpose'}
|
||||
onBack={() => navigate('/sixstep-agents')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="file-skills" element={<FileSkillsWorkshop />} />
|
||||
{/* 结果管理 */}
|
||||
{/* <Route path="/result-management" element={<ResultManagement onNavigate={handleNavigate} initialCategory="planning" />} />
|
||||
<Route path="/result-management/result-planning" element={<ResultManagement onNavigate={handleNavigate} initialCategory="planning" />} />
|
||||
<Route path="/result-management/result-mnty" element={<ResultManagement onNavigate={handleNavigate} initialCategory="mnty" />} />
|
||||
<Route path="/result-management/result-effect-pg" element={<ResultManagement onNavigate={handleNavigate} initialCategory="effect-pg" />} />
|
||||
<Route path="/result-management/result-xk" element={<ResultManagement onNavigate={handleNavigate} initialCategory="xk" />} />
|
||||
<Route path="/result-management/result-dq" element={<ResultManagement onNavigate={handleNavigate} initialCategory="dq-research" />} />
|
||||
<Route path="/result-management/result-other" element={<ResultManagement onNavigate={handleNavigate} initialCategory="other" />} />
|
||||
<Route path="/result-management/result-detail/:taskId" element={<ResultDetail taskId={params.taskId} onNavigate={handleNavigate} onBack={() => navigate('/result-management')} />} />
|
||||
<Route path="/result-management/result-detail" element={<ResultDetail taskId={undefined} onNavigate={handleNavigate} onBack={() => navigate('/result-management')} />} /> */}
|
||||
{/* 结果管理 - 嵌套路由 */}
|
||||
<Route
|
||||
path="/result-management"
|
||||
element={<ResultManagementLayout />}
|
||||
>
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<Navigate to="/result-management/result-planning" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="result-planning"
|
||||
element={<ResultPlanning onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="result-mnty"
|
||||
element={<ResultMnty onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="result-effect-pg"
|
||||
element={<ResultEffectPg onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="result-xk"
|
||||
element={<ResultXk onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="result-dq"
|
||||
element={<ResultDq onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="result-other"
|
||||
element={<ResultOther onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="result-detail"
|
||||
element={
|
||||
<ResultDetail
|
||||
taskId={undefined}
|
||||
onNavigate={handleNavigate}
|
||||
onBack={() => navigate("/result-management")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="result-detail/:taskId"
|
||||
element={
|
||||
<ResultDetail
|
||||
taskId={params.taskId}
|
||||
onNavigate={handleNavigate}
|
||||
onBack={() => navigate("/result-management")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* 资源管理 */}
|
||||
<Route
|
||||
path="/resource-management"
|
||||
element={<ZZResourceManagement onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="/resource-management/resource-zz"
|
||||
element={<ZZResourceManagement onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="/resource-management/resource-network-actor"
|
||||
element={
|
||||
<NetworkActorResourceManagement onNavigate={handleNavigate} />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/resource-management/resource-knowledge-base"
|
||||
element={
|
||||
<KnowledgeBaseResourceManagement onNavigate={handleNavigate} />
|
||||
}
|
||||
/>
|
||||
{/* <Route
|
||||
path="/resource-management/resource-evaluation-index"
|
||||
element={
|
||||
<EvaluationIndexResourceManagement
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
}
|
||||
/> */}
|
||||
<Route
|
||||
path="/resource-management/resource-evaluation-index"
|
||||
element={
|
||||
<TaskXkFrom
|
||||
gopath={'#/pageIndie/pageZyjc/pageZyzl'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 其他路由 */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={<TaskDashboard onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="/strategy-result"
|
||||
element={<StrategyResult onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="/final-plan"
|
||||
element={<FinalPlan onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route
|
||||
path="/system-settings"
|
||||
element={<SystemSettings onNavigate={handleNavigate} />}
|
||||
/>
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/login" element={<Login/>}/>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
<AppContent />
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg id="轻应用logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="40" height="40" viewBox="0 0 40 40">
|
||||
<defs>
|
||||
<linearGradient id="linear-gradient" x1="-0.025" x2="1" y2="1" gradientUnits="objectBoundingBox">
|
||||
<stop offset="0" stop-color="#006fea"/>
|
||||
<stop offset="1" stop-color="#004ebd"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-2" x2="1" y2="1" gradientUnits="objectBoundingBox">
|
||||
<stop offset="0" stop-color="#00c7ff"/>
|
||||
<stop offset="1" stop-color="#92eeff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect id="矩形_152188" data-name="矩形 152188" width="40" height="40" rx="8" fill="url(#linear-gradient)"/>
|
||||
<g id="应用_application-one" transform="translate(8 8)">
|
||||
<rect id="矩形_153908" data-name="矩形 153908" width="24" height="24" fill="#fff" opacity="0"/>
|
||||
<path id="联合_1324" data-name="联合 1324" d="M-8800,20219.859l-8.5-5a.994.994,0,0,1-.5-.863v-10a.994.994,0,0,1,.5-.863l8.5-5a.988.988,0,0,1,.5-.143,1.008,1.008,0,0,1,.508.143l8.5,5a.989.989,0,0,1,.492.863v10a.989.989,0,0,1-.492.863l-8.5,5a.977.977,0,0,1-.506.141A.976.976,0,0,1-8800,20219.859Zm-7-15.285v8.855l7.5,4.41,7.5-4.41v-8.855l-7.5-4.414Zm6.5,8.926v-3.945l-3.526-2.209a1,1,0,0,1-.32-1.381,1,1,0,0,1,1.381-.314l3.466,2.172,3.471-2.172a1,1,0,0,1,1.375.314,1,1,0,0,1-.314,1.381l-3.531,2.207v3.947a1,1,0,0,1-1,1A1,1,0,0,1-8800.5,20213.5Z" transform="translate(8811.5 -20197)" fill="url(#linear-gradient-2)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 614 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="40" height="40" viewBox="0 0 40 40"><defs><style>.a{fill:url(#a);}.b{fill:#fff;opacity:0;}.c{fill:url(#b);}</style><linearGradient id="a" x1="-0.025" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#006fea"/><stop offset="1" stop-color="#004ebd"/></linearGradient><linearGradient id="b" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#00c7ff"/><stop offset="1" stop-color="#92eeff"/></linearGradient></defs><rect class="a" width="40" height="40" rx="8"/><g transform="translate(8 8)"><rect class="b" width="24" height="24"/><path class="c" d="M-8800,20219.859l-8.5-5a.994.994,0,0,1-.5-.863v-10a.994.994,0,0,1,.5-.863l8.5-5a.988.988,0,0,1,.5-.143,1.008,1.008,0,0,1,.508.143l8.5,5a.989.989,0,0,1,.492.863v10a.989.989,0,0,1-.492.863l-8.5,5a.977.977,0,0,1-.506.141A.976.976,0,0,1-8800,20219.859Zm-7-15.285v8.855l7.5,4.41,7.5-4.41v-8.855l-7.5-4.414Zm6.5,8.926v-3.945l-3.526-2.209a1,1,0,0,1-.32-1.381,1,1,0,0,1,1.381-.314l3.466,2.172,3.471-2.172a1,1,0,0,1,1.375.314,1,1,0,0,1-.314,1.381l-3.531,2.207v3.947a1,1,0,0,1-1,1A1,1,0,0,1-8800.5,20213.5Z" transform="translate(8811.5 -20197)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,175 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Upload, FileText, Calendar, Users, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface CreateTaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (taskData: any) => void;
|
||||
}
|
||||
|
||||
const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onCreate }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
scene: '宣传策略生成',
|
||||
deadline: '',
|
||||
resources: '',
|
||||
constraints: '',
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.name || !formData.scene) {
|
||||
alert('请填写必填项');
|
||||
return;
|
||||
}
|
||||
onCreate(formData);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative bg-slate-800/95 backdrop-blur-xl border border-blue-800/30 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-blue-800/30">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">新建任务</h2>
|
||||
<p className="text-slate-400 text-sm mt-1">创建一个新的宣传策略分析任务</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-700/50 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-5 max-h-[60vh] overflow-y-auto">
|
||||
{/* Task Name */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">
|
||||
任务名称 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="例如:五四青年节宣传活动策略"
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Task Description */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">
|
||||
任务描述
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="详细描述任务目标和背景..."
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scene Selection */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2">
|
||||
选择场景 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.scene}
|
||||
onChange={(e) => setFormData({ ...formData, scene: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
>
|
||||
<option value="宣传策略生成">宣传策略生成</option>
|
||||
<option value="宣传方案推演">宣传方案推演</option>
|
||||
<option value="宣传活动复盘">宣传活动复盘</option>
|
||||
<option value="舆情应对策略">舆情应对策略</option>
|
||||
<option value="多版本策略比较">多版本策略比较</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Deadline */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
时间要求
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.deadline}
|
||||
onChange={(e) => setFormData({ ...formData, deadline: e.target.value })}
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
资源条件
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.resources}
|
||||
onChange={(e) => setFormData({ ...formData, resources: e.target.value })}
|
||||
placeholder="例如:预算10万,运营团队3人..."
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Constraints */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
风险约束
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={formData.constraints}
|
||||
onChange={(e) => setFormData({ ...formData, constraints: e.target.value })}
|
||||
placeholder="描述任务的限制条件和风险边界..."
|
||||
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
附件/参考资料
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-blue-800/30 rounded-lg p-6 text-center hover:border-blue-500/50 transition-colors cursor-pointer">
|
||||
<Upload className="w-8 h-8 text-slate-500 mx-auto mb-2" />
|
||||
<p className="text-slate-400 text-sm">点击或拖拽文件到此处上传</p>
|
||||
<p className="text-slate-500 text-xs mt-1">支持 PDF、Word、图片等格式</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-blue-800/30 bg-slate-900/30">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 bg-slate-700/50 hover:bg-slate-600/50 border border-blue-800/30 text-slate-300 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors font-medium"
|
||||
>
|
||||
开始分析
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTaskModal;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
const searilizeError = (error: any) => {
|
||||
if (error instanceof Error) {
|
||||
return error.message + '\n' + error.stack;
|
||||
}
|
||||
return JSON.stringify(error, null, 2);
|
||||
};
|
||||
|
||||
export class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error: any }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: any) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="p-4 border border-red-500 rounded">
|
||||
<h2 className="text-red-500">Something went wrong.</h2>
|
||||
<pre className="mt-2 text-sm">{searilizeError(this.state.error)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Bell, Search, User, ChevronDown, Moon, Sun, Palette } from 'lucide-react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
export interface userData {
|
||||
userName: string;
|
||||
typeName: string;
|
||||
}
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const [userData, setUserData] = useState<userData>({
|
||||
userName: '',
|
||||
typeName: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let userInfo: any = localStorage.getItem('userInfo');
|
||||
if (userInfo) {
|
||||
userInfo = JSON.parse(userInfo);
|
||||
setUserData(userInfo);
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<header className={`h-16 flex items-center justify-between px-6 theme-transition ${
|
||||
isDark
|
||||
? 'bg-slate-900/50 backdrop-blur-xl border-b border-blue-800/30'
|
||||
: 'bg-red-600 border-b border-red-700'
|
||||
}`}>
|
||||
{/* 左侧 - 搜索框 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* <div className="relative">
|
||||
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${isDark ? 'text-slate-500' : 'text-red-200'}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索场景、智能体、任务..."
|
||||
className={`w-80 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-white/20 transition-all duration-200 theme-transition ${
|
||||
isDark
|
||||
? 'bg-slate-800/50 border border-blue-800/30 text-white placeholder-slate-500'
|
||||
: 'bg-red-700 border border-red-800 text-white placeholder-red-200'
|
||||
}`}
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* 右侧 - 用户信息和通知 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`p-2 rounded-lg transition-all duration-200 ${
|
||||
isDark
|
||||
? 'hover:bg-slate-800/50 text-slate-400 hover:text-white'
|
||||
: 'hover:bg-red-500 text-white'
|
||||
}`}
|
||||
title={isDark ? '切换到红白主题' : '切换到深色主题'}
|
||||
>
|
||||
{isDark ? (
|
||||
<Palette className="w-5 h-5" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 通知 */}
|
||||
{/* <button className={`relative p-2 rounded-lg transition-colors ${
|
||||
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
|
||||
}`}>
|
||||
<Bell className={`w-5 h-5 ${isDark ? 'text-slate-400' : 'text-white'}`} />
|
||||
<span className={`absolute top-1 right-1 w-2 h-2 rounded-full ${isDark ? 'bg-cyan-500' : 'bg-white'}`} />
|
||||
</button> */}
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className={`w-px h-8 ${isDark ? 'bg-blue-800/30' : 'bg-red-500'}`} />
|
||||
|
||||
{/* 用户信息 */}
|
||||
<button className={`flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors ${
|
||||
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
|
||||
}`}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-blue-500 to-cyan-400'
|
||||
: 'bg-white'
|
||||
}`}>
|
||||
<User className={`w-4 h-4 ${isDark ? 'text-white' : 'text-red-600'}`} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-white' : 'text-white'}`}>{userData.userName}</p>
|
||||
<p className={`text-xs ${isDark ? 'text-blue-400/60' : 'text-red-200'}`}>{userData.typeName}</p>
|
||||
</div>
|
||||
{/* <ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-white'}`} /> */}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
onCitationClick?: (index: number) => void;
|
||||
}
|
||||
|
||||
const extractTextFromNode = (node: React.ReactNode): string => {
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
||||
if (Array.isArray(node)) return node.map(extractTextFromNode).join('');
|
||||
if (React.isValidElement<{ children?: React.ReactNode }>(node)) return extractTextFromNode(node.props.children);
|
||||
return '';
|
||||
};
|
||||
|
||||
const CodeBlock: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const codeText = extractTextFromNode(children).replace(/\n$/, '');
|
||||
const language = className?.match(/language-(\w+)/)?.[1];
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(codeText);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch (error) {
|
||||
console.error('复制代码失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative my-4 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 shadow-sm dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400">
|
||||
<span>{language || 'code'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
|
||||
>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="m-0 overflow-x-auto p-4 text-sm leading-relaxed text-gray-800 dark:text-slate-100">
|
||||
<code className={className}>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '', onCitationClick }) => {
|
||||
const contentWithCitationBadge = content.replace(
|
||||
/\[\[(\d+)\]\]/g,
|
||||
'<button type="button" data-citation-index="$1" class="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-2 mx-0.5 rounded-md border text-xs font-semibold align-middle bg-red-50 text-red-600 border-red-200 dark:bg-slate-700 dark:text-slate-100 dark:border-slate-500 cursor-pointer transition-all hover:opacity-85">[$1]</button>',
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`markdown-content ${className}`}
|
||||
onClick={(event) => {
|
||||
if (!onCitationClick) return;
|
||||
const target = event.target as HTMLElement;
|
||||
const button = target.closest('[data-citation-index]') as HTMLElement | null;
|
||||
if (!button) return;
|
||||
const index = Number(button.dataset.citationIndex || '0');
|
||||
if (Number.isNaN(index) || index <= 0) return;
|
||||
onCitationClick(index);
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{
|
||||
h1: ({ node, ...props }) => <h1 className="text-2xl font-bold mb-4" {...props} />,
|
||||
h2: ({ node, ...props }) => <h2 className="text-xl font-bold mb-3" {...props} />,
|
||||
h3: ({ node, ...props }) => <h3 className="text-lg font-bold mb-2" {...props} />,
|
||||
h4: ({ node, ...props }) => <h4 className="text-base font-bold mb-2" {...props} />,
|
||||
h5: ({ node, ...props }) => <h5 className="text-sm font-bold mb-2" {...props} />,
|
||||
h6: ({ node, ...props }) => <h6 className="text-xs font-bold mb-2" {...props} />,
|
||||
p: ({ node, ...props }) => <p className="mb-4 leading-relaxed" {...props} />,
|
||||
ul: ({ node, ...props }) => <ul className="list-disc list-inside mb-4 ml-4" {...props} />,
|
||||
ol: ({ node, ...props }) => <ol className="list-decimal list-inside mb-4 ml-4" {...props} />,
|
||||
li: ({ node, ...props }) => <li className="mb-1" {...props} />,
|
||||
// code: ({ node, inline, className, children, ...props }: any) => {
|
||||
// if (inline) {
|
||||
// return (
|
||||
// <code
|
||||
// className={`rounded bg-gray-100 px-1 py-0.5 text-sm text-gray-800 dark:bg-slate-800 dark:text-slate-100 ${className || ''}`}
|
||||
// {...props}
|
||||
// >
|
||||
// {children}
|
||||
// </code>
|
||||
// );
|
||||
// }
|
||||
// return <CodeBlock className={className}>{children}</CodeBlock>;
|
||||
// },
|
||||
// pre: ({ node, children }) => <>{children}</>,
|
||||
blockquote: ({ node, ...props }) => (
|
||||
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic my-4" {...props} />
|
||||
),
|
||||
a: ({ node, ...props }) => (
|
||||
<a className="text-blue-600 dark:text-blue-400 hover:underline" {...props} />
|
||||
),
|
||||
table: ({ node, ...props }) => (
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="min-w-full border-collapse" {...props} />
|
||||
</div>
|
||||
),
|
||||
thead: ({ node, ...props }) => <thead className="bg-gray-100 dark:bg-gray-800" {...props} />,
|
||||
tbody: ({ node, ...props }) => <tbody className="divide-y divide-gray-200 dark:divide-gray-700" {...props} />,
|
||||
tr: ({ node, ...props }) => <tr className="hover:bg-gray-50 dark:hover:bg-gray-700" {...props} />,
|
||||
th: ({ node, ...props }) => <th className="px-4 py-2 text-left font-semibold" {...props} />,
|
||||
td: ({ node, ...props }) => <td className="px-4 py-2" {...props} />,
|
||||
hr: ({ node, ...props }) => <hr className="my-4 border-gray-300 dark:border-gray-600" {...props} />,
|
||||
strong: ({ node, ...props }) => <strong className="font-bold" {...props} />,
|
||||
em: ({ node, ...props }) => <em className="italic" {...props} />,
|
||||
}}
|
||||
>
|
||||
{contentWithCitationBadge}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownRenderer;
|
||||
@@ -0,0 +1,572 @@
|
||||
import React, { useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
BarChart3,
|
||||
Bot,
|
||||
Brain,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
Compass,
|
||||
Database,
|
||||
FileBox,
|
||||
FileOutput,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Grid3X3,
|
||||
HelpCircle,
|
||||
History,
|
||||
Layers,
|
||||
Layout,
|
||||
Library,
|
||||
ListTodo,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Network,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
Scale,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
|
||||
export type PageType = string;
|
||||
|
||||
type MenuItem = {
|
||||
id: PageType;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
children?: MenuItem[];
|
||||
path?: string;
|
||||
activePath?: string;
|
||||
};
|
||||
type Props = {
|
||||
currentPage: PageType;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
};
|
||||
type Level1MenuId =
|
||||
| "qa-center"
|
||||
| "light-app-center"
|
||||
| "task-management"
|
||||
| "agent-list"
|
||||
| "result-management"
|
||||
| "resource-management";
|
||||
type TaskGroupId = string;
|
||||
|
||||
const taskGroups: TaskGroupId[] = [
|
||||
"xk-task",
|
||||
"plan-task",
|
||||
"mnty-task",
|
||||
"effect-task",
|
||||
];
|
||||
const collapsibleMenus = new Set<PageType>([
|
||||
"qa-center",
|
||||
"light-app-center",
|
||||
"task-management",
|
||||
"agent-list",
|
||||
"result-management",
|
||||
"resource-management",
|
||||
]);
|
||||
const taskPages = new Set([
|
||||
"dashboard",
|
||||
"task-create",
|
||||
"task-single",
|
||||
"task-scene",
|
||||
"task-consult-create",
|
||||
"task-consult",
|
||||
"task-consult-result",
|
||||
"task-list",
|
||||
]);
|
||||
const qaPages = new Set([
|
||||
"new-qa",
|
||||
"general-qa",
|
||||
"agent-qa",
|
||||
"multi-agent-qa",
|
||||
"qa-records",
|
||||
"sixstep-chat",
|
||||
"topic-research",
|
||||
"task-chat",
|
||||
"action-chat",
|
||||
"qa-from",
|
||||
"qa-from-6bf"
|
||||
]);
|
||||
const lightAppPages = new Set([
|
||||
"light-app-square",
|
||||
"light-app-records",
|
||||
"light-app-run",
|
||||
]);
|
||||
const agentPages = new Set(["agent-trial"]);
|
||||
const resultPages = new Set([
|
||||
"result-planning",
|
||||
"result-mnty",
|
||||
"result-effect-pg",
|
||||
"result-xk",
|
||||
"result-dq",
|
||||
"result-other",
|
||||
"result-detail",
|
||||
]);
|
||||
const resourcePages = new Set([
|
||||
"resource-zz",
|
||||
"resource-network-actor",
|
||||
"resource-knowledge-base",
|
||||
"resource-evaluation-index",
|
||||
]);
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
label: "问答管理",
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "qa-from", label: "通用问答", icon: Bot }, // general-qa
|
||||
// { id: 'sixstep-chat', label: 'zz决策支持', icon: Layers },
|
||||
{ id: "qa-from-6bf", label: "zz决策支持", icon: Layers },
|
||||
{ id: "action-chat", label: "zzXD应用", icon: History },
|
||||
{ id: "topic-research", label: "课题研究", icon: CircleHelp },
|
||||
// { id: 'agent-qa', label: '智能体问答', icon: Bot },
|
||||
// { id: 'multi-agent-qa', label: '多智能体协同', icon: Layers },
|
||||
// { id: 'qa-records', label: '问答记录', icon: History },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "task-management",
|
||||
label: "任务管理",
|
||||
icon: Target,
|
||||
children: [
|
||||
// { id: "dashboard", label: "任务统计", icon: BarChart3 },
|
||||
{
|
||||
id: "xk-task",
|
||||
label: "XK中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo },
|
||||
{ id: "xk-task-dispatch", label: "任务调度", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "plan-task",
|
||||
label: "COH中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "plan-task-sentiment", label: "三情FX", icon: ListTodo },
|
||||
{ id: "plan-task-fx", label: "任务FX", icon: ListTodo },
|
||||
{ id: "plan-task-action", label: "行动CE", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "effect-task",
|
||||
label: "PG中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "effect-task-pg", label: "任务PG", icon: ListTodo },
|
||||
{ id: "effect-action-pg", label: "行动PG", icon: ListTodo },
|
||||
{ id: "effect-info-pg", label: "信息PG", icon: ListTodo },
|
||||
// { id: "effect-account-pg", label: "账号PG", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mnty-task",
|
||||
label: "TY中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "mnty-task-mechanism", label: "XDTY任务", icon: ListTodo },
|
||||
{ id: "mnty-task-plan", label: "方案TY任务", icon: ListTodo },
|
||||
{ id: "mnty-task-zsty", label: "XXTY任务", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// id: 'light-app-center', label: '工具管理', icon: Grid3X3,
|
||||
// children: [
|
||||
// { id: 'light-app-square', label: '应用广场', icon: Layout },
|
||||
// { id: 'light-app-records', label: '运行记录', icon: History },
|
||||
// { id: 'scene-list', label: '场景管理', icon: Layers },
|
||||
// ]
|
||||
// },
|
||||
{
|
||||
id: "agent-list",
|
||||
label: " 综合管理",
|
||||
icon: Bot,
|
||||
children: [
|
||||
// { id: 'agent-list', label: '业务链条管理', icon: Users },
|
||||
// { id: 'agent-list', label: '智能体管理', icon: Users },
|
||||
{ id: "agent-expert-center", label: "智能体管理", icon: Users },
|
||||
{ id: 'scene-list', label: '业务链条管理', icon: Layers },
|
||||
// { id: "skills-workshop", label: "技能列表", icon: Users },
|
||||
// { id: "sixstep-agents", label: "mcp管理", icon: Layers },
|
||||
// { id: "file-skills", label: "skills管理", icon: Layers },
|
||||
// { id: 'agent-trial', label: '单智能体试用', icon: PlayCircle }
|
||||
],
|
||||
},
|
||||
// { id: 'result-management', label: '结果管理', icon: BarChart3, children: [
|
||||
// { id: 'result-planning', label: '规划任务', icon: Target, children: [
|
||||
// { id: 'result-planning-all', label: '全部', icon: BarChart3, path: '/result-management/result-planning', activePath: '/result-management/result-planning' },
|
||||
// { id: 'result-planning-goal', label: '目标分析', icon: Target, path: '/result-management/result-planning?subCategory=目标分析' },
|
||||
// { id: 'result-planning-brand', label: '品牌策略', icon: Target, path: '/result-management/result-planning?subCategory=品牌策略' },
|
||||
// { id: 'result-planning-content', label: '内容规划', icon: Target, path: '/result-management/result-planning?subCategory=内容规划' },
|
||||
// { id: 'result-planning-channel', label: '渠道规划', icon: Target, path: '/result-management/result-planning?subCategory=渠道规划' },
|
||||
// { id: 'result-planning-narrative', label: '叙事策略', icon: Target, path: '/result-management/result-planning?subCategory=叙事策略' },
|
||||
// ] },
|
||||
// { id: 'result-mnty', label: 'MNTY任务', icon: Brain, children: [
|
||||
// { id: 'result-mnty-all', label: '全部', icon: BarChart3, path: '/result-management/result-mnty', activePath: '/result-management/result-mnty' },
|
||||
// { id: 'result-mnty-mechanism', label: '机制研究', icon: Brain, path: '/result-management/result-mnty?subCategory=机制研究' },
|
||||
// { id: 'result-mnty-plan', label: '方案设计', icon: Brain, path: '/result-management/result-mnty?subCategory=方案设计' },
|
||||
// { id: 'result-mnty-zsty', label: 'ZSTY分析', icon: Brain, path: '/result-management/result-mnty?subCategory=ZSTY分析' },
|
||||
// ] },
|
||||
// { id: 'result-effect-pg', label: '效果PG任务', icon: TrendingUp, children: [
|
||||
// { id: 'result-effect-pg-all', label: '全部', icon: BarChart3, path: '/result-management/result-effect-pg', activePath: '/result-management/result-effect-pg' },
|
||||
// { id: 'result-effect-pg-behavior', label: '行为PG', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=行为PG' },
|
||||
// { id: 'result-effect-pg-evaluation', label: '效果评估', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=效果评估' },
|
||||
// { id: 'result-effect-pg-info', label: '信息PG', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=信息PG' },
|
||||
// { id: 'result-effect-pg-account', label: '账号PG', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=账号PG' },
|
||||
// ] },
|
||||
// { id: 'result-xk', label: 'XK任务', icon: Compass, children: [
|
||||
// { id: 'result-xk-all', label: '全部', icon: BarChart3, path: '/result-management/result-xk', activePath: '/result-management/result-xk' },
|
||||
// { id: 'result-xk-jc', label: '任务JC', icon: Compass, path: '/result-management/result-xk?subCategory=任务JC' },
|
||||
// { id: 'result-xk-dispatch', label: '任务调度', icon: Compass, path: '/result-management/result-xk?subCategory=任务调度' },
|
||||
// ] },
|
||||
// { id: 'result-dq', label: 'DQ研究类任务', icon: Database, children: [
|
||||
// { id: 'result-dq-all', label: '全部', icon: BarChart3, path: '/result-management/result-dq', activePath: '/result-management/result-dq' },
|
||||
// { id: 'result-dq-audience', label: '受众分析', icon: Database, path: '/result-management/result-dq?subCategory=受众分析' },
|
||||
// { id: 'result-dq-kb', label: '知识库研究', icon: Database, path: '/result-management/result-dq?subCategory=知识库研究' },
|
||||
// { id: 'result-dq-trend', label: '趋势研究', icon: Database, path: '/result-management/result-dq?subCategory=趋势研究' },
|
||||
// ] },
|
||||
// { id: 'result-other', label: '其他类任务', icon: MoreHorizontal, children: [
|
||||
// { id: 'result-other-all', label: '全部', icon: BarChart3, path: '/result-management/result-other', activePath: '/result-management/result-other' },
|
||||
// { id: 'result-other-general', label: '通用分析', icon: MoreHorizontal, path: '/result-management/result-other?subCategory=通用分析' },
|
||||
// { id: 'result-other-temp', label: '临时任务', icon: MoreHorizontal, path: '/result-management/result-other?subCategory=临时任务' },
|
||||
// { id: 'result-other-other', label: '其他', icon: MoreHorizontal, path: '/result-management/result-other?subCategory=其他' },
|
||||
// ] },
|
||||
// ] },
|
||||
{
|
||||
id: "resource-management",
|
||||
label: "资源管理",
|
||||
icon: Database,
|
||||
children: [
|
||||
// { id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
|
||||
{
|
||||
id: "resource-network-actor",
|
||||
label: "网络行为体资源管理",
|
||||
icon: Network,
|
||||
},
|
||||
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
|
||||
{ id: "resource-evaluation-index", label: "作战资源管理", icon: Library },
|
||||
// {
|
||||
// id: "resource-evaluation-index",
|
||||
// label: "评估指标体系资源管理",
|
||||
// icon: Scale,
|
||||
// },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const subEntries = [
|
||||
// { id: 'task-create', label: '新建任务', icon: ListTodo, parent: 'task-management' },
|
||||
// { id: 'task-single', label: '单智能体任务', icon: Bot, parent: 'task-management' },
|
||||
// { id: 'task-scene', label: '场景任务', icon: Workflow, parent: 'task-management' },
|
||||
// { id: 'task-consult-create', label: '多智能体会商', icon: MessageSquare, parent: 'task-management' },
|
||||
// { id: 'final-plan', label: '方案输出', icon: FileOutput, parent: 'strategy-result' },
|
||||
];
|
||||
|
||||
const getPath = (id: PageType) => {
|
||||
if (taskGroups.includes(id as TaskGroupId)) {
|
||||
console.log('----task-management', id);
|
||||
return `/task-management/${id}`;
|
||||
}
|
||||
if (taskGroups.some((group) => id.startsWith(group))) {
|
||||
return `/task-management/${id.split("-").slice(0, 2).join("-")}/${id}`;
|
||||
}
|
||||
// 特殊处理 effect-task 下的子菜单项(effect-action-pg, effect-info-pg, effect-account-pg)
|
||||
if (id.startsWith("effect-") && !id.startsWith("effect-task")) {
|
||||
return `/task-management/effect-task/${id}`;
|
||||
}
|
||||
if (taskPages.has(id)) return `/task-management/${id}`;
|
||||
if (qaPages.has(id)) return `/qa-center/${id}`;
|
||||
if (id === "qa-center") return "/qa-center";
|
||||
if (lightAppPages.has(id)) return `/light-app-center/${id}`;
|
||||
if (agentPages.has(id)) return `/agent-list/${id}`;
|
||||
if (resultPages.has(id)) return `/result-management/${id}`;
|
||||
if (resourcePages.has(id)) return `/resource-management/${id}`;
|
||||
|
||||
return `/${id}`;
|
||||
};
|
||||
|
||||
const toggleSet = <T,>(set: Set<T>, item: T) => {
|
||||
const next = new Set(set);
|
||||
next.has(item) ? next.delete(item) : next.add(item);
|
||||
return next;
|
||||
};
|
||||
|
||||
export default function Sidebar({
|
||||
currentPage,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse,
|
||||
}: Props) {
|
||||
const { theme } = useTheme();
|
||||
const location = useLocation();
|
||||
const isDark = theme === "dark";
|
||||
const [collapsedMenus, setCollapsedMenus] = useState<Set<Level1MenuId>>(
|
||||
new Set()
|
||||
);
|
||||
const [expandedTaskGroups, setExpandedTaskGroups] = useState<Set<string>>(
|
||||
() =>
|
||||
new Set([
|
||||
...taskGroups.filter((group) => currentPage.startsWith(group)),
|
||||
...(currentPage.startsWith("result-") ? [currentPage] : []),
|
||||
])
|
||||
);
|
||||
|
||||
const panel = `ml-4 pl-4 border-l space-y-1 theme-transition ${
|
||||
isDark ? "border-blue-800/30" : "border-red-100"
|
||||
}`;
|
||||
const iconBtn = `rounded-lg transition-all duration-200 ${
|
||||
isDark
|
||||
? "text-slate-400 hover:text-white hover:bg-slate-800/50"
|
||||
: "text-gray-500 hover:text-red-600 hover:bg-red-50"
|
||||
}`;
|
||||
const itemClass = (active: boolean) =>
|
||||
`rounded-lg transition-all duration-200 theme-transition ${
|
||||
active
|
||||
? isDark
|
||||
? "bg-blue-600/20 text-blue-400 border border-blue-500/30"
|
||||
: "bg-red-50 text-red-600 border border-red-200"
|
||||
: isDark
|
||||
? "text-slate-400 hover:text-white hover:bg-slate-800/50"
|
||||
: "text-gray-600 hover:text-red-600 hover:bg-red-50"
|
||||
}`;
|
||||
|
||||
const isTopActive = (id: PageType) => {
|
||||
if (id === "qa-center") return qaPages.has(currentPage);
|
||||
if (id === "light-app-center") return currentPage.startsWith("light-app");
|
||||
if (id === "task-management")
|
||||
return (
|
||||
currentPage === "dashboard" ||
|
||||
taskPages.has(currentPage) ||
|
||||
taskGroups.some((group) => currentPage.startsWith(group))
|
||||
);
|
||||
if (id === "scene-list") return currentPage.startsWith("scene");
|
||||
if (id === "agent-list") return currentPage.startsWith("agent");
|
||||
if (id === "result-management") return currentPage.startsWith("result");
|
||||
if (id === "resource-management") return currentPage.startsWith("resource");
|
||||
return currentPage === id;
|
||||
};
|
||||
const hasActiveChild = (item: MenuItem): boolean =>
|
||||
currentPage === item.id || Boolean(item.children?.some(hasActiveChild));
|
||||
|
||||
const renderLink = (item: MenuItem, top = false) => {
|
||||
const Icon = item.icon;
|
||||
const activePath = decodeURI(`${location.pathname}${location.search}`);
|
||||
const active = top
|
||||
? isTopActive(item.id)
|
||||
: item.path
|
||||
? activePath === (item.activePath ?? item.path)
|
||||
: currentPage === item.id;
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path ?? getPath(item.id)}
|
||||
onClick={() =>
|
||||
top &&
|
||||
item.id === "task-management" &&
|
||||
expandedTaskGroups.size === 0 &&
|
||||
setExpandedTaskGroups(new Set(["xk-task"]))
|
||||
}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
className={`${top ? "flex-1" : "w-full"} flex items-center ${
|
||||
isCollapsed && top ? "justify-center gap-0 px-2" : "gap-2 px-3"
|
||||
} py-2 ${itemClass(active)}`}
|
||||
>
|
||||
<Icon
|
||||
className={`${top ? "w-5 h-5" : "w-4 h-4"} flex-shrink-0 ${
|
||||
active ? (isDark ? "text-blue-400" : "text-red-500") : ""
|
||||
}`}
|
||||
/>
|
||||
{(!isCollapsed || !top) && (
|
||||
<span
|
||||
className={`${
|
||||
top ? "text-sm" : "text-xs"
|
||||
} font-medium flex-1 text-left`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTaskGroup = (item: MenuItem) => {
|
||||
const expanded = expandedTaskGroups.has(item.id);
|
||||
const active = hasActiveChild(item);
|
||||
const Icon = expanded ? FolderOpen : Folder;
|
||||
return (
|
||||
<div key={item.id} className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedTaskGroups((prev) => toggleSet(prev, item.id))
|
||||
}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 ${itemClass(
|
||||
active
|
||||
)}`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-4 h-4 flex-shrink-0 ${
|
||||
active ? (isDark ? "text-blue-400" : "text-red-500") : ""
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs font-medium flex-1 text-left">
|
||||
{item.label}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={`w-4 h-4 transition-transform duration-200 ${
|
||||
expanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{expanded && item.children && (
|
||||
<div className={panel}>
|
||||
{item.children.map((child) => renderLink(child))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderChildren = (item: MenuItem) =>
|
||||
item.children ? (
|
||||
<div className={panel}>
|
||||
{item.children.map((child) =>
|
||||
child.children ? renderTaskGroup(child) : renderLink(child)
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
const renderSubEntries = (item: MenuItem) => {
|
||||
const entries = subEntries.filter((entry) => entry.parent === item.id);
|
||||
return entries.length && isTopActive(item.id) ? (
|
||||
<div className={panel}>{entries.map((entry) => renderLink(entry))}</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 h-screen ${
|
||||
isCollapsed ? "w-16" : "w-64"
|
||||
} z-50 theme-transition overflow-y-auto ${
|
||||
isDark
|
||||
? "bg-slate-900/80 backdrop-blur-xl border-r border-blue-800/30"
|
||||
: "bg-white border-r border-red-100 shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-16 flex items-center ${
|
||||
isCollapsed ? "justify-center px-2" : "justify-between px-6"
|
||||
} border-b theme-transition flex-shrink-0 ${
|
||||
isDark ? "border-blue-800/30" : "border-red-100"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||
isDark
|
||||
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||||
: "bg-gradient-to-br from-red-500 to-red-400"
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
<h1
|
||||
className={`font-semibold text-sm ${
|
||||
isDark ? "text-white" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
智能CM系统
|
||||
</h1>
|
||||
<p
|
||||
className={`text-xs ${
|
||||
isDark ? "text-blue-400/60" : "text-red-400/80"
|
||||
}`}
|
||||
>
|
||||
Smart CM Platform
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
className={`p-2 ${iconBtn}`}
|
||||
title="收起侧边栏"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<nav className={`${isCollapsed ? "p-2" : "p-4"} space-y-2`}>
|
||||
{isCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
className={`w-full flex items-center justify-center p-2.5 ${iconBtn}`}
|
||||
title="展开侧边栏"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{menuItems.map((item) => {
|
||||
const canCollapse = collapsibleMenus.has(item.id);
|
||||
const childrenCollapsed =
|
||||
canCollapse && collapsedMenus.has(item.id as Level1MenuId);
|
||||
return (
|
||||
<div key={item.id} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderLink(item, true)}
|
||||
{!isCollapsed && item.children && canCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCollapsedMenus((prev) =>
|
||||
toggleSet(prev, item.id as Level1MenuId)
|
||||
)
|
||||
}
|
||||
className={`p-1.5 ${iconBtn}`}
|
||||
title={childrenCollapsed ? "展开菜单" : "折叠菜单"}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-4 h-4 transition-transform duration-200 ${
|
||||
childrenCollapsed ? "" : "rotate-90"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed &&
|
||||
item.children &&
|
||||
!childrenCollapsed &&
|
||||
renderChildren(item)}
|
||||
{!isCollapsed && !childrenCollapsed && renderSubEntries(item)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{isDark ? (
|
||||
<>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-20 right-0 w-24 h-24 bg-cyan-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-red-500/5 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-20 right-0 w-24 h-24 bg-red-400/5 rounded-full blur-3xl pointer-events-none" />
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
@keyframes breathing {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathingGlow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-breathing {
|
||||
animation: breathing 1.5s ease-in-out infinite, breathingGlow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.agent-resizing {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll,
|
||||
.agent-file-preview-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar-track,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar-thumb,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.38);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.agent-file-tree-scroll::-webkit-scrollbar-thumb:hover,
|
||||
.agent-file-preview-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(96, 165, 250, 0.58);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.agent-file-markdown-preview > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.agent-file-markdown-preview > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.agent-file-markdown-preview img {
|
||||
max-width: 100%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,55 @@
|
||||
import SkillCreatorConversationItem from './SkillCreatorConversationItem';
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export interface ConversationStep {
|
||||
text: string;
|
||||
subText?: string;
|
||||
status: 'done' | 'processing';
|
||||
}
|
||||
|
||||
export interface ConversationMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
steps?: ConversationStep[];
|
||||
thinkings?: Array<{ duration: number; content: string }>;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
interface AgentConversationListProps {
|
||||
messages: ConversationMessage[];
|
||||
isConnecting?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AgentConversationList({ messages, isConnecting = false, className = '' }: AgentConversationListProps) {
|
||||
return (
|
||||
<div className={`w-full max-w-4xl mx-auto space-y-5 pb-6 ${className}`}>
|
||||
{messages.map((message, index) => {
|
||||
// 仅在最后一条 assistant 且尚未收到任何生成内容时显示“连接中”态
|
||||
// 一旦出现 steps/thinkings/toolCalls/content,就切回正常渲染
|
||||
const hasStartedGenerating =
|
||||
Boolean(message.content) ||
|
||||
Boolean(message.steps?.length) ||
|
||||
Boolean(message.thinkings?.length) ||
|
||||
Boolean(message.toolCalls?.length);
|
||||
const shouldShowConnecting =
|
||||
isConnecting && index === messages.length - 1 && message.role === 'assistant' && !hasStartedGenerating;
|
||||
|
||||
return (
|
||||
<SkillCreatorConversationItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
isConnecting={shouldShowConnecting}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, Sparkles, ArrowRight, MessageCircle, Bot, BriefcaseBusiness, Info, Pencil, Trash2, Eye, EyeOff, MessageCircleMore, InfoIcon, Edit } from 'lucide-react';
|
||||
import { Dialog, Loading, MessagePlugin, Popconfirm, Switch } from 'tdesign-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useExpertAuthBootstrap } from '../../hooks';
|
||||
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
|
||||
|
||||
interface ExpertAgentItem {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
description: string | null;
|
||||
model_name: string;
|
||||
is_draft: boolean;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type ExpertTab = 'mine' | 'published';
|
||||
|
||||
export default function AgentExpertCenterPage() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const { llmList, currentUser, authedFetch } = useExpertAuthBootstrap();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ExpertTab>('mine');
|
||||
const [agentList, setAgentList] = useState<ExpertAgentItem[]>([]);
|
||||
const [isListLoading, setIsListLoading] = useState(false);
|
||||
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [selectedAgent, setSelectedAgent] = useState<ExpertAgentItem | null>(null);
|
||||
|
||||
const fetchExpertList = useCallback(async () => {
|
||||
setIsListLoading(true);
|
||||
try {
|
||||
const isDraft = activeTab === 'mine';
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/list`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ is_draft: isDraft }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('expert-agents/list接口调用失败:', res.status, res.statusText);
|
||||
setAgentList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: unknown = await res.json();
|
||||
const rawList = Array.isArray(data) ? (data as ExpertAgentItem[]) : [];
|
||||
|
||||
if (isDraft) {
|
||||
const mineFromList = currentUser ? rawList.filter((item) => item.user_id === currentUser.id) : [];
|
||||
|
||||
const allRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
let mineFromAll: ExpertAgentItem[] = [];
|
||||
if (allRes.ok) {
|
||||
const allData: unknown = await allRes.json();
|
||||
const allList = Array.isArray(allData) ? (allData as ExpertAgentItem[]) : [];
|
||||
mineFromAll = currentUser ? allList.filter((item) => item.user_id === currentUser.id) : [];
|
||||
} else {
|
||||
console.error('expert-agents接口调用失败:', allRes.status, allRes.statusText);
|
||||
}
|
||||
|
||||
const mergedMap = new Map<number, ExpertAgentItem>();
|
||||
[...mineFromList, ...mineFromAll].forEach((item) => mergedMap.set(item.id, item));
|
||||
setAgentList(Array.from(mergedMap.values()));
|
||||
return;
|
||||
}
|
||||
|
||||
setAgentList(rawList);
|
||||
} catch (error) {
|
||||
console.error('expert-agents/list接口异常:', error);
|
||||
setAgentList([]);
|
||||
} finally {
|
||||
setIsListLoading(false);
|
||||
}
|
||||
}, [activeTab, authedFetch, currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'mine' && !currentUser) return;
|
||||
void fetchExpertList();
|
||||
}, [activeTab, currentUser, fetchExpertList]);
|
||||
|
||||
const handleStatusToggle = async (nextPublished: boolean) => {
|
||||
if (!selectedAgent) return;
|
||||
|
||||
const nextIsDraft = !nextPublished;
|
||||
setIsStatusUpdating(true);
|
||||
try {
|
||||
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${selectedAgent.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ is_draft: nextIsDraft }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('更新专家状态失败:', res.status, res.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAgent({ ...selectedAgent, is_draft: nextIsDraft });
|
||||
await fetchExpertList();
|
||||
} catch (error) {
|
||||
console.error('更新专家状态异常:', error);
|
||||
} finally {
|
||||
setIsStatusUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addAgentSearch = async () => {
|
||||
const parmy = {
|
||||
model_name: 'Qwen3.5-27B',
|
||||
name: '未命名专家',
|
||||
system_prompt: '你是一个专业的助手,能够回答用户的问题。'
|
||||
}
|
||||
const res: any = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(parmy),
|
||||
});
|
||||
console.log('创建专家', res);
|
||||
const data: any = await res.json()
|
||||
navigate(`/agent-expert-edit?id=${data.id}`)
|
||||
}
|
||||
|
||||
const delAgentSearch = async (id) => {
|
||||
const res: any = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
MessagePlugin.success('操作成功')
|
||||
await fetchExpertList();
|
||||
}
|
||||
|
||||
const cards = useMemo(
|
||||
() =>
|
||||
agentList.map((item) => ({
|
||||
id: String(item.id),
|
||||
title: item.name || '未命名专家',
|
||||
desc: item.description || '暂无描述',
|
||||
status: item.is_draft ? '草稿' : '已发布',
|
||||
avatarSvg: item.avatar_url,
|
||||
raw: item,
|
||||
})),
|
||||
[agentList],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`w-full min-h-[calc(100vh-140px)] ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
<div className="max-w-[1100px] mx-auto">
|
||||
<section className={`rounded-2xl border px-6 py-6 ${isDark ? 'bg-slate-900/60 border-slate-700' : 'bg-white border-gray-200'}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-fuchsia-500 to-violet-600 flex items-center justify-center text-white shadow">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">我的智能体</h1>
|
||||
</div>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>管理你的专属 AI 专家助手</p>
|
||||
<p className={`mt-1 text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>已加载模型:{llmList.length}</p>
|
||||
|
||||
<div className="mt-4 inline-flex items-center rounded-lg border overflow-hidden">
|
||||
<button
|
||||
onClick={() => setActiveTab('mine')}
|
||||
className={`px-3 py-1.5 text-xs ${activeTab === 'mine' ? 'bg-slate-900 text-white' : isDark ? 'bg-slate-800/60 text-slate-300' : 'bg-gray-50 text-gray-600'}`}
|
||||
>
|
||||
我的智能体
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('published')}
|
||||
className={`px-3 py-1.5 text-xs ${activeTab === 'published' ? 'bg-slate-900 text-white' : isDark ? 'bg-slate-800/60 text-slate-300' : 'bg-gray-50 text-gray-600'}`}
|
||||
>
|
||||
发布的智能体
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="inline-flex items-center gap-2 h-10 px-4 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition" onClick={addAgentSearch}>
|
||||
<Plus className="w-4 h-4" /> 创建智能体
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
{isListLoading ? (
|
||||
<div className={`min-h-[280px] rounded-2xl border flex items-center justify-center ${isDark ? 'bg-slate-900/40 border-slate-700' : 'bg-white border-gray-200'}`}>
|
||||
<Loading loading text="加载专家列表中..." />
|
||||
</div>
|
||||
) : cards.length === 0 ? (
|
||||
<div className={`min-h-[280px] rounded-2xl border flex flex-col items-center justify-center text-center px-4 ${isDark ? 'bg-slate-900/40 border-slate-700' : 'bg-white border-gray-200'}`}>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center mb-3 ${isDark ? 'bg-slate-800 text-slate-300' : 'bg-gray-100 text-gray-500'}`}>
|
||||
<BriefcaseBusiness className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className={`text-base font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
|
||||
{activeTab === 'mine' ? '暂无我的专家' : '暂无发布的专家'}
|
||||
</h3>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
创建你的第一个专家助手,开始高效工作
|
||||
</p>
|
||||
<button className="mt-4 inline-flex items-center gap-2 h-10 px-4 rounded-xl bg-slate-900 text-white hover:bg-slate-800 transition">
|
||||
<Plus className="w-4 h-4" /> 创建第一个专家
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{cards.map((item, idx) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className={`rounded-2xl border p-5 transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 ${isDark ? 'bg-slate-900/60 border-slate-700 hover:border-slate-500' : 'bg-white border-gray-200 hover:border-gray-300'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{item.avatarSvg ? (
|
||||
<img
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(item.avatarSvg)}`}
|
||||
alt={item.title}
|
||||
className="w-10 h-10 rounded-xl object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center text-white shadow-sm flex-shrink-0">
|
||||
{idx % 2 === 0 ? <MessageCircle className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-[15px] leading-6 truncate">{item.title}</h3>
|
||||
<p className={`text-xs mt-1 line-clamp-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{activeTab === 'mine' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title="查看详情"
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-700/60' : 'border-gray-200 text-gray-500 hover:bg-gray-100'}`}
|
||||
onClick={() => {
|
||||
setSelectedAgent(item.raw);
|
||||
setDetailVisible(true);
|
||||
}}
|
||||
>
|
||||
<Info className="w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="编辑"
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-700/60' : 'border-gray-200 text-gray-500 hover:bg-gray-100'}`}
|
||||
onClick={() => {
|
||||
navigate(`/agent-expert-edit?id=${item.id}`);
|
||||
}}
|
||||
>
|
||||
<Edit className="w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
<Popconfirm
|
||||
content="确认删除该条数据吗?"
|
||||
onConfirm={() => delAgentSearch(item.id)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title="删除"
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-rose-900/20 hover:text-rose-300' : 'border-gray-200 text-gray-500 hover:bg-rose-50 hover:text-rose-500'}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 shrink-0" />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded-full border ${isDark ? 'bg-emerald-950/50 border-emerald-800 text-emerald-300' : 'bg-emerald-50 border-emerald-200 text-emerald-700'}`}>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/agent-chat?id=${item.id}`)}
|
||||
className={`mt-6 inline-flex items-center gap-1 text-sm ${isDark ? 'text-slate-300 hover:text-white' : 'text-gray-700 hover:text-gray-900'}`}
|
||||
>
|
||||
进行对话
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={`mt-6 rounded-2xl border px-6 py-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4 ${isDark ? 'bg-violet-950/30 border-violet-800/50' : 'bg-violet-50 border-violet-100'}`}>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">不确定从哪个开始?</h3>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>直接开始对话,AI 会自动匹配最合适的专家助手</p>
|
||||
</div>
|
||||
|
||||
<button className="inline-flex items-center justify-center gap-2 h-11 px-6 rounded-xl bg-gradient-to-r from-fuchsia-500 to-violet-600 text-white font-medium hover:opacity-95 transition">
|
||||
开始对话
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<Dialog
|
||||
visible={detailVisible}
|
||||
onClose={() => setDetailVisible(false)}
|
||||
confirmBtn={null}
|
||||
cancelBtn={null}
|
||||
header="专家详情"
|
||||
width={520}
|
||||
>
|
||||
<div className="py-2">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{selectedAgent?.avatar_url ? (
|
||||
<img
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(selectedAgent.avatar_url)}`}
|
||||
alt={selectedAgent?.name || '专家头像'}
|
||||
className="w-20 h-20 rounded-2xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-2xl bg-gray-100" />
|
||||
)}
|
||||
|
||||
<h4 className="mt-4 text-2xl font-semibold text-gray-900">{selectedAgent?.name || '未命名专家'}</h4>
|
||||
<p className="mt-2 text-sm text-gray-400">{selectedAgent?.description || '暂无描述'}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-xl bg-gray-50 px-3 py-3 flex items-center justify-between">
|
||||
<div className="inline-flex items-center gap-2 text-sm text-gray-500">
|
||||
{selectedAgent?.is_draft ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{selectedAgent?.is_draft ? '草稿状态' : '已发布'}
|
||||
</div>
|
||||
<Switch
|
||||
value={!selectedAgent?.is_draft}
|
||||
loading={isStatusUpdating}
|
||||
disabled={isStatusUpdating}
|
||||
onChange={(value) => {
|
||||
void handleStatusToggle(value as boolean);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="mt-4 w-full h-10 rounded-lg bg-gradient-to-r from-fuchsia-500 to-violet-600 text-white inline-flex items-center justify-center gap-2">
|
||||
<MessageCircleMore className="w-4 h-4" />
|
||||
开始聊天
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,130 @@
|
||||
import type { KeyboardEvent, ReactNode } from 'react';
|
||||
import { Send, Square, Upload } from 'lucide-react';
|
||||
import { Textarea } from 'tdesign-react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
interface ChatInputComposerProps {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
onSend?: () => void;
|
||||
onStop?: () => void;
|
||||
onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
isSending?: boolean;
|
||||
isDarkMode: boolean;
|
||||
trailingActions?: ReactNode;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
showBorderTop?: boolean;
|
||||
showUpload?: boolean;
|
||||
onUploadFile?: (file: File) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function ChatInputComposer({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
onStop,
|
||||
onKeyDown,
|
||||
placeholder = '输入你的问题...',
|
||||
disabled = false,
|
||||
isSending = false,
|
||||
isDarkMode,
|
||||
trailingActions,
|
||||
readOnly = false,
|
||||
className = '',
|
||||
showBorderTop = false,
|
||||
showUpload = true,
|
||||
onUploadFile,
|
||||
}: ChatInputComposerProps) {
|
||||
const submitEnabled = Boolean(value.trim()) && !disabled && !readOnly && Boolean(onSend);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const iconButtonClass = `rounded-lg p-2 transition-colors ${
|
||||
isDarkMode ? 'text-slate-400 hover:bg-slate-600 hover:text-slate-200' : 'text-gray-500 hover:bg-gray-200 hover:text-gray-700'
|
||||
}`;
|
||||
|
||||
const handleKeyDown = (val, context) => {
|
||||
if (context?.e?.key === 'Enter') {
|
||||
// 阻止默认的换行行为(如果是 Textarea)
|
||||
context.e.preventDefault();
|
||||
|
||||
// 模拟发送请求
|
||||
onSend()
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${showBorderTop ? (isDarkMode ? 'border-t border-slate-700/50' : 'border-t border-red-100') : ''} p-4 ${className}`}>
|
||||
<div className={`flex items-end gap-3 rounded-2xl p-3 shadow-inner ${isDarkMode ? 'bg-slate-700/50' : 'bg-gray-100'}`}>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(nextValue) => onChange?.(String(nextValue))}
|
||||
onKeydown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
autosize={{ minRows: 1, maxRows: 8 }}
|
||||
disabled={disabled}
|
||||
readonly={readOnly}
|
||||
className={`flex-1 !bg-transparent !border-0 !shadow-none qa-composer-textarea [&]:!border-0 [&]:!shadow-none [&]:!outline-none [&]:focus:!border-0 [&]:focus:!shadow-none [&]:focus:!outline-none [&_.t-textarea]:!border-0 [&_.t-textarea]:!shadow-none [&_.t-textarea]:!outline-none [&_.t-textarea:hover]:!border-0 [&_.t-textarea:hover]:!shadow-none [&_.t-textarea--focused]:!border-0 [&_.t-textarea--focused]:!shadow-none [&_.t-is-focused]:!border-0 [&_.t-is-focused]:!shadow-none [&_.t-textarea__inner]:!min-h-[40px] [&_.t-textarea__inner]:!resize-none [&_.t-textarea__inner]:!bg-transparent [&_.t-textarea__inner]:!border-0 [&_.t-textarea__inner]:!p-0 [&_.t-textarea__inner]:!shadow-none [&_.t-textarea__inner]:!outline-none [&_.t-textarea__inner:hover]:!border-0 [&_.t-textarea__inner:hover]:!shadow-none [&_.t-textarea__inner:focus]:!border-0 [&_.t-textarea__inner:focus]:!shadow-none [&_.t-textarea__inner:focus]:!outline-none [&_.t-textarea__inner]:disabled:!cursor-not-allowed ${
|
||||
isDarkMode
|
||||
? '[&_.t-textarea__inner]:!text-white [&_.t-textarea__inner]:placeholder:!text-slate-500'
|
||||
: '[&_.t-textarea__inner]:!text-gray-800 [&_.t-textarea__inner]:placeholder:!text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{showUpload && (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
void onUploadFile?.(file);
|
||||
}
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled || !onUploadFile}
|
||||
className={`${iconButtonClass} ${(disabled || !onUploadFile) ? 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-inherit' : ''}`}
|
||||
title="上传文件"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{trailingActions && <div className="flex items-center gap-2">{trailingActions}</div>}
|
||||
|
||||
<button
|
||||
onClick={isSending ? onStop : onSend}
|
||||
disabled={!isSending && !submitEnabled}
|
||||
className={`flex items-center gap-2 rounded-xl px-4 py-2 font-medium shadow-sm transition-all ${
|
||||
isSending
|
||||
? isDarkMode
|
||||
? 'bg-rose-600 text-white'
|
||||
: 'bg-red-600 text-white'
|
||||
: submitEnabled
|
||||
? isDarkMode
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-red-500 text-white hover:bg-red-600'
|
||||
: isDarkMode
|
||||
? 'cursor-not-allowed bg-slate-600 text-slate-400'
|
||||
: 'cursor-not-allowed bg-gray-300 text-gray-500'
|
||||
}`}
|
||||
type="button"
|
||||
title={isSending ? '停止' : '发送'}
|
||||
>
|
||||
{isSending ? <Square className="h-4 w-4 fill-current" /> : <Send className="h-4 w-4" />}
|
||||
{isSending ? '停止' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
FileCode2,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
interface FileSkill {
|
||||
id: string;
|
||||
folder: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kind: 'md' | 'py';
|
||||
filename: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
const API_BASE = 'https://ch1.b.uat.4.cn/zncmback/api/skills/files';
|
||||
|
||||
export default function FileSkillsWorkshop() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const [skills, setSkills] = useState<FileSkill[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FileSkill | null>(null);
|
||||
const [openCardMenuId, setOpenCardMenuId] = useState<string | null>(null);
|
||||
|
||||
const cardMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const reload = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(API_BASE);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
const data = (await res.json()) as FileSkill[];
|
||||
setSkills(data);
|
||||
} catch (err) {
|
||||
console.error('加载技能失败', err);
|
||||
setError('加载技能失败,请稍后重试');
|
||||
setSkills([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (cardMenuRef.current && !cardMenuRef.current.contains(target)) {
|
||||
setOpenCardMenuId(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const keyword = searchTerm.trim().toLowerCase();
|
||||
if (!keyword) return skills;
|
||||
return skills.filter((s) =>
|
||||
`${s.name} ${s.description} ${s.filename}`.toLowerCase().includes(keyword),
|
||||
);
|
||||
}, [searchTerm, skills]);
|
||||
|
||||
const handleDelete = async (skill: FileSkill) => {
|
||||
setOpenCardMenuId(null);
|
||||
if (!window.confirm(`确认删除技能「${skill.name}」?`)) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/${encodeURIComponent(skill.folder)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
setSkills((prev) => prev.filter((s) => s.folder !== skill.folder));
|
||||
} catch (err) {
|
||||
console.error('删除技能失败', err);
|
||||
setError('删除技能失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full min-h-[calc(100vh-140px)] ${
|
||||
isDark ? 'bg-slate-950 text-slate-100' : 'bg-[#fcfcfd] text-[#101828]'
|
||||
}`}
|
||||
>
|
||||
{createOpen && (
|
||||
<CreateSkillModal
|
||||
isDark={isDark}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => {
|
||||
setCreateOpen(false);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditSkillModal
|
||||
isDark={isDark}
|
||||
skill={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mx-auto max-w-[1360px] px-6 py-8">
|
||||
<section className="mb-7 flex items-start justify-between gap-5">
|
||||
<div>
|
||||
<h1
|
||||
className={`text-[22px] font-bold tracking-tight ${
|
||||
isDark ? 'text-slate-100' : 'text-[#111827]'
|
||||
}`}
|
||||
>
|
||||
技能管理
|
||||
</h1>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>
|
||||
上传 Markdown 提示词或 Python 脚本作为技能,供 MCP 与模型调用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="inline-flex h-11 items-center gap-2 rounded-xl bg-[#ea2d2d] px-5 text-sm font-medium text-white shadow-[0_10px_20px_-12px_rgba(234,45,45,0.7)] transition hover:bg-[#d92626]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建技能
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`mb-8 rounded-[28px] border px-6 py-5 ${
|
||||
isDark ? 'border-slate-800 bg-slate-900/65' : 'border-[#f5d4d4] bg-white'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-2xl border px-4 py-3 ${
|
||||
isDark ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'
|
||||
}`}
|
||||
>
|
||||
<Search className={`h-4 w-4 ${isDark ? 'text-slate-400' : 'text-[#ff6b6b]'}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索技能名称 / 描述..."
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
className={`min-w-0 flex-1 bg-transparent text-sm outline-none ${
|
||||
isDark
|
||||
? 'text-slate-100 placeholder:text-slate-500'
|
||||
: 'text-gray-800 placeholder:text-[#9ca3af]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{loading ? (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-[28px] border py-20 text-sm ${
|
||||
isDark
|
||||
? 'border-slate-800 bg-slate-900/60 text-slate-300'
|
||||
: 'border-[#f5d4d4] bg-white text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
正在加载技能...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${
|
||||
isDark ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'
|
||||
}`}
|
||||
>
|
||||
<h3
|
||||
className={`mb-2 text-lg font-semibold ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
加载失败
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>{error}</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${
|
||||
isDark ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mb-4 flex h-12 w-12 items-center justify-center rounded-2xl ${
|
||||
isDark ? 'bg-slate-800 text-slate-400' : 'bg-[#fff1f1] text-[#ff6b6b]'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-6 w-6" />
|
||||
</div>
|
||||
<h3
|
||||
className={`mb-2 text-lg font-semibold ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
暂无技能
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>
|
||||
点击右上角「新建技能」上传你的第一个技能。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filtered.map((skill) => {
|
||||
const isPy = skill.kind === 'py';
|
||||
const Icon = isPy ? FileCode2 : FileText;
|
||||
return (
|
||||
<article
|
||||
key={skill.folder}
|
||||
className={`group relative overflow-hidden rounded-[22px] border px-5 py-5 transition-all duration-200 ${
|
||||
isDark
|
||||
? 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:shadow-xl hover:shadow-black/20'
|
||||
: 'border-[#f5d4d4] bg-white hover:border-[#ffbdbd] hover:shadow-[0_16px_40px_-26px_rgba(239,68,68,0.35)]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-x-0 top-0 h-1 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
|
||||
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute right-4 top-4 z-20"
|
||||
ref={openCardMenuId === skill.folder ? cardMenuRef : null}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpenCardMenuId((prev) =>
|
||||
prev === skill.folder ? null : skill.folder,
|
||||
);
|
||||
}}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{openCardMenuId === skill.folder && (
|
||||
<div
|
||||
className={`absolute right-0 top-[calc(100%+6px)] z-30 w-36 rounded-2xl border py-1 shadow-xl ${
|
||||
isDark ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpenCardMenuId(null);
|
||||
setEditing(skill);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-slate-300 hover:bg-slate-800'
|
||||
: 'text-gray-700 hover:bg-[#fff6f6]'
|
||||
}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4 shrink-0" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(skill);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-rose-400 hover:bg-slate-800'
|
||||
: 'text-rose-500 hover:bg-[#fff6f6]'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 pr-8">
|
||||
<div
|
||||
className={`relative flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br shadow-[0_18px_32px_-22px_rgba(15,23,42,0.35)] ring-1 ${
|
||||
isPy
|
||||
? 'from-indigo-100 via-blue-50 to-white text-indigo-500 shadow-indigo-100/80 ring-indigo-100'
|
||||
: 'from-rose-100 via-red-50 to-white text-red-500 shadow-red-100/80 ring-red-100'
|
||||
}`}
|
||||
>
|
||||
<Icon className="relative z-10 h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className={`truncate text-[17px] font-semibold ${
|
||||
isDark ? 'text-slate-100' : 'text-[#111827]'
|
||||
}`}
|
||||
title={skill.name}
|
||||
>
|
||||
{skill.name}
|
||||
</h3>
|
||||
<p
|
||||
className={`mt-1 line-clamp-3 min-h-[3.6em] text-sm ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
{skill.description || '暂无描述'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mt-4 flex items-center justify-between text-xs ${
|
||||
isDark ? 'text-slate-500' : 'text-[#98a2b3]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium ${
|
||||
isPy
|
||||
? 'bg-indigo-50 text-indigo-600'
|
||||
: 'bg-[#fff2f2] text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{isPy ? 'Python' : 'Markdown'}
|
||||
</span>
|
||||
<span>{new Date(skill.updated_at * 1000).toLocaleString()}</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CreateModalProps {
|
||||
isDark: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
function CreateSkillModal({ isDark, onClose, onCreated }: CreateModalProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleFileChange = (next: File | null) => {
|
||||
setFile(next);
|
||||
if (next && !name.trim()) {
|
||||
const stem = next.name.replace(/\.[^.]+$/, '');
|
||||
setName(stem);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!file) {
|
||||
setErrMsg('请上传 .md 或 .py 文件');
|
||||
return;
|
||||
}
|
||||
setErrMsg('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
if (name.trim()) fd.append('name', name.trim());
|
||||
if (description.trim()) fd.append('description', description.trim());
|
||||
const res = await fetch(API_BASE, { method: 'POST', body: fd });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
console.error('新建技能失败', err);
|
||||
setErrMsg(err instanceof Error ? err.message : '新建技能失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`relative w-[480px] overflow-hidden rounded-[28px] border shadow-2xl ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-100'
|
||||
: 'border-[#f3d6d6] bg-white text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-1 w-full ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
|
||||
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between px-6 pb-3 pt-5">
|
||||
<h2 className="text-base font-semibold">新建技能</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 pb-2 pt-2">
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
技能文件(.md 或 .py)
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".md,.py"
|
||||
className="hidden"
|
||||
onChange={(event) => handleFileChange(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 hover:bg-slate-800'
|
||||
: 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow ring-1 ring-white/20">
|
||||
<Upload className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`truncate text-sm font-medium ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{file ? file.name : '选择文件'}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
支持 .md 提示词 或 .py 可执行脚本
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
技能名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="默认使用上传文件名"
|
||||
className={`w-full rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
描述(可选)
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="技能用途、输入输出说明等"
|
||||
className={`w-full resize-none rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errMsg && (
|
||||
<p className="text-xs text-rose-500">{errMsg}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-5 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`rounded-xl px-4 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
: 'text-[#667085] hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
className={`rounded-xl bg-[#ea2d2d] px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-[#d92626] ${
|
||||
submitting ? 'cursor-not-allowed opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
{submitting ? '提交中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EditModalProps {
|
||||
isDark: boolean;
|
||||
skill: FileSkill;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function EditSkillModal({ isDark, skill, onClose, onSaved }: EditModalProps) {
|
||||
const [name, setName] = useState(skill.name);
|
||||
const [description, setDescription] = useState(skill.description ?? '');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
setErrMsg('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
if (file) fd.append('file', file);
|
||||
if (name.trim() !== skill.name) fd.append('name', name.trim());
|
||||
if (description !== (skill.description ?? '')) fd.append('description', description);
|
||||
|
||||
const res = await fetch(`${API_BASE}/${encodeURIComponent(skill.folder)}`, {
|
||||
method: 'PUT',
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
console.error('保存技能失败', err);
|
||||
setErrMsg(err instanceof Error ? err.message : '保存技能失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`relative w-[480px] overflow-hidden rounded-[28px] border shadow-2xl ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-100'
|
||||
: 'border-[#f3d6d6] bg-white text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-1 w-full ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
|
||||
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between px-6 pb-3 pt-5">
|
||||
<h2 className="text-base font-semibold">编辑技能</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 pb-2 pt-2">
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
技能名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className={`w-full rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
className={`w-full resize-none rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
覆盖文件(可选,选择后将替换原有内容)
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".md,.py"
|
||||
className="hidden"
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 hover:bg-slate-800'
|
||||
: 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow ring-1 ring-white/20">
|
||||
<Upload className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`truncate text-sm font-medium ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{file ? file.name : `当前文件:${skill.filename}`}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
仅支持 .md 或 .py
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errMsg && <p className="text-xs text-rose-500">{errMsg}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-5 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`rounded-xl px-4 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
: 'text-[#667085] hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
className={`rounded-xl bg-[#ea2d2d] px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-[#d92626] ${
|
||||
submitting ? 'cursor-not-allowed opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bot, CheckCircle2, ChevronDown, Info, Sparkles, UserRound, Wrench } from 'lucide-react';
|
||||
import MarkdownRenderer from '../MarkdownRenderer';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
interface ConversationStep {
|
||||
text: string;
|
||||
subText?: string;
|
||||
status: 'done' | 'processing';
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
interface ConversationMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
steps?: ConversationStep[];
|
||||
thinkings?: Array<{ duration: number; content: string }>;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
interface SkillCreatorConversationItemProps {
|
||||
message: ConversationMessage;
|
||||
isConnecting?: boolean;
|
||||
}
|
||||
|
||||
function MessageAvatar({ role, active = false }: { role: 'user' | 'assistant'; active?: boolean }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const Icon = role === 'assistant' ? Bot : UserRound;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-9 h-9 rounded-2xl flex items-center justify-center flex-shrink-0 shadow-sm ${
|
||||
role === 'assistant'
|
||||
? isDarkMode
|
||||
? 'bg-gradient-to-br from-slate-700 to-slate-900 text-sky-200 ring-1 ring-slate-600'
|
||||
: 'bg-gradient-to-br from-red-50 to-rose-100 text-red-500 ring-1 ring-red-100'
|
||||
: isDarkMode
|
||||
? 'bg-gradient-to-br from-blue-500 to-indigo-600 text-white'
|
||||
: 'bg-gradient-to-br from-red-500 to-rose-500 text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{role === 'assistant' && active && (
|
||||
<Sparkles className={`absolute -right-0.5 -top-0.5 w-3.5 h-3.5 ${isDarkMode ? 'text-sky-200' : 'text-amber-400'}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepsSummary({ steps }: { steps: ConversationStep[] }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const [open, setOpen] = useState(true);
|
||||
const doneCount = steps.filter((step) => step.status === 'done').length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mb-3 overflow-hidden rounded-2xl border shadow-sm ${
|
||||
isDarkMode ? 'border-slate-700 bg-slate-900/60 text-slate-300' : 'border-red-100 bg-white text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
|
||||
isDarkMode ? 'bg-slate-800/70 hover:bg-slate-800' : 'bg-red-50/60 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className={`w-3.5 h-3.5 ${isDarkMode ? 'text-emerald-400' : 'text-red-500'}`} />
|
||||
<span className="font-medium">执行步骤</span>
|
||||
<span className={isDarkMode ? 'text-slate-500' : 'text-gray-400'}>已完成 {doneCount} / {steps.length}</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'max-h-[640px] opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className={`border-t px-4 py-3 space-y-3 ${isDarkMode ? 'border-slate-700' : 'border-red-50'}`}>
|
||||
{steps.map((step, index) => {
|
||||
const done = step.status === 'done';
|
||||
|
||||
return (
|
||||
<div key={`${step.text}-${index}`} className="flex gap-3">
|
||||
<div className="pt-0.5">
|
||||
{done ? (
|
||||
<CheckCircle2 className={`w-4 h-4 ${isDarkMode ? 'text-emerald-400' : 'text-red-500'}`} />
|
||||
) : (
|
||||
<span className={`block w-4 h-4 rounded-full border-2 ${isDarkMode ? 'border-sky-300 border-t-transparent' : 'border-red-400 border-t-transparent'} animate-spin`} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-xs font-medium ${isDarkMode ? 'text-slate-200' : 'text-gray-800'}`}>
|
||||
第 {index + 1} 步:{step.text}
|
||||
</div>
|
||||
{step.subText && (
|
||||
<div className={`mt-1 rounded-xl px-3 py-2 text-xs leading-5 ${isDarkMode ? 'bg-slate-950/50 text-slate-400' : 'bg-gray-50 text-gray-600'}`}>
|
||||
{step.subText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingPanel({ duration, content }: { duration: number; content: string }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (duration > 0) setOpen(false);
|
||||
}, [duration]);
|
||||
|
||||
return (
|
||||
<div className={`mb-3 rounded-2xl border overflow-hidden shadow-sm ${isDarkMode ? 'border-slate-700 bg-slate-900/60' : 'border-red-100 bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
|
||||
isDarkMode ? 'text-slate-300 bg-slate-800/70 hover:bg-slate-800' : 'text-gray-600 bg-red-50/60 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<Info className={`w-3.5 h-3.5 ${isDarkMode ? 'text-sky-300' : 'text-red-500'}`} />
|
||||
<span className="font-medium">思考过程</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
|
||||
</button>
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className={`px-4 py-3 text-xs leading-6 whitespace-pre-wrap border-t ${isDarkMode ? 'border-slate-700 text-slate-300' : 'border-red-50 text-gray-600'}`}>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallPanel({ toolCall }: { toolCall: ToolCall }) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const details = {
|
||||
input: toolCall.input && Object.keys(toolCall.input).length > 0 ? JSON.stringify(toolCall.input, null, 2) : '',
|
||||
output: toolCall.output ?? '',
|
||||
};
|
||||
const hasDetails = Boolean(details.input || details.output);
|
||||
|
||||
return (
|
||||
<div className={`mb-3 rounded-2xl border overflow-hidden shadow-sm ${isDarkMode ? 'border-slate-700 bg-slate-900/60' : 'border-red-100 bg-white'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
|
||||
isDarkMode ? 'text-slate-300 bg-slate-800/70 hover:bg-slate-800' : 'text-gray-600 bg-red-50/60 hover:bg-red-50'
|
||||
}`}
|
||||
>
|
||||
<Wrench className={`w-3.5 h-3.5 ${isDarkMode ? 'text-amber-300' : 'text-red-500'}`} />
|
||||
<span className="font-medium">{toolCall.name}</span>
|
||||
{hasDetails && <ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />}
|
||||
</button>
|
||||
{hasDetails && (
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'max-h-[520px] opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className={`border-t px-4 py-3 space-y-3 ${isDarkMode ? 'border-slate-700' : 'border-red-50'}`}>
|
||||
{details.input && (
|
||||
<div>
|
||||
<p className={`text-[11px] mb-1 ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>请求参数</p>
|
||||
<pre className={`rounded-xl px-3 py-2 text-[11px] leading-5 overflow-x-auto ${isDarkMode ? 'bg-slate-950/60 text-slate-300' : 'bg-gray-50 text-gray-700'}`}>{details.input}</pre>
|
||||
</div>
|
||||
)}
|
||||
{details.output && (
|
||||
<div>
|
||||
<p className={`text-[11px] mb-1 ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>返回结果</p>
|
||||
<pre className={`rounded-xl px-3 py-2 text-[11px] leading-5 overflow-x-auto ${isDarkMode ? 'bg-slate-950/60 text-slate-300' : 'bg-gray-50 text-gray-700'}`}>{details.output}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SkillCreatorConversationItem({ message, isConnecting = false }: SkillCreatorConversationItemProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDarkMode = theme === 'dark';
|
||||
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end gap-3">
|
||||
<div className={`max-w-[72%] rounded-2xl rounded-tr-md px-4 py-3 text-sm leading-relaxed shadow-sm ${
|
||||
isDarkMode ? 'bg-blue-600/80 text-white shadow-blue-950/20' : 'bg-gradient-to-r from-red-500 to-rose-500 text-white shadow-red-200/70'
|
||||
}`}>
|
||||
{message.content}
|
||||
</div>
|
||||
<MessageAvatar role="user" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<MessageAvatar role="assistant" active={!message.content} />
|
||||
<div
|
||||
className={`max-w-[86%] flex-1 rounded-3xl border px-4 py-3.5 text-sm shadow-sm ${
|
||||
isDarkMode ? 'border-slate-700 bg-slate-800/45 text-slate-200 shadow-black/10' : 'border-red-100 bg-white text-[#2a2f3f] shadow-red-100/60'
|
||||
}`}
|
||||
>
|
||||
{message.steps && message.steps.length > 0 && <StepsSummary steps={message.steps} />}
|
||||
{message.thinkings?.map((thinking, index) => (
|
||||
<ThinkingPanel key={`${message.id}-thinking-${index}`} duration={thinking.duration} content={thinking.content} />
|
||||
))}
|
||||
{message.toolCalls?.map((toolCall) => (
|
||||
<ToolCallPanel key={toolCall.id} toolCall={toolCall} />
|
||||
))}
|
||||
{isConnecting && (
|
||||
<div className={`flex items-center gap-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<span className="skill-creator-dot" />
|
||||
<span className="skill-creator-dot" />
|
||||
<span className="skill-creator-dot" />
|
||||
<span
|
||||
data-text="正在连接技能服务..."
|
||||
className={`ml-1 skill-creator-shimmer-text ${isDarkMode ? 'skill-creator-shimmer-dark' : 'skill-creator-shimmer-light'}`}
|
||||
>
|
||||
正在连接技能服务...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{message.content && (
|
||||
<MarkdownRenderer
|
||||
content={message.content}
|
||||
className={isDarkMode ? '[&_p]:text-slate-200 [&_li]:text-slate-200' : '[&_p]:text-[#2a2f3f] [&_li]:text-[#2a2f3f]'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 250 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user