Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd
This commit is contained in:
commit
9a67ca7c03
97 files changed
+20759
-306
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
@@ -32,6 +33,7 @@ import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
@@ -66,7 +68,7 @@ export default function AgentChatPage() {
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#/page/workspace/agents/${agent_id}/chats/${createdThreadId}`,
|
||||
`${window.location.pathname}${window.location.search}#${routeBase}/agents/${agent_id}/chats/${createdThreadId}`,
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
@@ -98,6 +100,14 @@ export default function AgentChatPage() {
|
||||
[sendMessage, threadId, agent_id],
|
||||
);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
(answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
void sendMessage(threadId, { text: answer, files: [] }, { agent_id });
|
||||
},
|
||||
[sendMessage, threadId, agent_id],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
@@ -160,6 +170,7 @@ export default function AgentChatPage() {
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,11 +26,13 @@ import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
@@ -64,7 +66,7 @@ export default function ChatPage() {
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#/page/workspace/chats/${createdThreadId}`,
|
||||
`${window.location.pathname}${window.location.search}#${routeBase}/chats/${createdThreadId}`,
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
@@ -95,6 +97,14 @@ export default function ChatPage() {
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
(answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
void sendMessage(threadId, { text: answer, files: [] });
|
||||
},
|
||||
[sendMessage, threadId],
|
||||
);
|
||||
const handleStop = useCallback(async () => {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
@@ -137,6 +147,7 @@ export default function ChatPage() {
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
@@ -145,8 +156,8 @@ export default function ChatPage() {
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
? "max-w-(--container-width-md)"
|
||||
: "max-w-(--container-width-lg)",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useThreads } from "@/core/threads/hooks";
|
||||
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
|
||||
import { usePathOfThread, titleOfThread } from "@/core/threads/utils";
|
||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||
|
||||
export default function ChatsPage() {
|
||||
const pathOfThread = usePathOfThread();
|
||||
const { t } = useI18n();
|
||||
const { data: threads } = useThreads();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
@@ -17,6 +17,13 @@ import {
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
@@ -30,37 +37,18 @@ import {
|
||||
AgentNameCheckError,
|
||||
checkAgentName,
|
||||
createAgent,
|
||||
getAgent,
|
||||
updateAgent,
|
||||
} from "@/core/agents/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { Skill } from "@/core/skills";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
|
||||
const DEFAULT_SELECTED_SKILL_COUNT = 3;
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function getAgentWithRetry(agentId: string) {
|
||||
for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) {
|
||||
if (delay > 0) {
|
||||
await wait(delay);
|
||||
}
|
||||
try {
|
||||
return await getAgent(agentId);
|
||||
} catch {
|
||||
// Retry until the file/database write settles.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCreateAgentErrorMessage(
|
||||
error: unknown,
|
||||
networkErrorMessage: string,
|
||||
@@ -83,6 +71,8 @@ export default function NewAgentPage() {
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [descriptionInput, setDescriptionInput] = useState("");
|
||||
const [soulInput, setSoulInput] = useState("");
|
||||
const [modelInput, setModelInput] = useState<string>("");
|
||||
const [chatModelInput, setChatModelInput] = useState<string>("");
|
||||
const [formError, setFormError] = useState("");
|
||||
const [agentId, setAgentId] = useState("");
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
@@ -92,6 +82,7 @@ export default function NewAgentPage() {
|
||||
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
|
||||
const [threadPrepared, setThreadPrepared] = useState(false);
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
const { models, isLoading: isModelsLoading } = useModels();
|
||||
|
||||
const enabledSkills = useMemo(
|
||||
() => skills.filter((skill) => skill.enabled),
|
||||
@@ -109,26 +100,30 @@ export default function NewAgentPage() {
|
||||
);
|
||||
}, [enabledSkills, isSkillSelectionTouched]);
|
||||
|
||||
// Seed both model selectors with the first available model the first time
|
||||
// models load. The user can still change either independently afterwards.
|
||||
useEffect(() => {
|
||||
if (models.length === 0) {
|
||||
return;
|
||||
}
|
||||
const first = models[0]!.name;
|
||||
setModelInput((prev) => prev || first);
|
||||
setChatModelInput((prev) => prev || first);
|
||||
}, [models]);
|
||||
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId,
|
||||
assistantId: agentId || undefined,
|
||||
context: {
|
||||
mode: "flash",
|
||||
is_bootstrap: true,
|
||||
model_name: chatModelInput || undefined,
|
||||
},
|
||||
onToolEnd({ name }) {
|
||||
if (name !== "setup_agent" || !agentId) {
|
||||
return;
|
||||
}
|
||||
void getAgentWithRetry(agentId).then((fetched) => {
|
||||
if (fetched) {
|
||||
setAgent(fetched);
|
||||
setDescriptionInput(fetched.description ?? "");
|
||||
setSoulInput(fetched.soul ?? "");
|
||||
setSelectedSkills(fetched.skills ?? []);
|
||||
toast.success("专家配置已创建");
|
||||
}
|
||||
});
|
||||
onToolEnd() {
|
||||
// setup_agent never actually executes in this flow — it gets
|
||||
// intercepted by SetupAgentApprovalMiddleware and the form is filled
|
||||
// from the approval ToolMessage payload (handleApproveSetupAgent).
|
||||
// No agent record is fetched/refreshed during chat.
|
||||
},
|
||||
});
|
||||
|
||||
@@ -159,12 +154,17 @@ export default function NewAgentPage() {
|
||||
}
|
||||
let name = provided;
|
||||
if (!name) {
|
||||
// Names are display-only and may collide, so a timestamped placeholder is
|
||||
// safe. Reflect it in the form so the user sees what got created.
|
||||
// Names are display-only and may collide, so a timestamped placeholder
|
||||
// is safe to send to the backend just to satisfy the required-field
|
||||
// validation. Intentionally NOT written back into the visible input —
|
||||
// the user wants the name box to stay blank while they chat, and we
|
||||
// refill it later (with the model's proposed name) on setup_agent
|
||||
// approval.
|
||||
name = `未命名专家-${Date.now().toString(36)}`;
|
||||
setNameInput(name);
|
||||
}
|
||||
|
||||
const modelOverride = modelInput.trim() || null;
|
||||
|
||||
if (agentId) {
|
||||
setIsSavingDraft(true);
|
||||
try {
|
||||
@@ -173,6 +173,7 @@ export default function NewAgentPage() {
|
||||
description: descriptionInput,
|
||||
soul: soulInput,
|
||||
skills: selectedSkills,
|
||||
model: modelOverride,
|
||||
});
|
||||
setAgent(updated);
|
||||
return updated;
|
||||
@@ -192,6 +193,7 @@ export default function NewAgentPage() {
|
||||
description: descriptionInput,
|
||||
soul: soulInput,
|
||||
skills: selectedSkills,
|
||||
model: modelOverride,
|
||||
});
|
||||
setAgentId(created.id);
|
||||
setAgent(created);
|
||||
@@ -218,6 +220,7 @@ export default function NewAgentPage() {
|
||||
}, [
|
||||
agentId,
|
||||
descriptionInput,
|
||||
modelInput,
|
||||
selectedSkills,
|
||||
soulInput,
|
||||
t.agents.nameStepCheckError,
|
||||
@@ -239,63 +242,37 @@ export default function NewAgentPage() {
|
||||
if (!trimmed || thread.isLoading) {
|
||||
return;
|
||||
}
|
||||
// Lazily create the agent on first message so the user can just start
|
||||
// typing — ensureAgent generates a placeholder name when none is provided.
|
||||
let activeAgentId = agentId;
|
||||
if (!activeAgentId) {
|
||||
const created = await ensureAgent();
|
||||
if (!created) {
|
||||
return;
|
||||
}
|
||||
activeAgentId = created.id;
|
||||
}
|
||||
// Chat is purely a discussion channel. The agent record is only
|
||||
// created when the user explicitly clicks Save (top-right). If the
|
||||
// model proposes a config via setup_agent, our approval handler
|
||||
// prefills the form locally — it does NOT persist anything.
|
||||
await ensureThreadExists();
|
||||
await sendMessage(
|
||||
threadId,
|
||||
{ text: trimmed, files: [] },
|
||||
{ agent_id: activeAgentId },
|
||||
);
|
||||
await sendMessage(threadId, { text: trimmed, files: [] });
|
||||
},
|
||||
[agentId, ensureAgent, ensureThreadExists, sendMessage, thread.isLoading, threadId],
|
||||
[ensureThreadExists, sendMessage, thread.isLoading, threadId],
|
||||
);
|
||||
|
||||
const handleApproveSetupAgent = useCallback(
|
||||
async (payload: SetupAgentApprovalPayload) => {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
// Approval persists directly to the backend — no LLM round-trip. The
|
||||
// backend's PUT /api/agents/{id} writes both the DB record and SOUL.md /
|
||||
// config.yaml on disk, which is exactly what setup_agent_tool would have
|
||||
// done if we'd let the model re-invoke it.
|
||||
// Approving the model's setup_agent proposal **does not** persist
|
||||
// anything — it only mirrors the proposal into the form so the user
|
||||
// can review it. Creation/update happens later when the user clicks
|
||||
// the top-right Save button.
|
||||
const proposedName = payload.name?.trim() ?? "";
|
||||
const newDescription = payload.description ?? "";
|
||||
const newSoul = payload.soul ?? "";
|
||||
const newSkills = payload.skills ?? [];
|
||||
|
||||
// Prefer the model's proposed name when present; fall back to whatever
|
||||
// the user already typed, or the existing agent record.
|
||||
const resolvedName = proposedName || nameInput.trim() || agent?.name || agentId;
|
||||
setNameInput(resolvedName);
|
||||
if (proposedName) {
|
||||
setNameInput(proposedName);
|
||||
}
|
||||
setDescriptionInput(newDescription);
|
||||
setSoulInput(newSoul);
|
||||
setSelectedSkills(newSkills);
|
||||
setIsSkillSelectionTouched(true);
|
||||
|
||||
try {
|
||||
const updated = await updateAgent(agentId, {
|
||||
name: resolvedName,
|
||||
description: newDescription,
|
||||
soul: newSoul,
|
||||
skills: newSkills,
|
||||
});
|
||||
setAgent(updated);
|
||||
toast.success("专家配置已保存");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
toast.success("已套用模型建议,可继续编辑或点保存");
|
||||
},
|
||||
[agent, agentId, nameInput],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRejectSetupAgent = useCallback(
|
||||
@@ -356,7 +333,28 @@ export default function NewAgentPage() {
|
||||
onSetupAgentReject={handleRejectSetupAgent}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-background shrink-0 border-t px-4 py-4">
|
||||
<div className="bg-background shrink-0 space-y-2 border-t px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
当前对话模型
|
||||
</span>
|
||||
<Select
|
||||
value={chatModelInput}
|
||||
onValueChange={setChatModelInput}
|
||||
disabled={isModelsLoading || thread.isLoading}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-auto min-w-[10rem] gap-2 px-2 text-xs">
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.name} value={model.name}>
|
||||
{model.display_name || model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<PromptInput
|
||||
onSubmit={({ text }) => void handleChatSubmit(text)}
|
||||
>
|
||||
@@ -405,13 +403,36 @@ export default function NewAgentPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">模型</label>
|
||||
<Select
|
||||
value={modelInput}
|
||||
onValueChange={setModelInput}
|
||||
disabled={isModelsLoading}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.name} value={model.name}>
|
||||
{model.display_name || model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
该专家被使用时调用的模型。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">SOUL.md</label>
|
||||
<Textarea
|
||||
placeholder="写下专家的人设、边界、工作方式和输出要求"
|
||||
value={soulInput}
|
||||
onChange={(event) => setSoulInput(event.currentTarget.value)}
|
||||
className="min-h-48 resize-y font-mono text-xs"
|
||||
className="min-h-48 max-h-[400px] resize-y overflow-y-auto font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -422,7 +443,7 @@ export default function NewAgentPage() {
|
||||
已选 {selectedSkills.length} 个
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md border p-2">
|
||||
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
|
||||
{isSkillsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
||||
) : enabledSkills.length === 0 ? (
|
||||
|
||||
@@ -4,29 +4,46 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { PageSidebar } from "@/components/page-sidebar";
|
||||
|
||||
import WorkspaceRoutes from "./WorkspaceRoutes";
|
||||
import StrategyRoutes from "./StrategyRoutes";
|
||||
import Header from "@/strategy-components/Header";
|
||||
|
||||
// 设为 true 时隐藏侧边栏(开发中,暂未完成)
|
||||
const SIDEBAR_ENABLED = false;
|
||||
|
||||
function PageLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/20">
|
||||
<PageSidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
|
||||
/>
|
||||
<main
|
||||
className={`min-h-screen transition-[padding] duration-300 ${
|
||||
collapsed ? "pl-16" : "pl-16 md:pl-64"
|
||||
<div className="h-screen flex bg-muted/20">
|
||||
{SIDEBAR_ENABLED && (
|
||||
<PageSidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`flex flex-col flex-1 overflow-hidden transition-[padding] duration-300 ${
|
||||
!SIDEBAR_ENABLED ? "" : collapsed ? "pl-16" : "pl-64"
|
||||
}`}
|
||||
>
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="workspace" replace />} />
|
||||
<Route
|
||||
path="workspace/*"
|
||||
element={<WorkspaceRoutes pageSidebarCollapsed={collapsed} />}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
{SIDEBAR_ENABLED && <Header />}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="workspace" replace />} />
|
||||
<Route
|
||||
path="workspace/*"
|
||||
element={
|
||||
<WorkspaceRoutes
|
||||
pageSidebarCollapsed={collapsed}
|
||||
pageSidebarHidden={!SIDEBAR_ENABLED}
|
||||
headerHidden={!SIDEBAR_ENABLED}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="strategy/*" element={<StrategyRoutes />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
GripVerticalIcon,
|
||||
Loader2Icon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useCreateRecommendedQuestion,
|
||||
useDeleteRecommendedQuestion,
|
||||
useRecommendedQuestions,
|
||||
useReorderRecommendedQuestions,
|
||||
useUpdateRecommendedQuestion,
|
||||
} from "@/core/recommended-questions/hooks";
|
||||
import type { RecommendedQuestion } from "@/core/recommended-questions";
|
||||
|
||||
type DialogState =
|
||||
| { mode: "add" }
|
||||
| { mode: "edit"; question: RecommendedQuestion }
|
||||
| null;
|
||||
|
||||
export default function RecommendedQuestionsPage() {
|
||||
const { questions, isLoading, error } = useRecommendedQuestions();
|
||||
const createMutation = useCreateRecommendedQuestion();
|
||||
const updateMutation = useUpdateRecommendedQuestion();
|
||||
const deleteMutation = useDeleteRecommendedQuestion();
|
||||
const reorderMutation = useReorderRecommendedQuestions();
|
||||
|
||||
const [dialog, setDialog] = useState<DialogState>(null);
|
||||
const [formTitle, setFormTitle] = useState("");
|
||||
const [formContent, setFormContent] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<RecommendedQuestion | null>(null);
|
||||
|
||||
// drag state
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
function openAdd() {
|
||||
setFormTitle("");
|
||||
setFormContent("");
|
||||
setDialog({ mode: "add" });
|
||||
}
|
||||
|
||||
function openEdit(q: RecommendedQuestion) {
|
||||
setFormTitle(q.title);
|
||||
setFormContent(q.content);
|
||||
setDialog({ mode: "edit", question: q });
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setDialog(null);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const title = formTitle.trim();
|
||||
const content = formContent.trim();
|
||||
if (!title || !content) {
|
||||
toast.error("标题和内容不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (dialog?.mode === "add") {
|
||||
await createMutation.mutateAsync({
|
||||
title,
|
||||
content,
|
||||
sort_order: questions.length,
|
||||
});
|
||||
toast.success("问题已添加");
|
||||
} else if (dialog?.mode === "edit") {
|
||||
await updateMutation.mutateAsync({
|
||||
id: dialog.question.id,
|
||||
body: { title, content },
|
||||
});
|
||||
toast.success("问题已更新");
|
||||
}
|
||||
closeDialog();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(q: RecommendedQuestion) {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(q.id);
|
||||
setDeleteTarget(null);
|
||||
toast.success("问题已删除");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragStart(index: number) {
|
||||
setDragIndex(index);
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent, index: number) {
|
||||
e.preventDefault();
|
||||
setDragOverIndex(index);
|
||||
}
|
||||
|
||||
async function handleDrop(toIndex: number) {
|
||||
if (dragIndex === null || dragIndex === toIndex) {
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
const next = [...questions];
|
||||
const [moved] = next.splice(dragIndex, 1);
|
||||
next.splice(toIndex, 0, moved!);
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
try {
|
||||
await reorderMutation.mutateAsync(next.map((q) => q.id));
|
||||
} catch {
|
||||
toast.error("排序保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
const isSaving =
|
||||
createMutation.isPending ||
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending ||
|
||||
reorderMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">推荐问题配置</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
管理首页展示的推荐问题,点击后直接发送到对话。拖拽可调整顺序。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openAdd} className="shrink-0" disabled={isLoading}>
|
||||
<PlusIcon className="mr-1.5 size-4" />
|
||||
添加问题
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive flex flex-col items-center gap-2 rounded-xl border border-dashed py-16 text-sm">
|
||||
<p>加载失败:{error.message}</p>
|
||||
</div>
|
||||
) : questions.length === 0 ? (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-sm">
|
||||
<p>暂无推荐问题</p>
|
||||
<Button variant="outline" size="sm" onClick={openAdd}>
|
||||
<PlusIcon className="mr-1.5 size-4" />
|
||||
添加第一个问题
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{questions.map((q, index) => (
|
||||
<div
|
||||
key={q.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={() => handleDrop(index)}
|
||||
onDragEnd={() => {
|
||||
setDragIndex(null);
|
||||
setDragOverIndex(null);
|
||||
}}
|
||||
className={`flex items-start gap-3 rounded-xl border bg-white px-4 py-3.5 transition-colors ${
|
||||
dragOverIndex === index && dragIndex !== index
|
||||
? "border-blue-400 bg-blue-50"
|
||||
: "border-[#e4e4e7]"
|
||||
}`}
|
||||
>
|
||||
<GripVerticalIcon className="text-muted-foreground/40 mt-0.5 size-4 shrink-0 cursor-grab active:cursor-grabbing" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-[#18181b]">
|
||||
{q.title}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5 line-clamp-2 text-xs">
|
||||
{q.content}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => openEdit(q)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive size-7"
|
||||
onClick={() => setDeleteTarget(q)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加 / 编辑弹窗 */}
|
||||
<Dialog open={dialog !== null} onOpenChange={(o) => !o && closeDialog()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{dialog?.mode === "add" ? "添加推荐问题" : "编辑推荐问题"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">卡片标题</p>
|
||||
<Input
|
||||
placeholder="例:市场分析报告"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">发送内容</p>
|
||||
<Textarea
|
||||
placeholder="点击卡片后实际发送给 AI 的消息内容"
|
||||
rows={4}
|
||||
value={formContent}
|
||||
onChange={(e) => setFormContent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog} disabled={isSaving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving && <Loader2Icon className="mr-1.5 size-4 animate-spin" />}
|
||||
{dialog?.mode === "add" ? "添加" : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<Dialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
确定要删除「{deleteTarget?.title}」吗?此操作不可撤销。
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={isSaving}
|
||||
onClick={() => deleteTarget && handleDelete(deleteTarget)}
|
||||
>
|
||||
{deleteMutation.isPending && (
|
||||
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||
)}
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { RouteBaseProvider } from "@/core/route-base";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||
|
||||
import GeneralQA from "@/strategy-components/components/qa/GeneralQA";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import ChatsPage from "./ChatsPage";
|
||||
import ChatPage from "./ChatPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
|
||||
function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
<PromptInputProvider>{children}</PromptInputProvider>
|
||||
</ArtifactsProvider>
|
||||
</SubtasksProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StrategyRoutes() {
|
||||
return (
|
||||
<RouteBaseProvider value="/page/strategy">
|
||||
<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="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="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="settings/recommended-questions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<RecommendedQuestionsPage />
|
||||
) : (
|
||||
<Navigate to="/page/strategy/chats" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
<Toaster position="top-center" />
|
||||
</RouteBaseProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||
@@ -9,6 +11,8 @@ import { Toaster } from "sonner";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
|
||||
import { RouteBaseProvider } from "@/core/route-base";
|
||||
|
||||
import ChatsPage from "./ChatsPage";
|
||||
import ChatPage from "./ChatPage";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
@@ -19,20 +23,32 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||
import SkillsPage from "./SkillsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
|
||||
function WorkspaceLayout({
|
||||
export function WorkspaceLayout({
|
||||
children,
|
||||
pageSidebarCollapsed,
|
||||
pageSidebarHidden = false,
|
||||
headerHidden = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden?: boolean;
|
||||
headerHidden?: boolean;
|
||||
}) {
|
||||
const pageSidebarWidth = pageSidebarHidden
|
||||
? "0rem"
|
||||
: pageSidebarCollapsed
|
||||
? "4rem"
|
||||
: "16rem";
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
className="h-screen w-full overflow-x-hidden [&_[data-slot=sidebar-container]]:left-[var(--page-sidebar-width)]"
|
||||
className="h-full w-full overflow-x-hidden [&_[data-slot=sidebar-container]]:left-[var(--page-sidebar-width)]"
|
||||
style={
|
||||
{
|
||||
"--page-sidebar-width": pageSidebarCollapsed ? "4rem" : "16rem",
|
||||
"--page-sidebar-width": pageSidebarWidth,
|
||||
"--header-height": headerHidden ? "0rem" : "4rem",
|
||||
} as CSSProperties
|
||||
}
|
||||
// Keep the fixed workspace sidebar aligned to the outer page sidebar width.
|
||||
@@ -60,16 +76,23 @@ function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
|
||||
export default function WorkspaceRoutes({
|
||||
pageSidebarCollapsed = false,
|
||||
pageSidebarHidden = false,
|
||||
headerHidden = false,
|
||||
}: {
|
||||
pageSidebarCollapsed?: boolean;
|
||||
pageSidebarHidden?: boolean;
|
||||
headerHidden?: boolean;
|
||||
}) {
|
||||
const layoutProps = { pageSidebarCollapsed, pageSidebarHidden, headerHidden };
|
||||
|
||||
return (
|
||||
<RouteBaseProvider value="/page/workspace">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="chats/new" replace />} />
|
||||
<Route
|
||||
path="chats"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -77,7 +100,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
@@ -87,7 +110,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="agents"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AgentsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -95,7 +118,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="agents/new"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<NewAgentPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -107,7 +130,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="agents/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<AgentChatPage />
|
||||
</ChatRuntime>
|
||||
@@ -117,7 +140,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="skills"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<SkillsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -125,7 +148,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="skills/evolution"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<SkillEvolutionPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -137,7 +160,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="scheduled/tasks"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ScheduledTasksPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -145,7 +168,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="scheduled/runs/:run_id"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ScheduledTaskRunDetailPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
@@ -153,11 +176,24 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<UsersPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<RecommendedQuestionsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</RouteBaseProvider>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user