This commit is contained in:
xuhd committed 2026-05-14 20:45:02 +08:00
commit 9a67ca7c03
97 files changed
+20759 -306

No files matched your search

+12 -1
View File
@@ -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>
+14 -3
View File
@@ -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">
+2 -1
View File
@@ -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("");
+101 -80
View File
@@ -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 ? (
+33 -16
View File
@@ -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";
+95
View File
@@ -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>
);
}
+49 -13
View File
@@ -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>
);
}