This commit is contained in:
董宏凯 committed 2026-05-18 22:45:25 +08:00
commit 1e5e2581c8
86 files changed
+5887 -671

No files matched your search

+3
View File
@@ -13,6 +13,7 @@ import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import PublicSharePage from "./pages/PublicSharePage";
import { NotFoundPage } from "./pages/NotFoundPage";
function AppProviders({ children }: { children: React.ReactNode }) {
@@ -47,6 +48,8 @@ export function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} />
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
@@ -6,6 +6,13 @@ import type { AgentThreadState } from "@/core/threads";
export interface ThreadContextType {
thread: BaseStream<AgentThreadState>;
isMock?: boolean;
/**
* When set, the conversation is rendered from a public read-only share.
* Artifact/image URLs then resolve to the unauthenticated
* `/api/public/shares/{code}/files/...` endpoint instead of the
* auth-gated thread artifact route.
*/
publicShareCode?: string;
}
export const ThreadContext = createContext<ThreadContextType | undefined>(
@@ -2,6 +2,8 @@ import type { Message } from "@langchain/langgraph-sdk";
import { FileIcon, Loader2Icon } from "lucide-react";
import {
memo,
useCallback,
useContext,
useMemo,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
@@ -23,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 { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
@@ -38,9 +41,33 @@ import { cn } from "@/lib/utils";
import { CopyButton } from "../copy-button";
import { ThreadContext } from "./context";
import { MarkdownContent } from "./markdown-content";
import { MessageTokenUsage } from "./message-token-usage";
/**
* Resolve an in-conversation file path to a fetchable URL.
*
* On a public read-only share (`ThreadContext.publicShareCode` set) artifact
* URLs point at the unauthenticated `/api/public/shares/{code}/files/...`
* endpoint; otherwise they use the normal auth-gated thread artifact route.
*/
function useArtifactResolver(threadId: string): (path: string) => string {
const publicShareCode = useContext(ThreadContext)?.publicShareCode;
return useCallback(
(path: string) => {
if (publicShareCode) {
const normalized = path.startsWith("/") ? path : `/${path}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
}
return resolveArtifactURL(path, threadId);
},
[publicShareCode, threadId],
);
}
export function MessageListItem({
className,
message,
@@ -102,6 +129,7 @@ function MessageImage({
threadId: string;
maxWidth?: string;
}) {
const resolveArtifact = useArtifactResolver(threadId);
if (!src) return null;
const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`);
@@ -110,7 +138,7 @@ function MessageImage({
return <img className={imgClassName} src={src} alt={alt} {...props} />;
}
const url = src.startsWith("/mnt/") ? resolveArtifactURL(src, threadId) : src;
const url = src.startsWith("/mnt/") ? resolveArtifact(src) : src;
return (
<a href={url} target="_blank" rel="noopener noreferrer">
@@ -134,6 +162,7 @@ function MessageContent_({
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const isHuman = message.type === "human";
const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo(
() => ({
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
@@ -141,7 +170,7 @@ function MessageContent_({
),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("/mnt/")) {
const url = resolveArtifactURL(href, threadId);
const url = resolveArtifact(href);
return (
<a
{...props}
@@ -154,7 +183,7 @@ function MessageContent_({
return <a {...props} href={href} />;
},
}),
[threadId],
[threadId, resolveArtifact],
);
const rawContent = extractContentFromMessage(message);
@@ -347,6 +376,7 @@ function RichFileCard({
threadId: string;
}) {
const { t } = useI18n();
const resolveArtifact = useArtifactResolver(threadId);
const isUploading = file.status === "uploading";
const isImage = isImageFile(file.filename);
@@ -379,7 +409,7 @@ function RichFileCard({
if (!file.path) return null;
const fileUrl = resolveArtifactURL(file.path, threadId);
const fileUrl = resolveArtifact(file.path);
if (isImage) {
return (
@@ -42,6 +42,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import {
@@ -76,6 +77,9 @@ export function RecentChatList() {
const [renameThreadId, setRenameThreadId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
// Share dialog state — the conversation whose share code is being shown.
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
const handleDelete = useCallback(
(threadId: string) => {
deleteThread({ threadId });
@@ -115,18 +119,10 @@ export function RecentChatList() {
}
}, [renameThread, renameThreadId, renameValue]);
const handleShare = useCallback(
async (thread: AgentThread) => {
const shareUrl = `${window.location.origin}${pathOfThread(thread)}`;
try {
await navigator.clipboard.writeText(shareUrl);
toast.success(t.clipboard.linkCopied);
} catch {
toast.error(t.clipboard.failedToCopyToClipboard);
}
},
[t],
);
const handleShare = useCallback((thread: AgentThread) => {
// Opens the share-code dialog; the recipient imports a copy via that code.
setShareThreadId(thread.thread_id);
}, []);
const handleExport = useCallback(
async (thread: AgentThread, format: "markdown" | "json") => {
@@ -173,89 +169,89 @@ export function RecentChatList() {
<div className="flex w-full flex-col gap-1">
{visibleThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{titleOfThread(thread)}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background"
>
<MoreHorizontal />
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={"right"}
align={"start"}
>
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "markdown")
}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "json")
}
>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread.thread_id)}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
);
})}
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{titleOfThread(thread)}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background"
>
<MoreHorizontal />
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={"right"}
align={"start"}
>
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "markdown")
}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "json")
}
>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread.thread_id)}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
);
})}
</div>
</SidebarMenu>
</SidebarGroupContent>
@@ -291,6 +287,15 @@ export function RecentChatList() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Share Dialog */}
<ShareConversationDialog
threadId={shareThreadId}
open={shareThreadId !== null}
onOpenChange={(o) => {
if (!o) setShareThreadId(null);
}}
/>
</>
);
}
@@ -0,0 +1,220 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// ---- 时间配置 ----
export type ScheduleFreq = "hourly" | "daily" | "weekly" | "monthly";
export interface ScheduleConfig {
freq: ScheduleFreq;
minute: number;
hour: number;
weekday: number;
date: number;
}
export const DEFAULT_SCHEDULE_CONFIG: ScheduleConfig = {
freq: "daily",
minute: 0,
hour: 9,
weekday: 1,
date: 1,
};
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const MINUTES = [0, 15, 30, 45];
const DATES = Array.from({ length: 31 }, (_, i) => i + 1);
export function buildCron(cfg: ScheduleConfig): string {
// Keep the minute slot when the user picked one for daily/weekly/monthly too,
// so "每天 9:30" doesn't silently truncate to 9:00 on save → reload round trips.
const m = Number.isFinite(cfg.minute) ? cfg.minute : 0;
switch (cfg.freq) {
case "hourly":
return `${m} * * * *`;
case "daily":
return `${m} ${cfg.hour} * * *`;
case "weekly":
return `${m} ${cfg.hour} * * ${cfg.weekday}`;
case "monthly":
return `${m} ${cfg.hour} ${cfg.date} * *`;
}
}
// Parse a 5-field cron expression into the form picker state.
//
// Bug we keep getting bitten by: the edit dialog used to call
// `parseCron(task.schedule_text)` and `schedule_text` may be the raw user
// input (free-text like "每天 9 点") rather than a real cron string. The
// fallback `DEFAULT_SCHEDULE_CONFIG` then made the picker silently
// reinterpret the task. Callers should prefer `task.cron_expr` (the canonical
// schedule produced by `_schedule_to_cron`); this function stays the single
// source of truth for cron → picker, and also handles the looser step-syntax
// variants (e.g. "0 */3 * * *") we sometimes generate.
export function parseCron(cron: string | null | undefined): ScheduleConfig | null {
if (!cron) return null;
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [min, hr, date, , wd] = parts;
const minute = parseInt(min ?? "", 10);
const hour = parseInt(hr ?? "", 10);
// Hourly: ``M * * * *`` — match before the daily branch so a minute-only
// cron isn't misread as daily.
if (hr === "*" && date === "*" && wd === "*") {
if (!isNaN(minute)) {
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
}
}
// ``0 */N * * *`` (every N hours): map to hourly with that minute; the
// picker doesn't model "every N hours" yet, so this is the safest fallback
// that at least doesn't silently round to 9:00.
if ((hr ?? "").startsWith("*/") && date === "*" && wd === "*") {
if (!isNaN(minute)) {
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
}
}
if (date === "*" && wd === "*") {
if (!isNaN(hour)) {
return {
...DEFAULT_SCHEDULE_CONFIG,
freq: "daily",
hour,
minute: isNaN(minute) ? 0 : minute,
};
}
}
if (date === "*" && wd !== "*") {
const weekday = parseInt(wd ?? "", 10);
if (!isNaN(hour) && !isNaN(weekday)) {
return {
...DEFAULT_SCHEDULE_CONFIG,
freq: "weekly",
hour,
weekday,
minute: isNaN(minute) ? 0 : minute,
};
}
}
if (wd === "*" && date !== "*") {
const d = parseInt(date ?? "", 10);
if (!isNaN(hour) && !isNaN(d)) {
return {
...DEFAULT_SCHEDULE_CONFIG,
freq: "monthly",
hour,
date: d,
minute: isNaN(minute) ? 0 : minute,
};
}
}
return null;
}
export function ScheduleBuilder({
value,
onChange,
}: {
value: ScheduleConfig;
onChange: (cfg: ScheduleConfig) => void;
}) {
const set = (patch: Partial<ScheduleConfig>) => onChange({ ...value, ...patch });
return (
<div className="flex flex-wrap gap-2">
<Select value={value.freq} onValueChange={(v) => set({ freq: v as ScheduleFreq })}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">每小时</SelectItem>
<SelectItem value="daily">每天</SelectItem>
<SelectItem value="weekly">每周</SelectItem>
<SelectItem value="monthly">每月</SelectItem>
</SelectContent>
</Select>
{value.freq === "hourly" && (
<Select value={String(value.minute)} onValueChange={(v) => set({ minute: parseInt(v) })}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m} value={String(m)}>{m} 分</SelectItem>
))}
</SelectContent>
</Select>
)}
{value.freq === "weekly" && (
<Select value={String(value.weekday)} onValueChange={(v) => set({ weekday: parseInt(v) })}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WEEKDAYS.map((d, i) => (
<SelectItem key={i} value={String(i)}>{d}</SelectItem>
))}
</SelectContent>
</Select>
)}
{value.freq === "monthly" && (
<Select value={String(value.date)} onValueChange={(v) => set({ date: parseInt(v) })}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATES.map((d) => (
<SelectItem key={d} value={String(d)}>第 {d} 日</SelectItem>
))}
</SelectContent>
</Select>
)}
{value.freq !== "hourly" && (
<Select value={String(value.hour)} onValueChange={(v) => set({ hour: parseInt(v) })}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOURS.map((h) => (
<SelectItem key={h} value={String(h)}>{h} 时</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
}
export function statusLabel(status?: string | null) {
switch (status) {
case "succeeded":
return "成功";
case "failed":
return "失败";
case "running":
return "执行中";
case "pending":
return "等待中";
default:
return status || "未知";
}
}
export function statusVariant(
status?: string | null,
): "default" | "secondary" | "destructive" | "outline" {
if (status === "succeeded") return "default";
if (status === "failed") return "destructive";
if (status === "running") return "secondary";
return "outline";
}
@@ -1,10 +1,24 @@
"use client";
import { Loader2, PencilIcon } from "lucide-react";
import {
ChevronDownIcon,
FileCode2Icon,
FileTextIcon,
GlobeIcon,
Loader2,
PencilIcon,
SparklesIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
@@ -23,15 +37,18 @@ import {
} from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { reasoningPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
/**
* Whether the current user may edit a skill's detail text.
* Whether the current user may edit a skill's detail text and view its raw
* SKILL.md source.
*
* Admins can edit any skill; the publisher (owner of a custom skill) can edit
* Admins qualify for any skill; the publisher (owner of a custom skill) for
* their own. Built-in skills have no human owner, so only admins qualify.
* Mirrors the backend authz in `PUT /api/skills/{name}/detail`.
* Mirrors the backend authz in `PUT /api/skills/{name}/detail` and
* `GET /api/skills/{name}/content`.
*/
function canEditSkillDetail(skill: Skill | null | undefined): boolean {
function canManageSkill(skill: Skill | null | undefined): boolean {
if (!skill) return false;
const auth = getStoredAuth();
if (!auth) return false;
@@ -45,8 +62,9 @@ function canEditSkillDetail(skill: Skill | null | undefined): boolean {
/**
* Reusable skill detail dialog. Given a skill name it fetches the skill
* metadata (including the admin/owner-authored `detail`) and the raw SKILL.md
* source, and lets authorized users edit the detail inline.
* metadata (including the admin/owner-authored `detail`) and lets authorized
* users edit the detail inline. The raw SKILL.md source is shown only to the
* publisher or an admin — everyone else sees just the description and detail.
*/
export function SkillDetailDialog({
skillName,
@@ -62,17 +80,22 @@ export function SkillDetailDialog({
// Only fetch while the dialog is open to avoid background requests.
const activeName = open ? skillName : null;
const { data: skill, isLoading: isSkillLoading } = useSkill(activeName);
const {
data: contentData,
isLoading: isContentLoading,
error: contentError,
} = useSkillContent(activeName);
const setDetail = useSetSkillDetail();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [showSource, setShowSource] = useState(false);
const canManage = useMemo(() => canManageSkill(skill), [skill]);
// The SKILL.md source endpoint is publisher/admin-only — only request it
// when the viewer is actually allowed to see it.
const {
data: contentData,
isLoading: isContentLoading,
error: contentError,
} = useSkillContent(open && canManage ? skillName : null);
// Reset transient UI whenever the dialog closes or switches to another skill.
useEffect(() => {
if (!open) {
@@ -81,8 +104,6 @@ export function SkillDetailDialog({
}
}, [open, skillName]);
const canEdit = useMemo(() => canEditSkillDetail(skill), [skill]);
function startEditing() {
setDraft(skill?.detail ?? "");
setEditing(true);
@@ -101,22 +122,54 @@ export function SkillDetailDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{skill?.name ?? skillName ?? ""}</DialogTitle>
<DialogDescription>{skill?.description}</DialogDescription>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
{/* Header */}
<DialogHeader className="space-y-0 border-b px-6 py-4 text-left">
<div className="flex items-start gap-3">
<div className="bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-lg">
<SparklesIcon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<DialogTitle className="truncate text-base">
{skill?.name ?? skillName ?? ""}
</DialogTitle>
{skill?.builtin && (
<Badge variant="secondary" className="text-xs">
{tx.badgeBuiltin}
</Badge>
)}
{skill?.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
>
<GlobeIcon className="size-3" />
{tx.badgePublished}
</Badge>
)}
</div>
<DialogDescription className="mt-1 text-sm leading-relaxed">
{skill?.description || "—"}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-auto pr-1">
{/* Body */}
<div className="min-h-0 flex-1 space-y-5 overflow-auto px-6 py-5">
{/* Detail — human-authored long-form introduction */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">{tx.detailSectionTitle}</h3>
{canEdit && !editing && (
<h3 className="text-foreground/90 flex items-center gap-1.5 text-sm font-semibold">
<FileTextIcon className="text-muted-foreground size-4" />
{tx.detailSectionTitle}
</h3>
{canManage && !editing && (
<Button
size="sm"
variant="ghost"
className="h-7"
className="text-muted-foreground hover:text-foreground h-7 px-2"
onClick={startEditing}
>
<PencilIcon className="size-3.5" />
@@ -124,15 +177,17 @@ export function SkillDetailDialog({
</Button>
)}
</div>
{editing ? (
<div className="space-y-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
placeholder={tx.detailPlaceholder}
className="max-h-[320px] min-h-40 resize-y overflow-y-auto text-sm"
className="max-h-[320px] min-h-44 resize-y overflow-y-auto text-sm leading-relaxed"
autoFocus
/>
<div className="flex justify-end gap-2">
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
@@ -154,12 +209,12 @@ export function SkillDetailDialog({
</div>
</div>
) : isSkillLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-dashed px-4 py-8 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : skill?.detail ? (
<div className="bg-muted/40 rounded-md border p-3 text-sm">
<div className="bg-muted/30 rounded-lg border p-4 text-sm leading-relaxed">
<MarkdownContent
content={skill.detail}
isLoading={false}
@@ -168,44 +223,55 @@ export function SkillDetailDialog({
/>
</div>
) : (
<p className="text-muted-foreground text-sm">
{tx.detailEmpty}
{!canEdit ? ` ${tx.detailAdminOnlyHint}` : ""}
</p>
)}
</section>
{/* SKILL.md source — collapsed by default */}
<section className="space-y-2">
<button
type="button"
className="text-muted-foreground hover:text-foreground text-sm font-medium"
onClick={() => setShowSource((v) => !v)}
>
{showSource ? "▾ " : "▸ "}
{tx.contentSectionTitle}
</button>
{showSource && (
<div className="bg-muted/40 overflow-auto rounded-md border">
{isContentLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : contentError ? (
<div className="text-destructive p-4 text-sm">
{contentError instanceof Error
? contentError.message
: String(contentError)}
</div>
) : (
<pre className="text-foreground/90 max-h-[320px] overflow-auto whitespace-pre-wrap wrap-break-word p-4 font-mono text-xs leading-relaxed">
{contentData?.content}
</pre>
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
<p className="text-sm">{tx.detailEmpty}</p>
{!canManage && (
<p className="mt-1 text-xs">{tx.detailAdminOnlyHint}</p>
)}
</div>
)}
</section>
{/* SKILL.md source — publisher / admin only, collapsed by default */}
{canManage && (
<section>
<Collapsible open={showSource} onOpenChange={setShowSource}>
<CollapsibleTrigger className="bg-muted/30 hover:bg-muted/60 flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-sm font-semibold transition-colors">
<span className="flex items-center gap-1.5">
<FileCode2Icon className="text-muted-foreground size-4" />
{tx.contentSectionTitle}
</span>
<ChevronDownIcon
className={cn(
"text-muted-foreground size-4 transition-transform",
showSource && "rotate-180",
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="bg-muted/50 max-h-[340px] overflow-auto rounded-lg border">
{isContentLoading ? (
<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>
) : contentError ? (
<div className="text-destructive p-4 text-sm">
{contentError instanceof Error
? contentError.message
: String(contentError)}
</div>
) : (
<pre className="text-foreground/80 p-4 font-mono text-xs leading-relaxed break-words whitespace-pre-wrap">
{contentData?.content}
</pre>
)}
</div>
</CollapsibleContent>
</Collapsible>
</section>
)}
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,328 @@
"use client";
import {
CheckIcon,
CopyIcon,
DownloadIcon,
ExternalLinkIcon,
Loader2,
Trash2Icon,
} from "lucide-react";
import { useEffect, 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 { useI18n } from "@/core/i18n/hooks";
import type {
SharePreview,
ThreadShare,
ThreadShareMode,
} from "@/core/thread-shares/api";
import { buildPublicShareUrl, extractShareCode } from "@/core/thread-shares/api";
import {
useCreateShare,
useImportShare,
usePreviewShare,
useRevokeShare,
} from "@/core/thread-shares/hooks";
import { cn } from "@/lib/utils";
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Dialog that produces a share link for a single conversation. Two modes:
*
* - `import` — mints a short code; a recipient enters it in the import dialog
* to copy the conversation into their own account.
* - `view` — mints a public, read-only link that anyone can open without
* logging in (a static page; viewers cannot send new messages).
*/
export function ShareConversationDialog({
threadId,
open,
onOpenChange,
}: {
threadId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const tx = t.threadShare;
const createShare = useCreateShare();
const revokeShare = useRevokeShare();
const [mode, setMode] = useState<ThreadShareMode>("view");
const [share, setShare] = useState<ThreadShare | null>(null);
const [copied, setCopied] = useState(false);
// Reset to the default mode (read-only link) every time the dialog opens.
useEffect(() => {
if (open) setMode("view");
}, [open, threadId]);
// Mint / reuse the link whenever the dialog opens or the mode changes.
useEffect(() => {
if (!open || !threadId) return;
setShare(null);
setCopied(false);
createShare
.mutateAsync({ threadId, mode })
.then(setShare)
.catch((err) => toast.error(errorMessage(err)));
// createShare is stable enough; re-running only on open/threadId/mode is intended.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, threadId, mode]);
const isView = mode === "view";
const publicUrl =
isView && share ? buildPublicShareUrl(share.share_code) : "";
async function handleCopy() {
if (!share) return;
const text = isView ? publicUrl : share.share_code;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
toast.success(isView ? "链接已复制" : tx.codeCopied);
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t.clipboard.failedToCopyToClipboard);
}
}
async function handleRevoke() {
if (!share || !window.confirm(tx.revokeConfirm)) return;
try {
await revokeShare.mutateAsync(share.share_code);
toast.success(tx.revokeSuccess);
onOpenChange(false);
} catch (err) {
toast.error(errorMessage(err));
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{tx.shareTitle}</DialogTitle>
<DialogDescription>
{isView
? "生成一个只读链接,任何人无需登录都能查看这个对话,但不能继续提问。"
: tx.shareDescription}
</DialogDescription>
</DialogHeader>
{/* Mode selector */}
<div className="bg-muted flex gap-1 rounded-lg p-1">
{(
[
{ value: "view" as const, label: "只读链接" },
{ value: "import" as const, label: "导入副本" },
]
).map((option) => (
<button
key={option.value}
type="button"
onClick={() => setMode(option.value)}
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
mode === option.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
))}
</div>
{!share ? (
<div className="text-muted-foreground flex items-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" />
{tx.generating}
</div>
) : (
<div className="space-y-3">
<div>
<p className="text-muted-foreground mb-1 text-xs">
{isView ? "只读链接" : tx.shareCodeLabel}
</p>
<div className="flex items-center gap-2">
{isView ? (
<Input
readOnly
value={publicUrl}
onFocus={(e) => e.currentTarget.select()}
className="flex-1 font-mono text-xs"
/>
) : (
<code className="bg-muted flex-1 rounded-md px-3 py-2 text-center font-mono text-lg tracking-[0.3em]">
{share.share_code}
</code>
)}
<Button variant="outline" onClick={() => void handleCopy()}>
{copied ? (
<CheckIcon className="size-4" />
) : (
<CopyIcon className="size-4" />
)}
{isView ? "复制链接" : tx.copyCode}
</Button>
</div>
</div>
{isView ? (
<div className="space-y-3">
<p className="text-muted-foreground text-xs">
拿到链接的人都能查看这个对话的全部内容(只读),无法继续发送问题。撤销后链接立即失效。
</p>
<Button
variant="outline"
size="sm"
onClick={() => window.open(publicUrl, "_blank", "noopener")}
>
<ExternalLinkIcon className="size-3.5" />
在新标签页打开
</Button>
</div>
) : (
<p className="text-muted-foreground text-xs">{tx.shareHint}</p>
)}
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={revokeShare.isPending}
onClick={() => void handleRevoke()}
>
<Trash2Icon className="size-3.5" />
{tx.revoke}
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
/** Dialog where a user pastes a share code to import someone's conversation. */
export function ImportConversationsDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const tx = t.threadShare;
const [input, setInput] = useState("");
const [preview, setPreview] = useState<SharePreview | null>(null);
const previewShare = usePreviewShare();
const importShare = useImportShare();
// Reset state whenever the dialog closes.
useEffect(() => {
if (!open) {
setInput("");
setPreview(null);
}
}, [open]);
async function handlePreview() {
const code = extractShareCode(input);
if (!code) {
toast.error(tx.emptyCode);
return;
}
try {
setPreview(await previewShare.mutateAsync(code));
} catch (err) {
setPreview(null);
toast.error(errorMessage(err));
}
}
async function handleImport() {
const code = preview?.share_code ?? extractShareCode(input);
if (!code) {
toast.error(tx.emptyCode);
return;
}
try {
const result = await importShare.mutateAsync(code);
if (result.imported > 0) {
toast.success(tx.importSuccess);
} else {
toast.info(tx.alreadyImported);
}
onOpenChange(false);
} catch (err) {
toast.error(errorMessage(err));
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{tx.importTitle}</DialogTitle>
<DialogDescription>{tx.importDescription}</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
value={input}
onChange={(e) => {
setInput(e.target.value);
setPreview(null);
}}
placeholder={tx.codeInputPlaceholder}
className="font-mono"
/>
<Button
variant="outline"
onClick={() => void handlePreview()}
disabled={previewShare.isPending || !input.trim()}
>
{previewShare.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{tx.preview}
</Button>
</div>
{preview && (
<div className="space-y-2 rounded-lg border p-3 text-sm">
<p className="font-medium">
{tx.previewConversation.replace("{title}", preview.title)}
</p>
<p className="text-muted-foreground text-xs">
{tx.previewFrom.replace("{user}", preview.shared_by)}
</p>
<Button
className="w-full"
onClick={() => void handleImport()}
disabled={importShare.isPending}
>
{importShare.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<DownloadIcon className="size-4" />
)}
{importShare.isPending ? tx.importing : tx.doImport}
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -13,7 +13,7 @@ import {
} from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BotIcon, CalendarClock, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
export function WorkspaceNavChatList() {
const { t } = useI18n();
@@ -77,7 +77,7 @@ export function WorkspaceNavChatList() {
{isAdmin && (
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/admin")}
isActive={pathname === "/page/workspace/admin/users"}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/admin/users">
@@ -87,6 +87,22 @@ export function WorkspaceNavChatList() {
</SidebarMenuButton>
</SidebarMenuItem>
)}
{isAdmin && (
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/admin/scheduled-tasks"}
asChild
>
<Link
className="text-sidebar-foreground"
href="/page/workspace/admin/scheduled-tasks"
>
<CalendarClock />
<span>定时任务管理</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroup>
);
@@ -250,6 +250,35 @@ export const enUS: Translations = {
searchChats: "Search chats",
},
// Conversation sharing
threadShare: {
importChats: "Import chat",
shareTitle: "Share conversation",
shareDescription:
'Share this conversation with others. They enter the code under "Import chat" to copy it into their own account and keep chatting.',
generating: "Generating code...",
shareCodeLabel: "Share code",
copyCode: "Copy",
codeCopied: "Share code copied",
shareHint:
"Anyone who has the code can copy this conversation — share it carefully.",
revoke: "Revoke share",
revokeConfirm: "Revoking makes the code unusable immediately. Continue?",
revokeSuccess: "Share revoked",
importTitle: "Import a conversation",
importDescription:
"Enter a share code to copy the shared conversation into your account.",
codeInputPlaceholder: "Paste a share code",
preview: "Preview",
previewConversation: "Conversation: {title}",
previewFrom: "From user {user}",
doImport: "Import",
importing: "Importing...",
importSuccess: "Conversation imported",
alreadyImported: "You have already imported this conversation",
emptyCode: "Enter a share code first",
},
// Page titles (document title)
pages: {
appName: "CM Assistant",
@@ -194,6 +194,32 @@ export interface Translations {
searchChats: string;
};
// Conversation sharing
threadShare: {
importChats: string;
shareTitle: string;
shareDescription: string;
generating: string;
shareCodeLabel: string;
copyCode: string;
codeCopied: string;
shareHint: string;
revoke: string;
revokeConfirm: string;
revokeSuccess: string;
importTitle: string;
importDescription: string;
codeInputPlaceholder: string;
preview: string;
previewConversation: string;
previewFrom: string;
doImport: string;
importing: string;
importSuccess: string;
alreadyImported: string;
emptyCode: string;
};
// Page titles (document title)
pages: {
appName: string;
@@ -238,6 +238,33 @@ export const zhCN: Translations = {
searchChats: "搜索对话",
},
// Conversation sharing
threadShare: {
importChats: "导入对话",
shareTitle: "分享对话",
shareDescription:
"把这个对话分享给他人。对方在「导入对话」里填入分享码,即可把它复制一份到自己账号并继续聊天。",
generating: "生成分享码中...",
shareCodeLabel: "分享码",
copyCode: "复制",
codeCopied: "分享码已复制",
shareHint: "知道分享码的人都能复制这个对话,请谨慎分享。",
revoke: "撤销分享",
revokeConfirm: "撤销后该分享码立即失效,确定撤销?",
revokeSuccess: "已撤销分享",
importTitle: "导入对话",
importDescription: "填入对方给你的分享码,把 TA 分享的那个对话复制到你的账号。",
codeInputPlaceholder: "粘贴分享码",
preview: "预览",
previewConversation: "对话:{title}",
previewFrom: "来自用户 {user}",
doImport: "导入",
importing: "导入中...",
importSuccess: "对话已导入",
alreadyImported: "你已经导入过这个对话了",
emptyCode: "请先填入分享码",
},
// Page titles (document title)
pages: {
appName: "CM 助手",
@@ -8,6 +8,7 @@ import type {
ScheduledTaskResultFileContent,
ScheduledTaskRun,
ScheduledTaskRunFileUpdateResponse,
ScheduledTaskRunMessagesResponse,
ScheduledTaskSubscription,
SchedulerThread,
} from "./types";
@@ -79,6 +80,13 @@ export async function deleteScheduledTaskRun(runId: string) {
);
}
/** Agent messages for a run's underlying agent run — used to inspect a stuck/running run. */
export async function getScheduledTaskRunMessages(runId: string) {
return parseResponse<ScheduledTaskRunMessagesResponse>(
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/messages`),
);
}
export function scheduledTaskRunDownloadUrl(runId: string) {
return `${baseUrl()}/runs/${encodeURIComponent(runId)}/download`;
}
@@ -296,3 +304,61 @@ export async function unsubscribeScheduledTask(taskId: string) {
}),
);
}
// --- Admin (cross-user management) ---
/** List every scheduled task across all users. Admin-only endpoint. */
export async function listAllScheduledTasks(search?: string) {
const qs = new URLSearchParams();
if (search) qs.set("search", search);
const query = qs.toString();
return parseResponse<ScheduledTask[]>(
await apiFetch(`${baseUrl()}/admin/all${query ? `?${query}` : ""}`),
);
}
/** List runs for any task regardless of owner. Admin-only endpoint. */
export async function adminListScheduledTaskRuns(taskId: string) {
return parseResponse<ScheduledTaskRun[]>(
await apiFetch(`${baseUrl()}/admin/${encodeURIComponent(taskId)}/runs`),
);
}
/** Update any task regardless of owner. Admin-only endpoint. */
export async function adminUpdateScheduledTask(
taskId: string,
data: Partial<{
name: string;
prompt: string;
schedule: string;
timezone: string;
enabled: boolean;
execution_agent_name: string | null;
}>,
) {
return parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/admin/${encodeURIComponent(taskId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
}
/** Delete any task regardless of owner. Admin-only endpoint. */
export async function adminDeleteScheduledTask(taskId: string) {
return parseResponse<{ success: boolean }>(
await apiFetch(`${baseUrl()}/admin/${encodeURIComponent(taskId)}`, {
method: "DELETE",
}),
);
}
/** Trigger an immediate run of any task regardless of owner. Admin-only. */
export async function adminTriggerScheduledTask(taskId: string) {
return parseResponse<{ success: boolean; task: ScheduledTask }>(
await apiFetch(`${baseUrl()}/admin/${encodeURIComponent(taskId)}/trigger`, {
method: "POST",
}),
);
}
@@ -6,12 +6,18 @@ import {
} from "@tanstack/react-query";
import {
adminDeleteScheduledTask,
adminListScheduledTaskRuns,
adminTriggerScheduledTask,
adminUpdateScheduledTask,
createScheduledTask,
deleteScheduledTask,
deleteScheduledTaskRun,
getSchedulerThread,
getScheduledTaskDeliveryProfile,
getScheduledTaskRun,
getScheduledTaskRunMessages,
listAllScheduledTasks,
listPublishedScheduledTasks,
listScheduledTaskRuns,
listScheduledTasks,
@@ -57,12 +63,34 @@ export function useScheduledTaskRuns(taskId?: string | null) {
});
}
/** A run still in flight — used to drive live polling of the run/messages. */
function runIsLive(status?: string | null): boolean {
return status === "running" || status === "pending";
}
export function useScheduledTaskRun(runId?: string | null) {
return useQuery({
queryKey: ["scheduled-tasks", "runs", runId],
queryFn: () => getScheduledTaskRun(runId!),
enabled: !!runId,
refetchOnWindowFocus: false,
// Keep refreshing while the run is still executing so the detail page
// reflects status / result changes without a manual reload.
refetchInterval: (query) => (runIsLive(query.state.data?.status) ? 5000 : false),
});
}
/** Agent messages for a run; polls every 4s while the run is still executing. */
export function useScheduledTaskRunMessages(
runId?: string | null,
options: { live?: boolean } = {},
) {
return useQuery({
queryKey: ["scheduled-tasks", "runs", runId, "messages"],
queryFn: () => getScheduledTaskRunMessages(runId!),
enabled: !!runId,
refetchOnWindowFocus: false,
refetchInterval: options.live ? 4000 : false,
});
}
@@ -74,6 +102,64 @@ export function useScheduledTaskDeliveryProfile() {
});
}
/** Admin: every scheduled task across all users. */
export function useAllScheduledTasks(
params: { search?: string; enabled?: boolean } = {},
) {
const search = params.search ?? "";
return useQuery({
queryKey: ["scheduled-tasks", "admin", "list", search],
queryFn: () => listAllScheduledTasks(search),
enabled: params.enabled ?? true,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
});
}
/** Admin: runs for any task regardless of owner. */
export function useAdminScheduledTaskRuns(taskId?: string | null) {
return useQuery({
queryKey: ["scheduled-tasks", "admin", taskId, "runs"],
queryFn: () => adminListScheduledTaskRuns(taskId!),
enabled: !!taskId,
refetchOnWindowFocus: false,
});
}
/** Admin mutations: edit / delete / trigger any task. */
export function useAdminScheduledTaskActions() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
return {
update: useMutation({
mutationFn: ({
taskId,
data,
}: {
taskId: string;
data: Parameters<typeof adminUpdateScheduledTask>[1];
}) => adminUpdateScheduledTask(taskId, data),
onSuccess: invalidate,
}),
remove: useMutation({
mutationFn: adminDeleteScheduledTask,
onSuccess: invalidate,
}),
// The DELETE /runs/{id} route is admin-aware, so the same call deletes
// any user's run when the caller is an admin.
removeRun: useMutation({
mutationFn: deleteScheduledTaskRun,
onSuccess: invalidate,
}),
trigger: useMutation({
mutationFn: adminTriggerScheduledTask,
onSuccess: invalidate,
}),
};
}
export function usePublishedScheduledTasks(enabled: boolean = true) {
return useQuery({
queryKey: ["scheduled-tasks", "published"],
@@ -94,6 +94,7 @@ export type ScheduledTaskRun = {
status: "pending" | "running" | "succeeded" | "failed" | string;
error?: string | null;
result?: ScheduledTaskRunResult | null;
retry_count?: number | null;
created_at?: string | null;
updated_at?: string | null;
can_edit?: boolean;
@@ -106,3 +107,19 @@ export type ScheduledTaskRunFileUpdateResponse = {
mime_type?: string | null;
kind?: string | null;
};
/** One agent message produced by a scheduled run's underlying agent run. */
export type ScheduledTaskRunMessage = {
seq: number;
event_type: string;
category: string;
// string for human input; object (LangChain message dump) for ai/tool.
content: unknown;
metadata?: Record<string, unknown> | null;
created_at?: string | null;
};
export type ScheduledTaskRunMessagesResponse = {
messages: ScheduledTaskRunMessage[];
has_more: boolean;
};
+137
View File
@@ -0,0 +1,137 @@
import type { Message } from "@langchain/langgraph-sdk";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
/**
* Share modes:
* - `import` — recipient (a logged-in user) copies the conversation into
* their own account and can keep chatting in the copy.
* - `view` — a public, read-only static page anyone can open without
* logging in; viewers cannot send new messages.
*/
export type ThreadShareMode = "import" | "view";
export interface ThreadShare {
share_code: string;
owner_user_id: string;
thread_id: string;
revoked: boolean;
mode: ThreadShareMode;
created_at: string | null;
}
/** Read-only conversation snapshot returned by the public view-share endpoint. */
export interface PublicShareSnapshot {
share_code: string;
title: string;
mode: string;
messages: Message[];
artifacts: string[];
created_at: string | null;
}
export interface SharePreview {
share_code: string;
shared_by: string;
title: string;
}
export interface ImportResult {
imported: number;
skipped: number;
}
const baseUrl = () => `${getBackendBaseURL()}/api/thread-shares`;
async function parseError(response: Response, fallback: string): Promise<Error> {
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
const detail = data.detail;
const message =
typeof detail === "string" && detail
? detail
: `${fallback} (HTTP ${response.status})`;
return new Error(message);
}
/** Create (or reuse) a share code for one conversation in the given mode. */
export async function createShare(
threadId: string,
mode: ThreadShareMode = "import",
): Promise<ThreadShare> {
const response = await apiFetch(baseUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ thread_id: threadId, mode }),
});
if (!response.ok) throw await parseError(response, "创建分享失败");
return response.json() as Promise<ThreadShare>;
}
/** List the current user's own share links. */
export async function listShares(): Promise<ThreadShare[]> {
const response = await apiFetch(baseUrl());
if (!response.ok) throw await parseError(response, "加载分享列表失败");
const json = (await response.json()) as { shares: ThreadShare[] };
return json.shares;
}
/** Revoke a share link so it can no longer be previewed or imported. */
export async function revokeShare(shareCode: string): Promise<void> {
const response = await apiFetch(`${baseUrl()}/${encodeURIComponent(shareCode)}`, {
method: "DELETE",
});
if (!response.ok) throw await parseError(response, "撤销分享失败");
}
/** Preview a share link before importing it. */
export async function previewShare(shareCode: string): Promise<SharePreview> {
const response = await apiFetch(
`${baseUrl()}/${encodeURIComponent(shareCode)}/preview`,
);
if (!response.ok) throw await parseError(response, "无法读取该分享");
return response.json() as Promise<SharePreview>;
}
/** Import every conversation behind a share link into the current account. */
export async function importShare(shareCode: string): Promise<ImportResult> {
const response = await apiFetch(
`${baseUrl()}/${encodeURIComponent(shareCode)}/import`,
{ method: "POST" },
);
if (!response.ok) throw await parseError(response, "导入失败");
return response.json() as Promise<ImportResult>;
}
/**
* Fetch the public read-only snapshot of a `view`-mode share.
*
* Uses a plain `fetch` (no auth header) — the `/api/public/` endpoint is
* intentionally world-readable so anyone with the link can open it.
*/
export async function getPublicShareSnapshot(
shareCode: string,
): Promise<PublicShareSnapshot> {
const response = await fetch(
`${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(shareCode)}`,
);
if (!response.ok) throw await parseError(response, "无法打开该分享");
return response.json() as Promise<PublicShareSnapshot>;
}
/** Build the shareable public URL for a `view`-mode share code. */
export function buildPublicShareUrl(shareCode: string): string {
// The app uses HashRouter, so the public viewer lives at `#/share/<code>`.
return `${window.location.origin}${window.location.pathname}#/share/${shareCode}`;
}
/**
* Extract a share code from arbitrary pasted text — accepts either a raw code
* or a URL/link that contains one. Share codes are 6-12 chars from the
* unambiguous alphabet (no 0/1/O/I/L).
*/
export function extractShareCode(input: string): string {
const trimmed = input.trim();
const matches = trimmed.toUpperCase().match(/[A-HJ-NP-Z2-9]{6,12}/g);
return matches && matches.length > 0 ? matches[matches.length - 1] : trimmed;
}
@@ -0,0 +1,61 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createShare,
importShare,
listShares,
previewShare,
revokeShare,
type ThreadShareMode,
} from "./api";
export function useShares(enabled = true) {
return useQuery({
queryKey: ["thread-shares"],
queryFn: listShares,
enabled,
});
}
export function useCreateShare() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
threadId,
mode = "import",
}: {
threadId: string;
mode?: ThreadShareMode;
}) => createShare(threadId, mode),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["thread-shares"] });
},
});
}
export function useRevokeShare() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (shareCode: string) => revokeShare(shareCode),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["thread-shares"] });
},
});
}
export function usePreviewShare() {
return useMutation({
mutationFn: (shareCode: string) => previewShare(shareCode),
});
}
export function useImportShare() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (shareCode: string) => importShare(shareCode),
onSuccess: () => {
// Imported conversations show up in the user's own thread list.
void queryClient.invalidateQueries({ queryKey: ["threads"] });
},
});
}
@@ -0,0 +1,491 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Download,
Edit3,
Eye,
Pause,
Play,
RefreshCw,
Search,
Trash2,
} from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import {
buildCron,
DEFAULT_SCHEDULE_CONFIG,
parseCron,
ScheduleBuilder,
type ScheduleConfig,
statusLabel,
statusVariant,
} from "@/components/workspace/scheduled-tasks/schedule-builder";
import {
downloadScheduledTaskRun,
type ScheduledTask,
useAdminScheduledTaskActions,
useAdminScheduledTaskRuns,
useAllScheduledTasks,
} from "@/core/scheduled-tasks";
import { cn } from "@/lib/utils";
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "暂无";
}
/** Render a creator-like identifier without the email domain part. */
function displayUsername(value?: string | null): string {
const raw = (value ?? "").trim();
if (!raw) return "";
const at = raw.indexOf("@");
return at === -1 ? raw : raw.slice(0, at);
}
const emptyForm = {
name: "",
timezone: "Asia/Shanghai",
prompt: "",
};
export default function AdminScheduledTasksPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// ?task= carries the task to re-select when returning from a run detail.
const initialTaskId = searchParams.get("task");
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const { data: tasks = [], isLoading } = useAllScheduledTasks({ search });
const actions = useAdminScheduledTaskActions();
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(
initialTaskId,
);
// One-shot scroll target after a back-navigation.
const pendingScrollRef = useRef<string | null>(initialTaskId);
const [editing, setEditing] = useState<ScheduledTask | null>(null);
const [form, setForm] = useState(emptyForm);
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>(
DEFAULT_SCHEDULE_CONFIG,
);
const sortedTasks = useMemo(
() =>
[...tasks].sort((a, b) =>
String(a.next_run_at ?? "").localeCompare(String(b.next_run_at ?? "")),
),
[tasks],
);
const selectedTask =
sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
const { data: runs = [], isLoading: runsLoading } =
useAdminScheduledTaskRuns(selectedTaskId);
useEffect(() => {
if (!selectedTaskId && sortedTasks[0]) {
setSelectedTaskId(sortedTasks[0].task_id);
}
if (
selectedTaskId &&
!sortedTasks.some((task) => task.task_id === selectedTaskId)
) {
setSelectedTaskId(sortedTasks[0]?.task_id ?? null);
}
}, [selectedTaskId, sortedTasks]);
const openEdit = (task: ScheduledTask) => {
setEditing(task);
setForm({
name: task.name,
timezone: task.timezone,
prompt: task.prompt,
});
setScheduleConfig(
parseCron(task.cron_expr) ??
parseCron(task.schedule_text) ??
DEFAULT_SCHEDULE_CONFIG,
);
};
const closeDialog = () => {
setEditing(null);
setForm(emptyForm);
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
};
const saveTask = async () => {
if (!editing) return;
try {
await actions.update.mutateAsync({
taskId: editing.task_id,
data: {
name: form.name,
schedule: buildCron(scheduleConfig),
timezone: form.timezone,
prompt: form.prompt,
},
});
toast.success("任务已更新");
closeDialog();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const toggleEnabled = async (task: ScheduledTask) => {
try {
await actions.update.mutateAsync({
taskId: task.task_id,
data: { enabled: !task.enabled },
});
toast.success(task.enabled ? "任务已暂停" : "任务已启用");
} catch (error) {
toast.error(error instanceof Error ? error.message : "操作失败");
}
};
const removeTask = async (task: ScheduledTask) => {
if (!window.confirm(`确认删除任务「${task.name}」?该操作不可撤销。`)) return;
try {
await actions.remove.mutateAsync(task.task_id);
toast.success("任务已删除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
const triggerTask = async (task: ScheduledTask) => {
try {
await actions.trigger.mutateAsync(task.task_id);
toast.success("已触发执行");
} catch (error) {
toast.error(error instanceof Error ? error.message : "触发失败");
}
};
const downloadRun = async (runId: string) => {
try {
await downloadScheduledTaskRun(runId);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
}
};
const deleteRun = async (runId: string) => {
if (!window.confirm("确认删除这条执行记录?该操作不可撤销。")) return;
try {
await actions.removeRun.mutateAsync(runId);
toast.success("执行记录已删除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="grid size-full min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[460px_minmax(0,1fr)]">
<Card className="flex min-h-0 flex-col">
<CardHeader className="gap-3">
<div>
<CardTitle>定时任务管理</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
管理员视图:查看、编辑、删除全部用户的定时任务
</p>
</div>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
setSearch(searchInput.trim());
}}
>
<Input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="按任务名称搜索"
/>
<Button type="submit" variant="outline">
<Search className="size-4" />
搜索
</Button>
</form>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
<div className="flex flex-col gap-3 p-4 pt-0">
{isLoading && (
<div className="text-muted-foreground text-sm">
正在加载任务...
</div>
)}
{!isLoading && sortedTasks.length === 0 && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
没有匹配的定时任务。
</div>
)}
{sortedTasks.map((task) => (
<button
key={task.task_id}
ref={(el) => {
if (el && pendingScrollRef.current === task.task_id) {
pendingScrollRef.current = null;
el.scrollIntoView({ block: "center" });
}
}}
className={cn(
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
selectedTaskId === task.task_id &&
"border-primary bg-accent",
)}
onClick={() => setSelectedTaskId(task.task_id)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">
{task.name}
</div>
<div className="text-muted-foreground mt-1 truncate text-xs">
{task.schedule_text} · {task.timezone}
</div>
</div>
<Badge variant={task.enabled ? "default" : "secondary"}>
{task.enabled ? "启用" : "暂停"}
</Badge>
</div>
<div className="text-muted-foreground mt-3 grid gap-1 text-xs">
<div>
创建者:
{displayUsername(task.creator_email) ||
task.creator_user_id ||
"未知"}
</div>
<div>下次执行:{formatDate(task.next_run_at)}</div>
<div>上次执行:{formatDate(task.last_run_at)}</div>
<div>最近状态:{statusLabel(task.last_status)}</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="icon"
variant="outline"
title="编辑"
onClick={(event) => {
event.stopPropagation();
openEdit(task);
}}
>
<Edit3 className="size-4" />
</Button>
<Button
size="icon"
variant="outline"
title={task.enabled ? "暂停" : "启用"}
onClick={(event) => {
event.stopPropagation();
void toggleEnabled(task);
}}
>
{task.enabled ? (
<Pause className="size-4" />
) : (
<Play className="size-4" />
)}
</Button>
<Button
size="icon"
variant="outline"
title="立即执行"
onClick={(event) => {
event.stopPropagation();
void triggerTask(task);
}}
>
<RefreshCw className="size-4" />
</Button>
<Button
size="icon"
variant="destructive"
title="删除"
onClick={(event) => {
event.stopPropagation();
void removeTask(task);
}}
>
<Trash2 className="size-4" />
</Button>
</div>
</button>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
<Card className="flex min-h-0 flex-col">
<CardHeader className="gap-2">
<CardTitle>执行记录</CardTitle>
<p className="text-muted-foreground text-sm">
{selectedTask ? selectedTask.name : "请选择左侧任务"}
</p>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
<div className="flex flex-col gap-3 p-4 pt-0">
{runsLoading && (
<div className="text-muted-foreground text-sm">
正在加载执行记录...
</div>
)}
{!selectedTask && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
请先选择一个定时任务。
</div>
)}
{!runsLoading && selectedTask && runs.length === 0 && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
这个任务还没有执行记录。
</div>
)}
{runs.map((run) => (
<div
key={run.id}
className="rounded-md border p-4 transition-colors hover:bg-accent/40"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">
{run.result?.title || formatDate(run.scheduled_for)}
</div>
<div className="text-muted-foreground mt-1 text-xs">
计划:{formatDate(run.scheduled_for)} · 开始:
{formatDate(run.started_at)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{(run.retry_count ?? 0) > 0 && (
<Badge variant="outline" className="text-xs">
已重试 {run.retry_count}
</Badge>
)}
<Badge variant={statusVariant(run.status)}>
{statusLabel(run.status)}
</Badge>
</div>
</div>
{run.error && (
<div className="text-destructive mt-3 line-clamp-2 text-sm">
{run.error}
</div>
)}
<div className="mt-4 flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() =>
navigate(
`/page/workspace/scheduled/runs/${run.id}?from=admin&task=${encodeURIComponent(run.task_id)}`,
)
}
>
<Eye className="size-4" />
查看结果
</Button>
<Button
size="sm"
variant="outline"
disabled={!run.result}
onClick={() => void downloadRun(run.id)}
>
<Download className="size-4" />
下载结果
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => void deleteRun(run.id)}
>
<Trash2 className="size-4" />
删除
</Button>
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</WorkspaceBody>
<Dialog open={!!editing} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>编辑定时任务</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
value={form.name}
onChange={(event) =>
setForm((value) => ({ ...value, name: event.target.value }))
}
placeholder="任务名称"
/>
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">执行频率</p>
<ScheduleBuilder
value={scheduleConfig}
onChange={setScheduleConfig}
/>
</div>
<Input
value={form.timezone}
onChange={(event) =>
setForm((value) => ({
...value,
timezone: event.target.value,
}))
}
placeholder="时区,例如:Asia/Shanghai"
/>
<Textarea
className="min-h-40"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder="任务 Prompt"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>
取消
</Button>
<Button onClick={saveTask}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</WorkspaceContainer>
);
}
+19 -1
View File
@@ -1,10 +1,13 @@
"use client";
import { DownloadIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
import {
WorkspaceBody,
WorkspaceContainer,
@@ -20,6 +23,7 @@ export default function ChatsPage() {
const { t } = useI18n();
const { data: threads } = useThreads();
const [search, setSearch] = useState("");
const [importOpen, setImportOpen] = useState(false);
useEffect(() => {
document.title = `${t.pages.chats} - ${t.pages.appName}`;
@@ -38,7 +42,17 @@ export default function ChatsPage() {
<WorkspaceHeader></WorkspaceHeader>
<WorkspaceBody>
<div className="flex size-full flex-col">
<header className="flex shrink-0 items-center justify-center pt-8">
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
<div className="flex w-full max-w-(--container-width-md) items-center justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setImportOpen(true)}
>
<DownloadIcon className="size-4" />
{t.threadShare.importChats}
</Button>
</div>
<Input
type="search"
className="h-12 w-full max-w-(--container-width-md) text-xl"
@@ -70,6 +84,10 @@ export default function ChatsPage() {
</main>
</div>
</WorkspaceBody>
<ImportConversationsDialog
open={importOpen}
onOpenChange={setImportOpen}
/>
</WorkspaceContainer>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { SubtasksProvider } from "@/core/tasks/context";
import { getPublicShareSnapshot } from "@/core/thread-shares/api";
import type { AgentThreadState } from "@/core/threads";
/**
* Public, read-only conversation viewer.
*
* Reachable at `#/share/<code>` with NO authentication — anyone with the link
* can open it. It renders a frozen snapshot of a `view`-mode shared
* conversation; there is no input box, so viewers cannot send new messages.
*/
export default function PublicSharePage() {
const { shareCode } = useParams<{ shareCode: string }>();
const { data, isLoading, isError } = useQuery({
queryKey: ["public-share", shareCode],
queryFn: () => getPublicShareSnapshot(shareCode!),
enabled: !!shareCode,
retry: false,
refetchOnWindowFocus: false,
});
// A minimal static stand-in for the live LangGraph stream. MessageList only
// reads `messages`, `values.artifacts`, `isLoading` and `isThreadLoading`.
const thread = useMemo(() => {
const messages = data?.messages ?? [];
return {
messages,
values: {
title: data?.title ?? "",
messages,
artifacts: data?.artifacts ?? [],
todos: [],
},
isLoading: false,
isThreadLoading: false,
error: undefined,
interrupt: undefined,
} as unknown as BaseStream<AgentThreadState>;
}, [data]);
if (isLoading) {
return (
<div className="bg-background text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载分享内容…
</div>
);
}
if (isError || !data) {
return (
<div className="bg-background flex h-screen flex-col items-center justify-center gap-2 px-6 text-center">
<p className="text-lg font-medium">分享不可用</p>
<p className="text-muted-foreground text-sm">
该分享链接不存在、已被撤销,或不是只读分享。
</p>
</div>
);
}
return (
<div className="bg-background flex h-screen flex-col">
<header className="flex shrink-0 flex-col gap-1 border-b px-6 py-3">
<div className="flex items-center gap-2">
<h1 className="truncate text-base font-semibold">
{data.title || "未命名对话"}
</h1>
<span className="bg-muted text-muted-foreground shrink-0 rounded-full px-2 py-0.5 text-xs">
只读分享
</span>
</div>
<p className="text-muted-foreground text-xs">
这是一个只读对话快照,你可以查看全部内容,但无法继续提问。
</p>
</header>
<main className="min-h-0 flex-1">
<SubtasksProvider>
<ArtifactsProvider>
<ThreadContext.Provider
value={{ thread, isMock: true, publicShareCode: shareCode }}
>
<MessageList
threadId={shareCode ?? "public-share"}
thread={thread}
paddingBottom={48}
/>
</ThreadContext.Provider>
</ArtifactsProvider>
</SubtasksProvider>
</main>
</div>
);
}
@@ -1,8 +1,8 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react";
import { useParams } from "react-router-dom";
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Loader2, Save, Trash2, X } from "lucide-react";
import { useParams, useSearchParams } from "react-router-dom";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
import { toast } from "sonner";
@@ -24,9 +24,11 @@ import {
getScheduledTaskRunFileContent,
type ScheduledTaskResultFileContent,
type ScheduledTaskRun,
type ScheduledTaskRunMessage,
updateScheduledTaskRunFile,
useScheduledTaskActions,
useScheduledTaskRun,
useScheduledTaskRunMessages,
} from "@/core/scheduled-tasks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { cn } from "@/lib/utils";
@@ -97,12 +99,117 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
type EditState = { draft: string; saving: boolean };
/** Flatten a LangChain-style message content (string | parts | nested) to text. */
function messageText(content: unknown): string {
if (content == null) return "";
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === "string") return part;
if (part && typeof part === "object" && "text" in part) {
return String((part as { text?: unknown }).text ?? "");
}
return "";
})
.filter(Boolean)
.join("\n");
}
if (typeof content === "object" && "content" in content) {
return messageText((content as { content?: unknown }).content);
}
return JSON.stringify(content, null, 2);
}
const EXECUTION_ROLE: Record<string, { label: string; tone: string }> = {
"llm.human.input": { label: "触发输入", tone: "bg-muted text-foreground" },
"llm.ai.response": { label: "智能体", tone: "bg-primary/10 text-primary" },
"llm.tool.result": { label: "工具结果", tone: "bg-amber-500/10 text-amber-600 dark:text-amber-400" },
};
/** One agent message rendered inside the execution-process tab. */
function ExecutionMessage({ message }: { message: ScheduledTaskRunMessage }) {
const role = EXECUTION_ROLE[message.event_type] ?? {
label: message.event_type,
tone: "bg-muted text-foreground",
};
const raw = message.content;
const obj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
const text = messageText(raw);
const toolName = typeof obj?.name === "string" ? obj.name : null;
const toolCalls = Array.isArray(obj?.tool_calls)
? (obj!.tool_calls as Array<Record<string, unknown>>)
: [];
const isAi = message.event_type === "llm.ai.response";
return (
<div className="rounded-md border">
<div className="flex items-center gap-2 border-b px-3 py-1.5">
<span className={cn("rounded px-1.5 py-0.5 text-xs font-medium", role.tone)}>
{role.label}
{toolName ? ` · ${toolName}` : ""}
</span>
{message.created_at && (
<span className="text-muted-foreground text-xs">
{new Date(message.created_at).toLocaleString()}
</span>
)}
</div>
<div className="p-3">
{text ? (
isAi ? (
<MarkdownContent className="max-w-none" content={text} isLoading={false} rehypePlugins={[]} />
) : (
<pre className="whitespace-pre-wrap break-words text-xs">{text}</pre>
)
) : (
!toolCalls.length && <div className="text-muted-foreground text-xs">(无文本内容)</div>
)}
{toolCalls.length > 0 && (
<div className="mt-2 flex flex-col gap-1">
{toolCalls.map((call, index) => (
<div key={index} className="bg-muted rounded px-2 py-1 text-xs">
<span className="font-medium">调用工具:{String(call.name ?? "未知")}</span>
{call.args != null && (
<pre className="mt-1 whitespace-pre-wrap break-words">
{typeof call.args === "string" ? call.args : JSON.stringify(call.args, null, 2)}
</pre>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
export default function ScheduledTaskRunDetailPage() {
const navigate = useNavigate();
const { run_id } = useParams();
const [searchParams] = useSearchParams();
const actions = useScheduledTaskActions();
const { data: run, isLoading } = useScheduledTaskRun(run_id);
const result = run?.result ?? null;
// A run still executing — drives live polling of its agent messages.
const runLive = run?.status === "running" || run?.status === "pending";
const hasExecution = !!run?.agent_run_id;
const { data: messagesData, isLoading: messagesLoading } = useScheduledTaskRunMessages(
hasExecution ? run_id : null,
{ live: runLive },
);
const runMessages = messagesData?.messages ?? [];
// Back navigation returns to the originating list (admin vs. mine) and
// re-selects the task the user came from (carried in ?task=).
const handleBack = useCallback(() => {
const base =
searchParams.get("from") === "admin"
? "/page/workspace/admin/scheduled-tasks"
: "/page/workspace/scheduled/tasks";
const taskId = run?.task_id ?? searchParams.get("task");
navigate(taskId ? `${base}?task=${encodeURIComponent(taskId)}` : base);
}, [navigate, run?.task_id, searchParams]);
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [showMeta, setShowMeta] = useState(false);
@@ -257,7 +364,7 @@ export default function ScheduledTaskRunDetailPage() {
try {
await actions.removeRun.mutateAsync(run.id);
toast.success("执行记录已删除");
navigate("/page/workspace/scheduled/tasks");
handleBack();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
@@ -281,9 +388,9 @@ export default function ScheduledTaskRunDetailPage() {
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
执行信息
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/page/workspace/scheduled/tasks")}>
<ListChecks className="size-3" />
返回记录
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleBack}>
<ChevronLeft className="size-3" />
返回
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
<Download className="size-3" />
@@ -330,28 +437,45 @@ export default function ScheduledTaskRunDetailPage() {
</div>
)}
{/* 内容区 */}
{!result && run && (
{run && !result && !hasExecution && (
<div className="text-muted-foreground p-4 text-sm">
这条记录暂时还没有结果,任务可能仍在执行中。
</div>
)}
{result && (
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
{run && (result || hasExecution) && (
<Tabs
defaultValue={result ? (fileCount > 0 ? "files" : "body") : "execution"}
className="flex min-h-0 flex-1 flex-col"
>
<div className="border-b px-3 pt-1">
<TabsList className="h-8">
<TabsTrigger value="body" className="text-xs">
<FileText className="mr-1 size-3" />
正文
</TabsTrigger>
<TabsTrigger value="files" className="text-xs">
结果文件{fileCount > 0 && `(${fileCount})`}
</TabsTrigger>
<TabsTrigger value="meta" className="text-xs">
元数据
</TabsTrigger>
{result && (
<TabsTrigger value="body" className="text-xs">
<FileText className="mr-1 size-3" />
正文
</TabsTrigger>
)}
{result && (
<TabsTrigger value="files" className="text-xs">
结果文件{fileCount > 0 && `(${fileCount})`}
</TabsTrigger>
)}
{hasExecution && (
<TabsTrigger value="execution" className="text-xs">
<Activity className="mr-1 size-3" />
执行过程{runMessages.length > 0 && `(${runMessages.length})`}
</TabsTrigger>
)}
{result && (
<TabsTrigger value="meta" className="text-xs">
元数据
</TabsTrigger>
)}
</TabsList>
</div>
<ScrollArea className="min-h-0 flex-1">
{result && (
<>
<TabsContent value="body" className="m-0 p-4">
{result.format === "markdown" || result.format === "mixed" ? (
<MarkdownContent
@@ -471,6 +595,31 @@ export default function ScheduledTaskRunDetailPage() {
{resultMetadata}
</pre>
</TabsContent>
</>
)}
{hasExecution && (
<TabsContent value="execution" className="m-0 p-3">
{runLive && (
<div className="text-primary border-primary/30 bg-primary/5 mb-3 flex items-center gap-2 rounded-md border p-2 text-xs">
<Loader2 className="size-3 animate-spin" />
任务执行中,下方消息每 4 秒自动刷新…
</div>
)}
{messagesLoading && runMessages.length === 0 && (
<div className="text-muted-foreground text-sm">正在加载执行消息...</div>
)}
{!messagesLoading && runMessages.length === 0 && (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
暂无执行消息,任务可能还未开始执行。
</div>
)}
<div className="flex flex-col gap-3">
{runMessages.map((message) => (
<ExecutionMessage key={message.seq} message={message} />
))}
</div>
</TabsContent>
)}
</ScrollArea>
</Tabs>
)}
+29 -216
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
BellOff,
BellRing,
@@ -18,6 +18,7 @@ import {
TagsIcon,
Trash2,
} from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
import { toast } from "sonner";
@@ -51,199 +52,17 @@ import {
useScheduledTasks,
usePublishedScheduledTasks,
} from "@/core/scheduled-tasks";
import {
buildCron,
DEFAULT_SCHEDULE_CONFIG,
parseCron,
ScheduleBuilder,
type ScheduleConfig,
statusLabel,
statusVariant,
} from "@/components/workspace/scheduled-tasks/schedule-builder";
import { cn } from "@/lib/utils";
// ---- 时间配置 ----
type ScheduleFreq = "hourly" | "daily" | "weekly" | "monthly";
interface ScheduleConfig {
freq: ScheduleFreq;
minute: number;
hour: number;
weekday: number;
date: number;
}
const DEFAULT_SCHEDULE_CONFIG: ScheduleConfig = {
freq: "daily",
minute: 0,
hour: 9,
weekday: 1,
date: 1,
};
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const MINUTES = [0, 15, 30, 45];
const DATES = Array.from({ length: 31 }, (_, i) => i + 1);
function buildCron(cfg: ScheduleConfig): string {
// Keep the minute slot when the user picked one for daily/weekly/monthly too,
// so "每天 9:30" doesn't silently truncate to 9:00 on save → reload round trips.
const m = Number.isFinite(cfg.minute) ? cfg.minute : 0;
switch (cfg.freq) {
case "hourly":
return `${m} * * * *`;
case "daily":
return `${m} ${cfg.hour} * * *`;
case "weekly":
return `${m} ${cfg.hour} * * ${cfg.weekday}`;
case "monthly":
return `${m} ${cfg.hour} ${cfg.date} * *`;
}
}
// Parse a 5-field cron expression into the form picker state.
//
// Bug we keep getting bitten by: the edit dialog used to call
// `parseCron(task.schedule_text)` and `schedule_text` may be the raw user
// input (free-text like "每天 9 点") rather than a real cron string. The
// fallback `DEFAULT_SCHEDULE_CONFIG` then made the picker silently
// reinterpret the task as "every day at 09:00" — or, when natural-language
// resolved to "0 * * * *" on the backend, the picker honestly reflected
// that as "每小时 0 分". Either way the user lost their original intent.
//
// Caller now prefers `task.cron_expr` (the canonical schedule produced by
// `_schedule_to_cron`); this function stays the single source of truth for
// cron → picker, and also handles the looser step-syntax variants
// (e.g. "0 [STAR]/3 * * *") we sometimes generate.
function parseCron(cron: string | null | undefined): ScheduleConfig | null {
if (!cron) return null;
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [min, hr, date, , wd] = parts;
const minute = parseInt(min ?? "", 10);
const hour = parseInt(hr ?? "", 10);
// Hourly: ``M * * * *`` — match before the daily branch so a minute-only
// cron isn't misread as daily.
if (hr === "*" && date === "*" && wd === "*") {
if (!isNaN(minute)) {
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
}
}
// ``0 */N * * *`` (every N hours): map to hourly with that minute; the
// picker doesn't model "every N hours" yet, so this is the safest fallback
// that at least doesn't silently round to 9:00.
if ((hr ?? "").startsWith("*/") && date === "*" && wd === "*") {
if (!isNaN(minute)) {
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
}
}
if (date === "*" && wd === "*") {
if (!isNaN(hour)) {
return {
...DEFAULT_SCHEDULE_CONFIG,
freq: "daily",
hour,
minute: isNaN(minute) ? 0 : minute,
};
}
}
if (date === "*" && wd !== "*") {
const weekday = parseInt(wd ?? "", 10);
if (!isNaN(hour) && !isNaN(weekday)) {
return {
...DEFAULT_SCHEDULE_CONFIG,
freq: "weekly",
hour,
weekday,
minute: isNaN(minute) ? 0 : minute,
};
}
}
if (wd === "*" && date !== "*") {
const d = parseInt(date ?? "", 10);
if (!isNaN(hour) && !isNaN(d)) {
return {
...DEFAULT_SCHEDULE_CONFIG,
freq: "monthly",
hour,
date: d,
minute: isNaN(minute) ? 0 : minute,
};
}
}
return null;
}
function ScheduleBuilder({
value,
onChange,
}: {
value: ScheduleConfig;
onChange: (cfg: ScheduleConfig) => void;
}) {
const set = (patch: Partial<ScheduleConfig>) => onChange({ ...value, ...patch });
return (
<div className="flex flex-wrap gap-2">
<Select value={value.freq} onValueChange={(v) => set({ freq: v as ScheduleFreq })}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">每小时</SelectItem>
<SelectItem value="daily">每天</SelectItem>
<SelectItem value="weekly">每周</SelectItem>
<SelectItem value="monthly">每月</SelectItem>
</SelectContent>
</Select>
{value.freq === "hourly" && (
<Select value={String(value.minute)} onValueChange={(v) => set({ minute: parseInt(v) })}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m} value={String(m)}>{m} 分</SelectItem>
))}
</SelectContent>
</Select>
)}
{value.freq === "weekly" && (
<Select value={String(value.weekday)} onValueChange={(v) => set({ weekday: parseInt(v) })}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WEEKDAYS.map((d, i) => (
<SelectItem key={i} value={String(i)}>{d}</SelectItem>
))}
</SelectContent>
</Select>
)}
{value.freq === "monthly" && (
<Select value={String(value.date)} onValueChange={(v) => set({ date: parseInt(v) })}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATES.map((d) => (
<SelectItem key={d} value={String(d)}>第 {d} 日</SelectItem>
))}
</SelectContent>
</Select>
)}
{value.freq !== "hourly" && (
<Select value={String(value.hour)} onValueChange={(v) => set({ hour: parseInt(v) })}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOURS.map((h) => (
<SelectItem key={h} value={String(h)}>{h} 时</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
}
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "暂无";
}
@@ -256,28 +75,6 @@ function displayUsername(value?: string | null): string {
return at === -1 ? raw : raw.slice(0, at);
}
function statusLabel(status?: string | null) {
switch (status) {
case "succeeded":
return "成功";
case "failed":
return "失败";
case "running":
return "执行中";
case "pending":
return "等待中";
default:
return status || "未知";
}
}
function statusVariant(status?: string | null): "default" | "secondary" | "destructive" | "outline" {
if (status === "succeeded") return "default";
if (status === "failed") return "destructive";
if (status === "running") return "secondary";
return "outline";
}
const emptyForm = {
name: "",
schedule: "",
@@ -288,6 +85,10 @@ const emptyForm = {
export default function ScheduledTasksPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// When returning from a run-detail page the task id is carried in ?task=,
// so the list re-selects (and scrolls to) the task the user was viewing.
const initialTaskId = searchParams.get("task");
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
@@ -298,7 +99,9 @@ export default function ScheduledTasksPage() {
const [activeTab, setActiveTab] = useState<"mine" | "plaza">("mine");
const { data: published = [], isLoading: publishedLoading } =
usePublishedScheduledTasks(activeTab === "plaza");
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(initialTaskId);
// One-shot scroll target: scroll the task into view after a back-navigation.
const pendingScrollRef = useRef<string | null>(initialTaskId);
const [elementUserId, setElementUserId] = useState("");
const [editing, setEditing] = useState<ScheduledTask | null>(null);
const [creating, setCreating] = useState(false);
@@ -504,6 +307,12 @@ export default function ScheduledTasksPage() {
return (
<button
key={task.task_id}
ref={(el) => {
if (el && pendingScrollRef.current === task.task_id) {
pendingScrollRef.current = null;
el.scrollIntoView({ block: "center" });
}
}}
className={cn(
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
selectedTaskId === task.task_id && "border-primary bg-accent",
@@ -802,7 +611,11 @@ export default function ScheduledTasksPage() {
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/page/workspace/scheduled/runs/${run.id}`)}
onClick={() =>
navigate(
`/page/workspace/scheduled/runs/${run.id}?from=mine&task=${encodeURIComponent(run.task_id)}`,
)
}
>
<Eye className="size-4" />
查看结果
@@ -18,6 +18,7 @@ import ChatPage from "./ChatPage";
import AgentsPage from "./AgentsPage";
import AgentChatPage from "./AgentChatPage";
import NewAgentPage from "./NewAgentPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import LlmMetricsPage from "./LlmMetricsPage";
@@ -184,6 +185,18 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminScheduledTasksPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={