feat: 定时任务管理 + 会话只读分享
定时任务: - 管理员可查看/编辑/删除/触发全部用户的定时任务(新增 /api/scheduled-tasks/admin/* 接口与管理页) - 卡死任务恢复:单次执行超 30 分钟超时重试(最多 3 次),耗尽后标记 failed;看门狗回收服务重启遗留的「执行中」任务 会话分享: - 新增「只读链接」分享模式,生成公开免登录的只读对话快照页(/share/:code) - 公开接口 /api/public/shares/* 提供对话快照与图片附件 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4761677676
commit
e0c1cb6581
45 files changed
+3235
-434
No files matched your search
@@ -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,
|
||||
@@ -37,9 +40,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,
|
||||
@@ -101,6 +128,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}]`);
|
||||
@@ -109,7 +137,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">
|
||||
@@ -133,6 +161,7 @@ function MessageContent_({
|
||||
}) {
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
const isHuman = message.type === "human";
|
||||
const resolveArtifact = useArtifactResolver(threadId);
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
@@ -140,7 +169,7 @@ function MessageContent_({
|
||||
),
|
||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (href?.startsWith("/mnt/")) {
|
||||
const url = resolveArtifactURL(href, threadId);
|
||||
const url = resolveArtifact(href);
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
@@ -153,7 +182,7 @@ function MessageContent_({
|
||||
return <a {...props} href={href} />;
|
||||
},
|
||||
}),
|
||||
[threadId],
|
||||
[threadId, resolveArtifact],
|
||||
);
|
||||
|
||||
const rawContent = extractContentFromMessage(message);
|
||||
@@ -346,6 +375,7 @@ function RichFileCard({
|
||||
threadId: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const resolveArtifact = useArtifactResolver(threadId);
|
||||
const isUploading = file.status === "uploading";
|
||||
const isImage = isImageFile(file.filename);
|
||||
|
||||
@@ -378,7 +408,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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user