This commit is contained in:
xuhd committed 2026-05-16 15:51:49 +08:00
commit 48ddbf0412
423 files changed
+117115 -3214

No files matched your search

@@ -0,0 +1,293 @@
"use client";
import { Loader2, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { SettingsSection } from "@/components/workspace/settings/settings-section";
import { TagChip } from "@/components/workspace/tags/tag-chip";
import { TAG_COLORS } from "@/components/workspace/tags/tag-colors";
import { useI18n } from "@/core/i18n/hooks";
import {
useCreateTag,
useDeleteTag,
useTags,
useUpdateTag,
type Tag,
type TagType,
} from "@/core/tags";
import { cn } from "@/lib/utils";
function TagFormDialog({
open,
onOpenChange,
tagType,
editing,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
tagType: TagType;
editing: Tag | null;
}) {
const { t } = useI18n();
const createTag = useCreateTag();
const updateTag = useUpdateTag();
const [name, setName] = useState("");
const [color, setColor] = useState("gray");
const [description, setDescription] = useState("");
// Sync form state whenever the dialog (re)opens.
const [syncedFor, setSyncedFor] = useState<string | null>(null);
const syncKey = `${open}:${editing?.id ?? "new"}`;
if (open && syncedFor !== syncKey) {
setName(editing?.name ?? "");
setColor(editing?.color ?? "gray");
setDescription(editing?.description ?? "");
setSyncedFor(syncKey);
}
if (!open && syncedFor !== null) setSyncedFor(null);
const pending = createTag.isPending || updateTag.isPending;
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) return;
try {
if (editing) {
await updateTag.mutateAsync({
id: editing.id,
input: { name: trimmed, color, description },
});
} else {
await createTag.mutateAsync({
name: trimmed,
tag_type: tagType,
color,
description,
});
}
onOpenChange(false);
} catch (e) {
toast.error(String(e instanceof Error ? e.message : e));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editing ? t.adminTags.editTitle : t.adminTags.createTitle}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{t.adminTags.name}</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.adminTags.namePlaceholder}
maxLength={256}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{t.adminTags.color}</label>
<div className="flex flex-wrap gap-2">
{TAG_COLORS.map((c) => (
<button
key={c.name}
type="button"
onClick={() => setColor(c.name)}
aria-label={c.name}
className={cn(
"size-6 rounded-full border-2",
c.swatch,
color === c.name
? "border-foreground"
: "border-transparent",
)}
/>
))}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{t.adminTags.tagDescription}
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t.adminTags.descriptionPlaceholder}
maxLength={1024}
rows={2}
/>
</div>
{name.trim() && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">Preview:</span>
<TagChip tag={{ name: name.trim(), color }} />
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t.adminTags.cancel}
</Button>
<Button onClick={handleSave} disabled={pending || !name.trim()}>
{pending && <Loader2 className="size-4 animate-spin" />}
{t.adminTags.save}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function TagManagementPage() {
const { t } = useI18n();
const [activeType, setActiveType] = useState<TagType>("agent");
const { data: tags, isLoading, error } = useTags(activeType);
const deleteTag = useDeleteTag();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Tag | null>(null);
const openCreate = () => {
setEditing(null);
setDialogOpen(true);
};
const openEdit = (tag: Tag) => {
setEditing(tag);
setDialogOpen(true);
};
const handleDelete = async (tag: Tag) => {
if (!window.confirm(t.adminTags.deleteConfirm)) return;
try {
await deleteTag.mutateAsync(tag.id);
} catch (e) {
toast.error(String(e instanceof Error ? e.message : e));
}
};
return (
<div className="flex flex-col gap-6 p-6">
<SettingsSection
title={t.adminTags.title}
description={t.adminTags.description}
>
<div className="flex items-center justify-between gap-4">
<Tabs
value={activeType}
onValueChange={(v) => setActiveType(v as TagType)}
>
<TabsList variant="line">
<TabsTrigger value="agent">{t.adminTags.typeAgent}</TabsTrigger>
<TabsTrigger value="skill">{t.adminTags.typeSkill}</TabsTrigger>
<TabsTrigger value="scheduled_task">
{t.adminTags.typeScheduledTask}
</TabsTrigger>
</TabsList>
</Tabs>
<Button size="sm" onClick={openCreate}>
<PlusIcon className="size-4" />
{t.adminTags.createButton}
</Button>
</div>
<div className="mt-4">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8">
<Loader2 className="size-4 animate-spin" />
<span>{t.common.loading}</span>
</div>
)}
{error && (
<div className="text-destructive py-4 text-sm">{String(error)}</div>
)}
{!isLoading && !error && tags && tags.length === 0 && (
<div className="text-muted-foreground py-8 text-center text-sm">
{t.adminTags.empty}
</div>
)}
{!isLoading && !error && tags && tags.length > 0 && (
<div className="overflow-hidden rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
{t.adminTags.name}
</th>
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
{t.adminTags.tagDescription}
</th>
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
{t.adminTags.usageCount}
</th>
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
{t.adminTags.actions}
</th>
</tr>
</thead>
<tbody>
{tags.map((tag, i) => (
<tr
key={tag.id}
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
>
<td className="px-4 py-3">
<TagChip tag={tag} />
</td>
<td className="text-muted-foreground px-4 py-3">
{tag.description || "—"}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{tag.usage_count}
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(tag)}
aria-label={t.adminTags.edit}
>
<PencilIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(tag)}
aria-label={t.adminTags.delete}
>
<Trash2Icon className="text-destructive size-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</SettingsSection>
<TagFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
tagType={activeType}
editing={editing}
/>
</div>
);
}
@@ -6,6 +6,7 @@ import {
EyeOffIcon,
MessageSquareIcon,
SquarePenIcon,
TagsIcon,
Trash2Icon,
} from "lucide-react";
import { useRouter } from "next/navigation";
@@ -33,6 +34,8 @@ import {
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
@@ -51,6 +54,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false);
const [editName, setEditName] = useState(agent.name);
const [editDescription, setEditDescription] = useState(agent.description ?? "");
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
@@ -212,6 +216,12 @@ export function AgentCard({ agent }: AgentCardProps) {
)}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-2">
<TagChipList tags={agent.tags!} />
</CardContent>
)}
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-3">
<div className="flex flex-wrap gap-1">
@@ -242,8 +252,19 @@ export function AgentCard({ agent }: AgentCardProps) {
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat}
</Button>
{(canEdit || canDelete || isOwned) && (
{(canEdit || canDelete || isOwned || isAdmin) && (
<div className="flex gap-1">
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
)}
{isOwned && (
<Button
size="icon"
@@ -414,6 +435,16 @@ export function AgentCard({ agent }: AgentCardProps) {
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && (
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
)}
</>
);
}
@@ -6,6 +6,7 @@ import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
@@ -14,9 +15,12 @@ import { AgentCard } from "./agent-card";
export function AgentGallery() {
const { t } = useI18n();
const { agents, isLoading } = useAgents();
const router = useRouter();
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/agents query.
const { agents, isLoading } = useAgents({ search, tagId });
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -83,10 +87,19 @@ export function AgentGallery() {
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
className="flex flex-1 flex-col overflow-hidden"
>
<TabsList className="mx-6 mt-4 w-auto self-start">
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value="all">广场</TabsTrigger>
</TabsList>
<div className="mx-6 mt-4 flex items-center justify-between gap-4">
<TabsList className="w-auto self-start">
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value="all">广场</TabsTrigger>
</TabsList>
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
className="w-[420px] max-w-[50%]"
/>
</div>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
{isLoading ? (
@@ -2,17 +2,16 @@
import {
ArrowDownIcon,
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
PencilIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
Code2Icon,
CopyIcon,
DownloadIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
SaveIcon,
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
@@ -34,16 +33,16 @@ import {
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api";
import {
downloadArtifactFile,
openArtifactInNewTab,
} from "@/core/artifacts/download";
import { useArtifactContent } from "@/core/artifacts/hooks";
import {
downloadArtifactFile,
openArtifactInNewTab,
} from "@/core/artifacts/download";
import {
useArtifactContent,
useUpdateArtifactContent,
} from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
@@ -118,33 +117,19 @@ export function ArtifactFileDetail({
const isSupportPreview = useMemo(() => {
return language === "html" || language === "markdown";
}, [language]);
const { content } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
const queryClient = useQueryClient();
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
const [isInstalling, setIsInstalling] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(null);
const [editedContent, setEditedContent] = useState<string | null>(null);
// Tracks last manually-saved content for write-file: artifacts (whose displayContent
// always reflects the original tool-call output, not the on-disk file after save).
const [savedContent, setSavedContent] = useState<string | null>(null);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
const displayContent = (isWriteFile && savedContent !== null ? savedContent : null) ?? content ?? "";
const { content, isApplyingEdit } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
const canEditArtifact =
language === "markdown" && !isWriteFile && !isSkillFile && !isMock;
const [draftContent, setDraftContent] = useState("");
const [hasLocalEdits, setHasLocalEdits] = useState(false);
const updateArtifact = useUpdateArtifactContent();
const displayContent = canEditArtifact ? draftContent : (content ?? "");
const isHtmlStreaming = language === "html" && thread.isLoading;
const canEdit = isCodeFile && !isSkillFile && !thread.isLoading;
const activeContent = editedContent ?? displayContent;
const isDirty = editedContent !== null && editedContent !== displayContent;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (language === "html") return "HTML";
@@ -154,12 +139,34 @@ export function ArtifactFileDetail({
return "FILE";
}, [isSkillFile, language]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
const [isInstalling, setIsInstalling] = useState(false);
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null);
useEffect(() => {
setDraftContent(content ?? "");
setHasLocalEdits(false);
}, [filepathFromProps]);
useEffect(() => {
if (!canEditArtifact || hasLocalEdits) {
return;
}
setDraftContent(content ?? "");
}, [canEditArtifact, content, hasLocalEdits]);
useEffect(() => {
setAllowAutoViewSwitch(true);
setAutoScrollEnabled(true);
setShowResumeAutoScroll(false);
setEditedContent(null);
setSavedContent(null);
if (language === "html") {
setViewMode(thread.isLoading ? "code" : "preview");
@@ -322,41 +329,7 @@ export function ArtifactFileDetail({
}, 1600);
}, [scrollArtifactToBottom]);
const handleSave = useCallback(async () => {
if (!isDirty || isSaving) return;
setIsSaving(true);
try {
const pathWithoutLeadingSlash = filepath.replace(/^\//, "");
const res = await apiFetch(
`${getBackendBaseURL()}/api/threads/${threadId}/artifacts/${pathWithoutLeadingSlash}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: activeContent }),
},
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail ?? "保存失败");
}
if (isWriteFile) {
setSavedContent(activeContent);
} else {
await queryClient.invalidateQueries({
queryKey: ["artifact", filepathFromProps, threadId],
});
}
setEditedContent(null);
toast.success("已保存");
} catch (error) {
console.error("Save artifact failed:", error);
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setIsSaving(false);
}
}, [isDirty, isSaving, filepath, threadId, activeContent, queryClient, filepathFromProps, isWriteFile]);
const handleInstallSkill = useCallback(async () => {
const handleInstallSkill = useCallback(async () => {
if (isInstalling) return;
setIsInstalling(true);
@@ -376,7 +349,33 @@ export function ArtifactFileDetail({
} finally {
setIsInstalling(false);
}
}, [threadId, filepath, isInstalling]);
}, [threadId, filepath, isInstalling]);
const handleSaveArtifact = useCallback(async () => {
if (!canEditArtifact || updateArtifact.isPending) {
return;
}
try {
await updateArtifact.mutateAsync({
filepath,
threadId,
isMock,
content: draftContent,
});
setHasLocalEdits(false);
toast.success("Markdown 已保存");
} catch (error) {
console.error("Failed to save artifact:", error);
toast.error(error instanceof Error ? error.message : "Markdown 保存失败");
}
}, [
canEditArtifact,
draftContent,
filepath,
isMock,
threadId,
updateArtifact,
]);
return (
<Artifact className={cn(className)}>
@@ -453,30 +452,19 @@ export function ArtifactFileDetail({
</div>
)}
</div>
<div className="flex items-center justify-end gap-2">
{canEdit && viewMode === "code" && !isDirty && (
<span className="text-muted-foreground bg-muted/65 inline-flex items-center gap-1.5 rounded-full border border-black/6 px-2.5 py-1 text-[11px] dark:border-white/8">
<PencilIcon className="size-3" />
可编辑
</span>
)}
{canEdit && viewMode === "code" && isDirty && (
<span className="text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 inline-flex items-center rounded-full border border-amber-200 dark:border-amber-800 px-2.5 py-1 text-[11px]">
未保存
</span>
)}
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
{canEdit && viewMode === "code" && (
<ArtifactAction
icon={isSaving ? LoaderIcon : SaveIcon}
label="保存"
tooltip={isDirty ? "保存修改 (Ctrl+S)" : "无修改"}
disabled={isSaving || !isDirty}
onClick={handleSave}
/>
)}
{!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<div className="flex items-center justify-end gap-2">
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
{canEditArtifact && (
<ArtifactAction
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
label="保存 Markdown"
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
disabled={!hasLocalEdits || updateArtifact.isPending}
onClick={handleSaveArtifact}
/>
)}
{!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon}
label={t.common.install}
@@ -562,13 +550,19 @@ export function ArtifactFileDetail({
</ArtifactActions>
</div>
</ArtifactHeader>
<ArtifactContent
ref={artifactContentRef}
className="relative p-0"
onWheelCapture={handlePauseAutoScroll}
>
{showResumeAutoScroll && (
<button
<ArtifactContent
ref={artifactContentRef}
className="relative p-0"
onWheelCapture={handlePauseAutoScroll}
>
{isApplyingEdit && (
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
<span className="h-3.5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
正在改写 Markdown
</div>
)}
{showResumeAutoScroll && (
<button
type="button"
onClick={handleResumeAutoScroll}
className="animate-[sandbox-scroll-button-in_220ms_ease-out] absolute inset-x-0 top-1/2 z-20 mx-auto flex w-fit -translate-y-1/2 items-center gap-2 rounded-full border border-white/35 bg-black/28 px-4 py-2 text-sm font-medium text-white shadow-[0_20px_40px_-24px_rgba(15,23,42,0.7)] backdrop-blur-md transition hover:bg-black/40"
@@ -580,30 +574,31 @@ export function ArtifactFileDetail({
{isSupportPreview &&
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
<ArtifactFilePreview
content={activeContent}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
/>
)}
<ArtifactFilePreview
content={displayContent}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit}
/>
)}
{isCodeFile && viewMode === "code" && (
<div
className="size-full"
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
handleSave();
}
}}
>
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={activeContent}
readonly={!canEdit}
onChange={canEdit ? (v) => setEditedContent(v) : undefined}
/>
</div>
)}
<div className="size-full">
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={displayContent ?? ""}
readonly={!canEditArtifact}
onChange={
canEditArtifact
? (value) => {
setDraftContent(value);
setHasLocalEdits(true);
}
: undefined
}
streamingReadonlyFallback={!canEditArtifact}
/>
</div>
)}
{!isCodeFile &&
(binaryPreviewUrl ? (
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
@@ -618,15 +613,17 @@ export function ArtifactFileDetail({
);
}
export function ArtifactFilePreview({
content,
language,
animated = false,
}: {
content: string;
language: string;
animated?: boolean;
}) {
export function ArtifactFilePreview({
content,
language,
animated = false,
activeCursor = false,
}: {
content: string;
language: string;
animated?: boolean;
activeCursor?: boolean;
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
useEffect(() => {
@@ -657,12 +654,15 @@ export function ArtifactFilePreview({
className="size-full"
{...streamdownPlugins}
components={{ a: ArtifactLink }}
>
{content ?? ""}
</Streamdown>
</div>
);
}
>
{content ?? ""}
</Streamdown>
{activeCursor && (
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
)}
</div>
);
}
if (language === "html") {
return (
@@ -38,20 +38,22 @@ export function CodeEditor({
className,
placeholder,
value,
onChange,
readonly,
disabled,
autoFocus,
onChange,
settings,
streamingReadonlyFallback = true,
}: {
className?: string;
placeholder?: string;
value: string;
onChange?: (value: string) => void;
readonly?: boolean;
disabled?: boolean;
autoFocus?: boolean;
onChange?: (value: string) => void;
settings?: unknown;
streamingReadonlyFallback?: boolean;
}) {
const {
thread: { isLoading },
@@ -79,7 +81,7 @@ export function CodeEditor({
className,
)}
>
{isLoading ? (
{isLoading && streamingReadonlyFallback ? (
<Textarea
className={cn(
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
@@ -3,6 +3,7 @@
import type { ChatStatus } from "ai";
import {
CheckIcon,
FileTextIcon,
GraduationCapIcon,
LightbulbIcon,
PaperclipIcon,
@@ -129,6 +130,7 @@ export function InputBox({
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
@@ -141,6 +143,7 @@ export function InputBox({
> & {
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
},
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
@@ -263,6 +266,13 @@ export function InputBox({
[onContextChange, context],
);
const handleWritingModeToggle = useCallback(() => {
onContextChange?.({
...context,
writing_mode: !context.writing_mode,
});
}, [context, onContextChange]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (status === "streaming") {
@@ -541,6 +551,26 @@ export function InputBox({
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700" />
<Tooltip
content={
context.writing_mode
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
: "开启写作模式:最终输出保存为 Markdown 文件并进入画布"
}
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full border px-3! shadow-none",
context.writing_mode
? "border-[#d8b878] bg-[#fff3cf] text-[#6d5625] hover:bg-[#ffedbd]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white",
)}
onClick={handleWritingModeToggle}
>
<FileTextIcon className="size-3" />
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>
<PromptInputActionMenu>
<ModeHoverGuide
mode={
@@ -329,41 +329,43 @@ function ToolCall({
)}
</ChainOfThoughtStep>
);
} else if (name === "write_file" || name === "str_replace") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = t.toolCalls.writeFile;
}
const path: string | undefined = (args as { path: string })?.path;
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
setTimeout(() => {
const url = new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString();
if (selectedArtifact === url) {
return;
}
select(url, true);
setOpen(true);
}, 100);
}
} else if (name === "write_file" || name === "str_replace") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = t.toolCalls.writeFile;
}
const path: string | undefined = (args as { path: string })?.path;
const artifactPath =
name === "write_file" && path
? new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString()
: path;
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
setTimeout(() => {
if (!artifactPath || selectedArtifact === artifactPath) {
return;
}
select(artifactPath, true);
setOpen(true);
}, 100);
}
return (
<ChainOfThoughtStep
key={id}
className="cursor-pointer"
label={description}
icon={NotebookPenIcon}
onClick={() => {
select(
new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString(),
);
setOpen(true);
}}
>
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath) {
return;
}
select(artifactPath);
setOpen(true);
}}
>
{path && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
@@ -358,9 +358,9 @@ function SetupAgentApprovalCard({
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">
创建专家审批
创建智能体审批
</div>
<div className="text-sm font-medium">请确认是否写入这个专家配置。</div>
<div className="text-sm font-medium">请确认是否写入这个智能体配置。</div>
</div>
{approvalPayload.name ? (
@@ -12,6 +12,7 @@ import {
PinIcon,
PinOffIcon,
SparklesIcon,
TagsIcon,
Trash2Icon,
TrendingUpIcon,
Upload,
@@ -46,6 +47,9 @@ import {
ItemDescription,
} from "@/components/ui/item";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
@@ -67,36 +71,27 @@ import { SkillStateSheet } from "./skill-state-sheet";
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
const { t } = useI18n();
const { skills, isLoading, error } = useSkills();
return (
<SettingsSection
title={t.settings.skills.title}
description={t.settings.skills.description}
>
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div>Error: {error.message}</div>
) : (
<SkillSettingsList skills={skills} onClose={onClose} />
)}
<SkillSettingsList onClose={onClose} />
</SettingsSection>
);
}
function SkillSettingsList({
skills,
onClose,
}: {
skills: Skill[];
onClose?: () => void;
}) {
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const { t } = useI18n();
const router = useRouter();
const [filter, setFilter] = useState<string>("mine");
const [installOpen, setInstallOpen] = useState(false);
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagId });
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -210,10 +205,22 @@ function SkillSettingsList({
</Button>
</div>
</header>
{filteredSkills.length === 0 && (
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
className="max-w-xl"
/>
{isLoading && (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
)}
{error && <div className="text-destructive text-sm">Error: {error.message}</div>}
{!isLoading && !error && filteredSkills.length === 0 && (
<EmptySkill onCreateSkill={handleCreateSkill} />
)}
{filteredSkills.length > 0 &&
{!isLoading && !error &&
filteredSkills.length > 0 &&
filteredSkills.map((skill) => (
<SkillRow
key={skill.name}
@@ -257,6 +264,7 @@ function SkillRow({
const setPinned = useSetSkillPinned();
const deleteSkill = useDeleteCustomSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const isCustom = skill.category === "custom";
@@ -395,8 +403,22 @@ function SkillRow({
: t.settings.skills.useCountInlineZero}
</div>
)}
{(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="mt-2" />
)}
</ItemContent>
<ItemActions>
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="size-3.5" />
</Button>
)}
{canManage && (
<Button
size="icon"
@@ -476,6 +498,15 @@ function SkillRow({
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && (
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="skill"
targetId={skill.name}
currentTags={skill.tags ?? []}
/>
)}
<SkillDetailDialog
skill={skill}
open={detailOpen}
@@ -21,6 +21,13 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
useCuratorConfig,
@@ -31,6 +38,7 @@ import {
} from "@/core/curator/hooks";
import type { CuratorConfig } from "@/core/curator/types";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { cn } from "@/lib/utils";
export function SkillEvolutionPanel() {
@@ -68,6 +76,7 @@ function StatusCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.status;
const { status, isLoading } = useCuratorStatus();
const { config } = useCuratorConfig();
const runCurator = useRunCurator();
const lastRunDisplay = useMemo(() => {
@@ -79,10 +88,25 @@ function StatusCard() {
}
}, [status?.last_run_at, tt.lastRunNever]);
// 总开关或 curator 子开关任一关闭时,手动触发跑不起来 —— 直接禁用按钮。
const runDisabled = !config || !config.enabled || !config.curator.enabled;
const handleRun = async () => {
try {
await runCurator.mutateAsync();
toast.success(tt.runStarted);
const res = await runCurator.mutateAsync();
if (res.triggered) {
toast.success(tt.runStarted);
} else {
// 后端预检拦下了(开关未开 / 已有任务在跑)—— 给出对应原因,而不是误报"已开始"。
const reasonMsg: Record<string, string> = {
evolution_disabled: tt.runBlockedEvolutionDisabled,
curator_disabled: tt.runBlockedCuratorDisabled,
already_running: tt.runBlockedAlreadyRunning,
};
toast.warning(
reasonMsg[res.reason ?? ""] ?? tt.runBlockedEvolutionDisabled,
);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
@@ -107,7 +131,8 @@ function StatusCard() {
<Button
size="sm"
onClick={handleRun}
disabled={runCurator.isPending}
disabled={runCurator.isPending || runDisabled}
title={runDisabled ? tt.runDisabledHint : undefined}
>
{runCurator.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
@@ -154,6 +179,73 @@ function StatusCard() {
</span>
</span>
</StatRow>
<StatRow label={tt.lastActions} className="sm:col-span-2">
<div className="flex flex-col gap-2 text-xs">
<div>
<div className="font-medium">{tt.llmSuggested}</div>
{status.last_consolidation_suggestions.consolidations.length ===
0 &&
status.last_consolidation_suggestions.prunings.length === 0 ? (
<span className="text-muted-foreground">
{tt.noActions}
</span>
) : (
<ul className="text-muted-foreground mt-0.5 list-disc pl-4">
{status.last_consolidation_suggestions.consolidations.map(
(c, i) => (
<li key={`c${i}`}>
{(c.from ?? "?") + " → " + (c.into ?? "?")}
{c.reason ? `(${c.reason})` : ""}
</li>
),
)}
{status.last_consolidation_suggestions.prunings.map(
(p, i) => (
<li key={`p${i}`}>
{p.name ?? "?"}
{p.reason ? `(${p.reason})` : ""}
</li>
),
)}
</ul>
)}
</div>
<div>
<div className="font-medium">{tt.actuallyDid}</div>
{status.last_actual_actions.length === 0 ? (
<span className="text-muted-foreground">
{tt.noActions}
</span>
) : (
<ul className="mt-0.5 flex flex-col gap-1">
{status.last_actual_actions.map((a, i) => (
<li
key={i}
className="flex flex-wrap items-center gap-1.5"
>
<span className="font-mono">{a.tool ?? "?"}</span>
{typeof a.args?.name === "string" && (
<span className="text-muted-foreground">
{String(a.args.name)}
</span>
)}
<Badge
variant={
a.status === "error"
? "destructive"
: "secondary"
}
className="px-1 text-[10px]"
>
{a.status}
</Badge>
</li>
))}
</ul>
)}
</div>
</div>
</StatRow>
</div>
) : null}
</CardContent>
@@ -178,11 +270,15 @@ function StatRow({
);
}
/** Select 不允许空字符串 value,用此哨兵代表"用主模型"(对应 model_name=null)。 */
const DEFAULT_MODEL_VALUE = "__default__";
function ConfigCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.config;
const { config, isLoading } = useCuratorConfig();
const updateConfig = useUpdateCuratorConfig();
const { models } = useModels();
const [draft, setDraft] = useState<CuratorConfig | null>(null);
useEffect(() => {
@@ -311,22 +407,35 @@ function ConfigCard() {
/>
</FieldRow>
<FieldRow
label={tt.moderationModelLabel}
helper={tt.moderationModelHelper}
label={tt.curatorModelLabel}
helper={tt.curatorModelHelper}
>
<Input
type="text"
className="max-w-[20rem]"
value={draft.moderation_model_name ?? ""}
placeholder=""
onChange={(e) =>
<Select
value={draft.curator.model_name ?? DEFAULT_MODEL_VALUE}
onValueChange={(v) =>
setDraft({
...draft,
moderation_model_name:
e.target.value.trim() === "" ? null : e.target.value,
curator: {
...draft.curator,
model_name: v === DEFAULT_MODEL_VALUE ? null : v,
},
})
}
/>
>
<SelectTrigger className="max-w-[20rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_MODEL_VALUE}>
{tt.curatorModelDefault}
</SelectItem>
{models.map((m) => (
<SelectItem key={m.name} value={m.name}>
{m.display_name || m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldRow>
<div className="flex justify-end gap-2 pt-2">
@@ -0,0 +1,58 @@
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Tag } from "@/core/tags";
import { resolveTagColor } from "./tag-colors";
/** A read-only (or removable) coloured chip for a single tag. */
export function TagChip({
tag,
onRemove,
className,
}: {
tag: Pick<Tag, "name" | "color">;
onRemove?: () => void;
className?: string;
}) {
const color = resolveTagColor(tag.color);
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
color.chip,
className,
)}
>
{tag.name}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="hover:opacity-70"
aria-label={`remove ${tag.name}`}
>
<XIcon className="size-3" />
</button>
)}
</span>
);
}
/** Renders a horizontal list of tag chips; nothing when the list is empty. */
export function TagChipList({
tags,
className,
}: {
tags: Pick<Tag, "name" | "color">[];
className?: string;
}) {
if (!tags || tags.length === 0) return null;
return (
<div className={cn("flex flex-wrap gap-1", className)}>
{tags.map((tag) => (
<TagChip key={tag.name} tag={tag} />
))}
</div>
);
}
@@ -0,0 +1,30 @@
// Fixed tag color palette. The backend only stores the color *name*; the
// Tailwind classes are resolved here so the class strings stay static and
// survive the production purge.
export interface TagColorDef {
name: string;
/** Classes for a rendered chip (background + text + border). */
chip: string;
/** A solid swatch used in the colour picker. */
swatch: string;
}
export const TAG_COLORS: TagColorDef[] = [
{ name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400" },
{ name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500" },
{ name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500" },
{ name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500" },
{ name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500" },
{ name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500" },
{ name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500" },
{ name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500" },
{ name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500" },
{ name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500" },
];
const BY_NAME = new Map(TAG_COLORS.map((c) => [c.name, c]));
export function resolveTagColor(name: string | undefined | null): TagColorDef {
return (name && BY_NAME.get(name)) || TAG_COLORS[0];
}
@@ -0,0 +1,140 @@
"use client";
import { CheckIcon, Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n } from "@/core/i18n/hooks";
import { useSetResourceTags, useTags, type Tag, type TagType } from "@/core/tags";
import { cn } from "@/lib/utils";
import { resolveTagColor } from "./tag-colors";
/**
* Admin-only dialog to set the tags on one resource. Clicking a tag binds or
* unbinds it immediately (no separate save step). Only tags of ``targetType``
* are offered, enforcing "a tag may only tag its own type".
*/
export function TagPickerDialog({
open,
onOpenChange,
targetType,
targetId,
currentTags,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
targetType: TagType;
targetId: string;
currentTags: Tag[];
}) {
const { t } = useI18n();
const { data: allTags, isLoading } = useTags(targetType);
const setResourceTags = useSetResourceTags();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [syncedFor, setSyncedFor] = useState<string | null>(null);
const syncKey = `${open}:${targetId}`;
if (open && syncedFor !== syncKey) {
setSelected(new Set(currentTags.map((tg) => tg.id)));
setSyncedFor(syncKey);
}
if (!open && syncedFor !== null) setSyncedFor(null);
// Clicking a tag mutates immediately. Local state updates optimistically and
// rolls back if the request fails.
const toggle = (tagId: string) => {
const prev = selected;
const next = new Set(prev);
if (next.has(tagId)) next.delete(tagId);
else next.add(tagId);
setSelected(next);
setResourceTags.mutate(
{ targetType, targetId, tagIds: [...next] },
{
onError: (e) => {
setSelected(prev);
toast.error(String(e instanceof Error ? e.message : e));
},
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.adminTags.pickerTitle}</DialogTitle>
<DialogDescription>{t.adminTags.pickerHint}</DialogDescription>
</DialogHeader>
<div className="min-h-[120px]">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10">
<Loader2 className="size-4 animate-spin" />
<span>{t.common.loading}</span>
</div>
)}
{!isLoading && (!allTags || allTags.length === 0) && (
<div className="text-muted-foreground py-10 text-center text-sm">
{t.adminTags.pickerEmpty}
</div>
)}
{!isLoading && allTags && allTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => {
const checked = selected.has(tag.id);
const color = resolveTagColor(tag.color);
return (
<button
key={tag.id}
type="button"
onClick={() => toggle(tag.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-all",
color.chip,
checked
? "ring-2 ring-offset-1 ring-foreground/40"
: "opacity-45 hover:opacity-90",
)}
>
{checked && <CheckIcon className="size-3.5" />}
{tag.name}
</button>
);
})}
</div>
)}
</div>
<DialogFooter className="items-center sm:justify-between">
<span className="text-muted-foreground text-xs">
{setResourceTags.isPending ? (
<span className="flex items-center gap-1">
<Loader2 className="size-3 animate-spin" />
{t.adminTags.saving}
</span>
) : (
t.adminTags.pickerSelectedCount.replace(
"{count}",
String(selected.size),
)
)}
</span>
<Button onClick={() => onOpenChange(false)}>
{t.adminTags.done}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,80 @@
"use client";
import { SearchIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useI18n } from "@/core/i18n/hooks";
import { useTags, type TagType } from "@/core/tags";
import { cn } from "@/lib/utils";
const ALL = "__all__";
/**
* Reusable name-search + tag-filter toolbar for resource list pages.
*
* The name input is debounced (300ms) before ``onSearchChange`` fires, so the
* caller can feed the value straight into a server-side query without a
* request per keystroke. Filtering is open to all users.
*/
export function TagSearchBar({
targetType,
onSearchChange,
tagId,
onTagIdChange,
className,
}: {
targetType: TagType;
onSearchChange: (value: string) => void;
tagId: string | null;
onTagIdChange: (value: string | null) => void;
className?: string;
}) {
const { t } = useI18n();
const { data: tags } = useTags(targetType);
const [input, setInput] = useState("");
const cbRef = useRef(onSearchChange);
cbRef.current = onSearchChange;
useEffect(() => {
const id = setTimeout(() => cbRef.current(input.trim()), 300);
return () => clearTimeout(id);
}, [input]);
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="relative flex-1">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t.adminTags.searchPlaceholder}
className="pl-8"
/>
</div>
<Select
value={tagId ?? ALL}
onValueChange={(v) => onTagIdChange(v === ALL ? null : v)}
>
<SelectTrigger className="w-44">
<SelectValue placeholder={t.adminTags.filterByTag} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t.adminTags.allTags}</SelectItem>
{(tags ?? []).map((tag) => (
<SelectItem key={tag.id} value={tag.id}>
{tag.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -8,6 +8,7 @@ import {
MessageSquarePlusIcon,
Settings2Icon,
SettingsIcon,
TagsIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
@@ -148,6 +149,14 @@ export function WorkspaceNavMenu() {
大模型调用统计
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
<TagsIcon />
{t.adminTags.title}
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</DropdownMenuContent>
+13 -2
View File
@@ -15,8 +15,19 @@ export class AgentNameCheckError extends Error {
}
}
export async function listAgents(): Promise<Agent[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents`);
export interface ListAgentsParams {
search?: string;
tagId?: string | null;
}
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
if (params.tagId) qs.set("tag_id", params.tagId);
const query = qs.toString();
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
);
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
const data = (await res.json()) as { agents: Agent[] };
return data.agents;
+12 -4
View File
@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
createAgent,
@@ -9,10 +14,13 @@ import {
} from "./api";
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
export function useAgents() {
export function useAgents(params: { search?: string; tagId?: string | null } = {}) {
const search = params.search ?? "";
const tagId = params.tagId ?? null;
const { data, isLoading, error } = useQuery({
queryKey: ["agents"],
queryFn: () => listAgents(),
queryKey: ["agents", search, tagId],
queryFn: () => listAgents({ search, tagId }),
placeholderData: keepPreviousData,
});
return { agents: data ?? [], isLoading, error };
}
+3
View File
@@ -1,3 +1,5 @@
import type { Tag } from "@/core/tags";
export interface Agent {
id: string;
name: string;
@@ -11,6 +13,7 @@ export interface Agent {
model?: string | null;
tool_groups?: string[] | null;
skills?: string[] | null;
tags?: Tag[];
}
export interface CreateAgentRequest {
+40
View File
@@ -0,0 +1,40 @@
import { apiFetch } from "@/core/api";
import { urlOfArtifact } from "./utils";
async function errorMessageFromResponse(res: Response): Promise<string> {
try {
const data: unknown = await res.json();
if (data && typeof data === "object" && "detail" in data) {
const detail = (data as { detail: unknown }).detail;
if (typeof detail === "string") return detail;
}
} catch {
// ignore non-json errors
}
return res.statusText || "Request failed";
}
export async function updateArtifactContent({
filepath,
threadId,
content,
isMock,
}: {
filepath: string;
threadId: string;
content: string;
isMock?: boolean;
}) {
const res = await apiFetch(urlOfArtifact({ filepath, threadId, isMock }), {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ content }),
});
if (!res.ok) {
throw new Error(await errorMessageFromResponse(res));
}
return res.text();
}
+101 -16
View File
@@ -1,9 +1,65 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { useThread } from "@/components/workspace/messages/context";
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
import { updateArtifactContent } from "./api";
function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) {
if (!toolCallId) {
return false;
}
return thread.messages.some(
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
);
}
function applyPendingTextEdit({
content,
filepath,
thread,
}: {
content: string | undefined;
filepath: string;
thread: ReturnType<typeof useThread>["thread"];
}) {
if (!content || !thread.isLoading) {
return null;
}
for (const message of [...thread.messages].reverse()) {
if (message.type !== "ai" || !message.tool_calls) {
continue;
}
for (const toolCall of [...message.tool_calls].reverse()) {
if (toolCall.name !== "str_replace" || hasToolResult(thread, toolCall.id)) {
continue;
}
const args = toolCall.args as {
path?: unknown;
old_str?: unknown;
new_str?: unknown;
replace_all?: unknown;
};
if (args.path !== filepath) {
continue;
}
if (typeof args.old_str !== "string" || typeof args.new_str !== "string") {
continue;
}
if (!content.includes(args.old_str)) {
return { content, kind: "editing" as const };
}
const nextContent =
args.replace_all === true
? content.split(args.old_str).join(args.new_str)
: content.replace(args.old_str, args.new_str);
return { content: nextContent, kind: "editing" as const };
}
}
return null;
}
export function useArtifactContent({
filepath,
@@ -25,19 +81,48 @@ export function useArtifactContent({
return null;
}, [filepath, isWriteFile, thread]);
const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock],
const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock],
queryFn: () => {
return loadArtifactContent({ filepath, threadId, isMock });
},
enabled,
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
staleTime: 5 * 60 * 1000,
});
return {
content: isWriteFile ? content : data?.content,
url: isWriteFile ? undefined : data?.url,
isLoading,
error,
};
}
enabled,
refetchInterval: thread.isLoading ? 1000 : false,
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
staleTime: 5 * 60 * 1000,
});
const pendingEdit = useMemo(() => {
if (isWriteFile) {
return null;
}
return applyPendingTextEdit({
content: data?.content,
filepath,
thread,
});
}, [data?.content, filepath, isWriteFile, thread]);
return {
content: isWriteFile ? content : (pendingEdit?.content ?? data?.content),
url: isWriteFile ? undefined : data?.url,
isLoading,
isApplyingEdit: pendingEdit?.kind === "editing",
error,
};
}
export function useUpdateArtifactContent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateArtifactContent,
onSuccess: (content, variables) => {
queryClient.setQueryData(
["artifact", variables.filepath, variables.threadId, variables.isMock],
{
content,
url: undefined,
},
);
},
});
}
+3 -2
View File
@@ -1,2 +1,3 @@
export * from "./download";
export * from "./loader";
export * from "./api";
export * from "./download";
export * from "./loader";
+8 -2
View File
@@ -28,11 +28,17 @@ export async function updateCuratorConfig(
return response.json() as Promise<CuratorConfig>;
}
export async function runCurator(): Promise<{ triggered: boolean; message: string }> {
export interface RunCuratorResult {
triggered: boolean;
/** 未触发时的原因代码:evolution_disabled / curator_disabled / already_running。 */
reason: string | null;
}
export async function runCurator(): Promise<RunCuratorResult> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/run`, {
method: "POST",
});
return response.json();
return response.json() as Promise<RunCuratorResult>;
}
export async function getCuratorLogs(): Promise<CuratorLogs> {
+30 -1
View File
@@ -3,14 +3,39 @@ export interface CuratorSubConfig {
interval_hours: number;
stale_after_days: number;
archive_after_days: number;
review_timeout_seconds: number;
/** curator 评审使用的模型名;null = 用主模型。 */
model_name: string | null;
}
export interface CuratorConfig {
enabled: boolean;
moderation_model_name: string | null;
curator: CuratorSubConfig;
}
export interface CuratorAction {
tool: string | null;
args: Record<string, unknown>;
status: string;
result: string;
}
export interface CuratorConsolidation {
from?: string;
into?: string;
reason?: string;
}
export interface CuratorPruning {
name?: string;
reason?: string;
}
export interface CuratorSuggestions {
consolidations: CuratorConsolidation[];
prunings: CuratorPruning[];
}
export interface CuratorStatus {
enabled: boolean;
run_count: number;
@@ -23,6 +48,10 @@ export interface CuratorStatus {
stale: number;
archived: number;
};
/** 上次运行 LLM 自述的建议(它"说"要做什么)。 */
last_consolidation_suggestions: CuratorSuggestions;
/** 上次运行的真实工具调用记录(它"实际"做了什么)。 */
last_actual_actions: CuratorAction[];
}
export interface CuratorLogRecord {
+51 -6
View File
@@ -177,10 +177,10 @@ export const enUS: Translations = {
// Agents
agents: {
title: "Experts",
title: "Agents",
description:
"Create and manage custom experts with specialized prompts and capabilities.",
newAgent: "New Expert",
"Create and manage custom agents with specialized prompts and capabilities.",
newAgent: "New Agent",
emptyTitle: "No custom agents yet",
emptyDescription:
"Create your first custom agent with a specialized system prompt.",
@@ -538,6 +538,16 @@ export const enUS: Translations = {
total: "Total",
runNow: "Run now",
runStarted: "Background run started",
runBlockedEvolutionDisabled:
"Skill evolution is off — enable it below to run the curator.",
runBlockedCuratorDisabled:
"The curator is off — enable it below to run it manually.",
runBlockedAlreadyRunning: "A curator run is already in progress.",
runDisabledHint: "Enable skill evolution and the curator below first.",
lastActions: "Last run",
llmSuggested: "LLM suggested (self-report)",
actuallyDid: "Actually done (tool calls)",
noActions: "None",
},
config: {
title: "Configuration",
@@ -558,9 +568,10 @@ export const enUS: Translations = {
archiveAfterDaysLabel: "Archive threshold (days)",
archiveAfterDaysHelper:
"Stale skills inactive for this many additional days are archived.",
moderationModelLabel: "Moderation model (optional)",
moderationModelHelper:
"Leave blank to use the default chat model.",
curatorModelLabel: "Curator review model",
curatorModelHelper:
"Model for the curator's LLM review and skill security moderation. Defaults to the primary model.",
curatorModelDefault: "Primary model (default)",
save: "Save changes",
saveSuccess: "Configuration updated",
reset: "Reset",
@@ -606,6 +617,40 @@ export const enUS: Translations = {
empty: "No users found",
},
adminTags: {
title: "Tag Management",
description: "Create and manage tags for agents, skills, and scheduled tasks. A tag may only be applied to resources of its own type.",
typeAgent: "Agent Tags",
typeSkill: "Skill Tags",
typeScheduledTask: "Scheduled Task Tags",
name: "Name",
color: "Color",
tagDescription: "Description",
usageCount: "Usage",
actions: "Actions",
empty: "No tags yet",
createButton: "New Tag",
createTitle: "Create Tag",
editTitle: "Edit Tag",
deleteConfirm: "Delete this tag? It will be removed from every resource it tags.",
namePlaceholder: "Tag name",
descriptionPlaceholder: "Optional description",
save: "Save",
cancel: "Cancel",
edit: "Edit",
delete: "Delete",
pickerTitle: "Edit Tags",
pickerHint: "Click a tag to bind or unbind it — changes are saved instantly.",
pickerEmpty: "No tags available for this type. Create some in Tag Management.",
pickerSelectedCount: "{count} selected",
saving: "Saving…",
done: "Done",
manageTags: "Tags",
searchPlaceholder: "Search by name",
filterByTag: "Filter by tag",
allTags: "All tags",
},
notifications: {
title: "Notifications",
bellAria: "Open notifications",
+48 -2
View File
@@ -468,6 +468,14 @@ export interface Translations {
total: string;
runNow: string;
runStarted: string;
runBlockedEvolutionDisabled: string;
runBlockedCuratorDisabled: string;
runBlockedAlreadyRunning: string;
runDisabledHint: string;
lastActions: string;
llmSuggested: string;
actuallyDid: string;
noActions: string;
};
config: {
title: string;
@@ -482,8 +490,9 @@ export interface Translations {
staleAfterDaysHelper: string;
archiveAfterDaysLabel: string;
archiveAfterDaysHelper: string;
moderationModelLabel: string;
moderationModelHelper: string;
curatorModelLabel: string;
curatorModelHelper: string;
curatorModelDefault: string;
save: string;
saveSuccess: string;
reset: string;
@@ -527,6 +536,43 @@ export interface Translations {
empty: string;
};
// Admin tag management page
adminTags: {
title: string;
description: string;
typeAgent: string;
typeSkill: string;
typeScheduledTask: string;
name: string;
color: string;
tagDescription: string;
usageCount: string;
actions: string;
empty: string;
createButton: string;
createTitle: string;
editTitle: string;
deleteConfirm: string;
namePlaceholder: string;
descriptionPlaceholder: string;
save: string;
cancel: string;
edit: string;
delete: string;
// tag picker dialog
pickerTitle: string;
pickerHint: string;
pickerEmpty: string;
pickerSelectedCount: string;
saving: string;
done: string;
manageTags: string;
// search controls on resource lists
searchPlaceholder: string;
filterByTag: string;
allTags: string;
};
// In-app notification inbox (bell in the workspace sidebar)
notifications: NotificationsTranslations;
}
+69 -26
View File
@@ -89,7 +89,7 @@ export const zhCN: Translations = {
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
proMode: "专业问答",
proModeDescription: "思考、计划再执行,获得更精准的结果,可能需要更多时间",
ultraMode: "多专家问答",
ultraMode: "多智能体问答",
ultraModeDescription:
"继承自专业问答模式,可调用子代理分工协作,适合复杂多步骤任务,能力最强",
reasoningEffort: "推理深度",
@@ -165,47 +165,47 @@ export const zhCN: Translations = {
chats: "对话",
recentChats: "最近的对话",
demoChats: "演示对话",
agents: "专家",
agents: "智能体",
users: "用户管理",
},
// Agents
agents: {
title: "专家",
description: "创建和管理具有专属 Prompt 与能力的自定义专家。",
newAgent: "新建专家",
emptyTitle: "还没有自定义专家",
emptyDescription: "创建你的第一个自定义专家,设置专属系统提示词。",
title: "智能体",
description: "创建和管理具有专属 Prompt 与能力的自定义智能体。",
newAgent: "新建智能体",
emptyTitle: "还没有自定义智能体",
emptyDescription: "创建你的第一个自定义智能体,设置专属系统提示词。",
chat: "对话",
delete: "删除",
deleteConfirm: "确定要删除该专家吗?此操作不可撤销。",
deleteSuccess: "专家已删除",
deleteConfirm: "确定要删除该智能体吗?此操作不可撤销。",
deleteSuccess: "智能体已删除",
newChat: "新对话",
createPageTitle: "设计你的专家",
createPageSubtitle: "描述你想要的专家,我来帮你通过对话创建。",
nameStepTitle: "给新专家起个名字",
createPageTitle: "设计你的智能体",
createPageSubtitle: "描述你想要的智能体,我来帮你通过对话创建。",
nameStepTitle: "给新智能体起个名字",
nameStepHint:
"只允许字母、数字和连字符,存储时自动转为小写(例如 code-reviewer)",
nameStepPlaceholder: "例如 code-reviewer",
nameStepContinue: "继续",
nameStepInvalidError: "名称无效,只允许字母、数字和连字符",
nameStepAlreadyExistsError: "已存在同名专家",
nameStepAlreadyExistsError: "已存在同名智能体",
nameStepNetworkError: "网络请求失败,请检查网络或后端连接",
nameStepCheckError: "无法验证名称可用性,请稍后重试",
nameStepBootstrapMessage:
"新专家的名称是 {name},现在开始为它生成 **SOUL**。",
save: "保存专家",
saving: "正在保存专家...",
"新智能体的名称是 {name},现在开始为它生成 **SOUL**。",
save: "保存智能体",
saving: "正在保存智能体...",
saveRequested:
"已提交保存请求,CM 助手 正在根据当前对话生成并保存初版专家。",
"已提交保存请求,CM 助手 正在根据当前对话生成并保存初版智能体。",
saveHint:
"你可以在右上角的菜单里随时保存这个专家,就算目前还只是初稿也可以。",
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
saveCommandMessage:
"请现在根据我们目前已经讨论的全部内容保存这个自定义专家。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
agentCreatedPendingRefresh:
"专家已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。",
"智能体已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。",
more: "更多操作",
agentCreated: "专家已创建!",
agentCreated: "智能体已创建!",
startChatting: "开始对话",
backToGallery: "返回 Gallery",
},
@@ -435,7 +435,7 @@ export const zhCN: Translations = {
pinSuccess: "已钉选",
unpinSuccess: "已取消钉选",
deleteAction: "删除技能",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有专家中自动移除。",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
deleteSuccess: "技能已删除",
stateSheet: {
archivedTitle: "归档的技能",
@@ -516,6 +516,14 @@ export const zhCN: Translations = {
total: "合计",
runNow: "立即执行",
runStarted: "已触发后台执行",
runBlockedEvolutionDisabled: "自我进化未开启 —— 请先在下方打开开关。",
runBlockedCuratorDisabled: "curator 未开启 —— 请先在下方打开 curator 开关。",
runBlockedAlreadyRunning: "已有一次 curator 运行进行中。",
runDisabledHint: "请先在下方开启自我进化与 curator 开关。",
lastActions: "最近一次运行",
llmSuggested: "LLM 建议(自述)",
actuallyDid: "实际执行(工具调用)",
noActions: "无",
},
config: {
title: "配置",
@@ -531,8 +539,9 @@ export const zhCN: Translations = {
staleAfterDaysHelper: "技能长时间不活跃后被标记为陈旧。",
archiveAfterDaysLabel: "归档阈值(天)",
archiveAfterDaysHelper: "标记陈旧后再经过这么多天会被自动归档。",
moderationModelLabel: "安全审核模型(可选)",
moderationModelHelper: "为空时使用默认主模型。",
curatorModelLabel: "技能优化模型",
curatorModelHelper: "用于 curator 技能评审和技能安全审核的模型,为空则使用主模型。",
curatorModelDefault: "主模型(默认)",
save: "保存修改",
saveSuccess: "配置已更新",
reset: "重置",
@@ -566,17 +575,51 @@ export const zhCN: Translations = {
adminUsers: {
title: "用户管理",
description: "查看所有注册用户及其关联的技能和专家数量。",
description: "查看所有注册用户及其关联的技能和智能体数量。",
email: "邮箱",
role: "角色",
userId: "用户 ID",
skillCount: "技能数",
agentCount: "专家数",
agentCount: "智能体数",
roleAdmin: "管理员",
roleUser: "普通用户",
empty: "暂无用户",
},
adminTags: {
title: "标签管理",
description: "为智能体、技能和定时任务创建并管理标签。标签只能用于其所属类型的资源。",
typeAgent: "智能体标签",
typeSkill: "技能标签",
typeScheduledTask: "定时任务标签",
name: "名称",
color: "颜色",
tagDescription: "描述",
usageCount: "使用数",
actions: "操作",
empty: "暂无标签",
createButton: "新建标签",
createTitle: "创建标签",
editTitle: "编辑标签",
deleteConfirm: "确认删除此标签?它将从所有已打标签的资源上移除。",
namePlaceholder: "标签名称",
descriptionPlaceholder: "描述(可选)",
save: "保存",
cancel: "取消",
edit: "编辑",
delete: "删除",
pickerTitle: "编辑标签",
pickerHint: "点击标签即可绑定或解绑,修改实时保存。",
pickerEmpty: "该类型暂无可用标签,请先在标签管理中创建。",
pickerSelectedCount: "已选 {count} 个",
saving: "保存中…",
done: "完成",
manageTags: "标签",
searchPlaceholder: "按名称搜索",
filterByTag: "按标签筛选",
allTags: "全部标签",
},
notifications: {
title: "通知",
bellAria: "打开通知",
+13 -2
View File
@@ -28,8 +28,19 @@ export async function getSchedulerThread() {
return parseResponse<SchedulerThread>(await apiFetch(`${baseUrl()}/thread`));
}
export async function listScheduledTasks() {
return parseResponse<ScheduledTask[]>(await apiFetch(baseUrl()));
export interface ListScheduledTasksParams {
search?: string;
tagId?: string | null;
}
export async function listScheduledTasks(params: ListScheduledTasksParams = {}) {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
if (params.tagId) qs.set("tag_id", params.tagId);
const query = qs.toString();
return parseResponse<ScheduledTask[]>(
await apiFetch(`${baseUrl()}${query ? `?${query}` : ""}`),
);
}
export async function createScheduledTask(data: {
+14 -4
View File
@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
createScheduledTask,
@@ -30,11 +35,16 @@ export function useSchedulerThread() {
});
}
export function useScheduledTasks() {
export function useScheduledTasks(
params: { search?: string; tagId?: string | null } = {},
) {
const search = params.search ?? "";
const tagId = params.tagId ?? null;
return useQuery({
queryKey: ["scheduled-tasks"],
queryFn: listScheduledTasks,
queryKey: ["scheduled-tasks", "list", search, tagId],
queryFn: () => listScheduledTasks({ search, tagId }),
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
});
}
@@ -1,3 +1,5 @@
import type { Tag } from "@/core/tags";
export type ScheduledTaskRole = "owner" | "subscriber";
export type ScheduledTaskSubscription = {
@@ -29,6 +31,7 @@ export type ScheduledTask = {
_subscription?: ScheduledTaskSubscription | null;
creator_user_id?: string | null;
creator_email?: string | null;
tags?: Tag[];
};
export type PublishedScheduledTask = {
+6 -5
View File
@@ -8,11 +8,12 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
streamSpeedMeterEnabled: false,
},
context: {
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
},
};
model_name: undefined,
mode: undefined,
reasoning_effort: undefined,
writing_mode: false,
},
};
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
+13 -2
View File
@@ -3,8 +3,19 @@ import { apiFetch } from "@/core/api";
import type { Skill, SkillState } from "./type";
export async function loadSkills() {
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
export interface ListSkillsParams {
search?: string;
tagId?: string | null;
}
export async function loadSkills(params: ListSkillsParams = {}) {
const qs = new URLSearchParams();
if (params.search) qs.set("search", params.search);
if (params.tagId) qs.set("tag_id", params.tagId);
const query = qs.toString();
const skills = await apiFetch(
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
);
const json = await skills.json();
return json.skills as Skill[];
}
+12 -4
View File
@@ -1,4 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
deleteCustomSkill,
@@ -14,10 +19,13 @@ import type { SkillState } from "./type";
import { loadSkills } from ".";
export function useSkills() {
export function useSkills(params: { search?: string; tagId?: string | null } = {}) {
const search = params.search ?? "";
const tagId = params.tagId ?? null;
const { data, isLoading, error } = useQuery({
queryKey: ["skills"],
queryFn: () => loadSkills(),
queryKey: ["skills", search, tagId],
queryFn: () => loadSkills({ search, tagId }),
placeholderData: keepPreviousData,
});
return { skills: data ?? [], isLoading, error };
}
+4
View File
@@ -1,3 +1,5 @@
import type { Tag } from "@/core/tags";
export type SkillState = "active" | "stale" | "archived";
export type SkillSource = "agent" | "upload";
@@ -23,4 +25,6 @@ export interface Skill {
use_count?: number;
view_count?: number;
patch_count?: number;
// Tags assigned to this skill (admin-managed).
tags?: Tag[];
}
+83
View File
@@ -0,0 +1,83 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
Tag,
TagCreateInput,
TagListResponse,
TagType,
TagUpdateInput,
} from "./types";
async function parseDetail(res: Response, fallback: string): Promise<Error> {
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
const detail = typeof data.detail === "string" && data.detail ? data.detail : null;
return new Error(detail ?? `${fallback} (HTTP ${res.status})`);
}
const base = () => `${getBackendBaseURL()}/api/tags`;
export async function listTags(tagType?: TagType): Promise<Tag[]> {
const query = tagType ? `?tag_type=${encodeURIComponent(tagType)}` : "";
const res = await apiFetch(`${base()}${query}`);
if (!res.ok) throw await parseDetail(res, "Failed to list tags");
const data = (await res.json()) as TagListResponse;
return data.tags;
}
export async function createTag(input: TagCreateInput): Promise<Tag> {
const res = await apiFetch(base(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw await parseDetail(res, "Failed to create tag");
return (await res.json()) as Tag;
}
export async function updateTag(id: string, input: TagUpdateInput): Promise<Tag> {
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw await parseDetail(res, "Failed to update tag");
return (await res.json()) as Tag;
}
export async function deleteTag(id: string): Promise<void> {
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, {
method: "DELETE",
});
if (!res.ok) throw await parseDetail(res, "Failed to delete tag");
}
export async function getResourceTags(
targetType: TagType,
targetId: string,
): Promise<Tag[]> {
const res = await apiFetch(
`${base()}/assignments/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}`,
);
if (!res.ok) throw await parseDetail(res, "Failed to load resource tags");
const data = (await res.json()) as TagListResponse;
return data.tags;
}
export async function setResourceTags(
targetType: TagType,
targetId: string,
tagIds: string[],
): Promise<Tag[]> {
const res = await apiFetch(
`${base()}/assignments/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag_ids: tagIds }),
},
);
if (!res.ok) throw await parseDetail(res, "Failed to update resource tags");
const data = (await res.json()) as TagListResponse;
return data.tags;
}
+88
View File
@@ -0,0 +1,88 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createTag,
deleteTag,
getResourceTags,
listTags,
setResourceTags,
updateTag,
} from "./api";
import type { TagCreateInput, TagType, TagUpdateInput } from "./types";
// Tags are global (admin-managed), so the cache key is not user-scoped.
const tagKeys = {
root: ["tags"] as const,
list: (tagType?: TagType) => ["tags", "list", tagType ?? "all"] as const,
assignment: (targetType: TagType, targetId: string) =>
["tags", "assignment", targetType, targetId] as const,
};
export function useTags(tagType?: TagType) {
return useQuery({
queryKey: tagKeys.list(tagType),
queryFn: () => listTags(tagType),
});
}
function useInvalidateTags() {
const queryClient = useQueryClient();
return () => {
void queryClient.invalidateQueries({ queryKey: tagKeys.root });
};
}
export function useCreateTag() {
const invalidate = useInvalidateTags();
return useMutation({
mutationFn: (input: TagCreateInput) => createTag(input),
onSuccess: invalidate,
});
}
export function useUpdateTag() {
const invalidate = useInvalidateTags();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: TagUpdateInput }) =>
updateTag(id, input),
onSuccess: invalidate,
});
}
export function useDeleteTag() {
const invalidate = useInvalidateTags();
return useMutation({
mutationFn: (id: string) => deleteTag(id),
onSuccess: invalidate,
});
}
export function useResourceTags(targetType: TagType, targetId: string, enabled = true) {
return useQuery({
queryKey: tagKeys.assignment(targetType, targetId),
queryFn: () => getResourceTags(targetType, targetId),
enabled,
});
}
export function useSetResourceTags() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
targetType,
targetId,
tagIds,
}: {
targetType: TagType;
targetId: string;
tagIds: string[];
}) => setResourceTags(targetType, targetId, tagIds),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: tagKeys.root });
// Resource list pages embed the tags, so refresh them too.
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["agents"] });
void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
},
});
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./api";
export * from "./hooks";
export * from "./types";
+31
View File
@@ -0,0 +1,31 @@
export type TagType = "agent" | "skill" | "scheduled_task";
export const TAG_TYPES: TagType[] = ["agent", "skill", "scheduled_task"];
export interface Tag {
id: string;
name: string;
tag_type: TagType;
color: string;
description: string;
usage_count: number;
created_at: string | null;
updated_at: string | null;
}
export interface TagListResponse {
tags: Tag[];
}
export interface TagCreateInput {
name: string;
tag_type: TagType;
color?: string;
description?: string;
}
export interface TagUpdateInput {
name?: string;
color?: string;
description?: string;
}
+5 -3
View File
@@ -14,9 +14,11 @@ export interface AgentThreadContext extends Record<string, unknown> {
model_name: string | undefined;
thinking_enabled: boolean;
is_plan_mode: boolean;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
agent_id?: string;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
writing_artifact_path?: string;
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
}
@@ -93,7 +93,7 @@ export function useExpertAuthBootstrap() {
useEffect(() => {
void bootstrap().catch((error) => {
console.error('专家中心初始化失败:', error);
console.error('智能体中心初始化失败:', error);
});
}, [bootstrap]);
@@ -0,0 +1 @@
export { getAPIClient } from "@/core/api";
@@ -0,0 +1,39 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type { CanvasModel } from "../types/model";
interface BackendModel {
name: string;
display_name?: string;
provider?: string;
is_local?: boolean;
supports_vision?: boolean;
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
supports_tool_calling?: boolean;
// Canvas-phase-9 fields (now returned by backend)
description?: string;
}
export async function loadCanvasModels(): Promise<CanvasModel[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/models`);
const data = (await res.json()) as { models?: BackendModel[] };
return (data.models ?? []).map(
(m): CanvasModel => ({
name: m.name,
display_name: m.display_name ?? m.name,
provider: m.provider,
is_local: m.is_local,
supports_vision: m.supports_vision,
supports_thinking: m.supports_thinking,
supports_reasoning_effort: m.supports_reasoning_effort,
supports_tool_calling: m.supports_tool_calling ?? true,
supports_canvas_generation: true,
supports_canvas_rewrite: true,
supports_canvas_code_edit: true,
supports_streaming_text: true,
supports_streaming_tool_calls: m.supports_tool_calling ?? true,
}),
);
}
+192
View File
@@ -0,0 +1,192 @@
import { getAPIClient } from "@/core/api";
import { CANVAS_AGENT_ID } from "../constants";
import type { CanvasArtifact } from "../types/artifact";
import type { CanvasMessage } from "../types/thread";
export type CanvasRunInput = {
messages: Array<{ type: string; content: string }>;
canvas_mode?: "text" | "code";
highlighted_text?: string;
highlighted_code?: string;
quick_action_id?: string;
};
/** Partial artifact data extracted from streaming tool_call_chunks */
export type StreamingArtifactPartial = {
type?: "text" | "code";
title?: string;
content?: string;
language?: string;
};
export type CanvasStreamCallbacks = {
onMessage?: (msg: CanvasMessage) => void;
onArtifact?: (artifact: CanvasArtifact) => void;
/** Called repeatedly during streaming as update_artifact args accumulate */
onStreamingArtifact?: (partial: StreamingArtifactPartial) => void;
onDone?: () => void;
onError?: (err: unknown) => void;
};
// ---------------------------------------------------------------------------
// Streaming JSON arg extraction helpers
// ---------------------------------------------------------------------------
function unescapeJsonFragment(raw: string): string {
// Remove trailing incomplete escape sequence
const safe = raw.replace(/\\[^"nrts\\bfu]?$/, "");
let out = "";
let i = 0;
while (i < safe.length) {
if (safe[i] === "\\" && i + 1 < safe.length) {
const next = safe[i + 1]!;
if (next === "n") { out += "\n"; i += 2; }
else if (next === "r") { out += "\r"; i += 2; }
else if (next === "t") { out += "\t"; i += 2; }
else if (next === '"') { out += '"'; i += 2; }
else if (next === "\\") { out += "\\"; i += 2; }
else if (next === "b") { out += "\b"; i += 2; }
else if (next === "f") { out += "\f"; i += 2; }
else { out += safe[i]!; i++; }
} else if (safe[i] === '"') {
// Unescaped closing quote — content value ends here
break;
} else {
out += safe[i]!;
i++;
}
}
return out;
}
/** Extract a fully-quoted string field, e.g. "type": "text" */
function extractStringField(args: string, field: string): string | undefined {
const re = new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, "");
const m = re.exec(args);
if (!m) return undefined;
return unescapeJsonFragment(m[1]!);
}
/** Extract the content field even from incomplete JSON (no closing quote yet) */
function extractContentField(args: string): string | undefined {
const keyIdx = args.indexOf('"content"');
if (keyIdx === -1) return undefined;
const afterKey = args.slice(keyIdx + '"content"'.length);
const colonQuote = /^\s*:\s*"/.exec(afterKey);
if (!colonQuote) return undefined;
const valueStart = colonQuote[0].length;
const raw = afterKey.slice(valueStart);
return unescapeJsonFragment(raw);
}
function extractPartialArtifactArgs(args: string): StreamingArtifactPartial {
return {
type: extractStringField(args, "type") as "text" | "code" | undefined,
title: extractStringField(args, "title"),
language: extractStringField(args, "language"),
content: extractContentField(args),
};
}
// ---------------------------------------------------------------------------
// Main stream function
// ---------------------------------------------------------------------------
export async function streamCanvasRun(
threadId: string,
input: CanvasRunInput,
callbacks: CanvasStreamCallbacks,
modelName?: string | null,
): Promise<void> {
const client = getAPIClient();
try {
const stream = client.runs.stream(threadId, CANVAS_AGENT_ID, {
input,
streamMode: ["values", "messages-tuple"],
...(modelName ? { config: { configurable: { model_name: modelName } } } : {}),
});
// Accumulate tool call args by index position
const toolCallAccumulator: Record<number, { name: string; args: string }> = {};
for await (const chunk of stream) {
const event = chunk.event as string;
const data = (chunk as { event: string; data: unknown }).data;
if (event === "values") {
const state = data as Record<string, unknown>;
if (state.artifact) {
callbacks.onArtifact?.(state.artifact as CanvasArtifact);
}
} else if (event === "messages") {
const tuple = data as [unknown, unknown] | unknown;
const rawMsg = Array.isArray(tuple) ? tuple[0] : tuple;
if (!rawMsg || typeof rawMsg !== "object") continue;
const msg = rawMsg as Record<string, unknown>;
// Emit text content to chat panel
callbacks.onMessage?.(msg as unknown as CanvasMessage);
// ---- Streaming tool call args (tool_call_chunks) ----
const toolCallChunks = msg.tool_call_chunks;
if (Array.isArray(toolCallChunks) && toolCallChunks.length > 0) {
for (const tcChunk of toolCallChunks as Array<Record<string, unknown>>) {
const idx = (tcChunk.index as number | undefined) ?? 0;
if (!toolCallAccumulator[idx]) {
toolCallAccumulator[idx] = {
name: (tcChunk.name as string | undefined) ?? "",
args: "",
};
} else if (tcChunk.name) {
toolCallAccumulator[idx]!.name = tcChunk.name as string;
}
toolCallAccumulator[idx]!.args += (tcChunk.args as string | undefined) ?? "";
if (toolCallAccumulator[idx]!.name === "update_artifact") {
const partial = extractPartialArtifactArgs(toolCallAccumulator[idx]!.args);
if (partial.content !== undefined || partial.title !== undefined) {
callbacks.onStreamingArtifact?.(partial);
}
}
}
}
// ---- Complete tool_calls (non-streaming or final snapshot) ----
const toolCalls = msg.tool_calls;
if (Array.isArray(toolCalls)) {
for (const tc of toolCalls as Array<Record<string, unknown>>) {
if (tc.name === "update_artifact" && tc.args) {
try {
const args =
typeof tc.args === "string"
? (JSON.parse(tc.args) as Record<string, unknown>)
: (tc.args as Record<string, unknown>);
callbacks.onStreamingArtifact?.({
type: args.type as "text" | "code" | undefined,
title: args.title as string | undefined,
content: args.content as string | undefined,
language: args.language as string | undefined,
});
} catch {
// ignore
}
}
}
}
}
}
callbacks.onDone?.();
} catch (err) {
callbacks.onError?.(err);
}
}
export async function stopCanvasRun(
threadId: string,
runId: string,
): Promise<void> {
const client = getAPIClient();
await client.runs.cancel(threadId, runId);
}
@@ -0,0 +1,35 @@
import { getAPIClient } from "@/core/api";
import { CANVAS_THREAD_METADATA } from "../constants";
import type { CanvasThreadMetadata, CanvasThreadValues } from "../types/thread";
export async function createCanvasThread(
metadata?: Partial<CanvasThreadMetadata>,
): Promise<string> {
const client = getAPIClient();
const thread = await client.threads.create({
metadata: { ...CANVAS_THREAD_METADATA, ...metadata },
});
return thread.thread_id;
}
export async function getCanvasThread(
threadId: string,
): Promise<CanvasThreadValues | null> {
const client = getAPIClient();
try {
const state =
await client.threads.getState<CanvasThreadValues>(threadId);
return (state.values as CanvasThreadValues) ?? null;
} catch {
return null;
}
}
export async function updateCanvasThreadState(
threadId: string,
values: Partial<CanvasThreadValues>,
): Promise<void> {
const client = getAPIClient();
await client.threads.updateState(threadId, { values });
}
@@ -0,0 +1,34 @@
import { FileText, Code2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { getCurrentArtifactContent } from "../../utils/artifact";
import { CanvasArtifactVersionNav } from "./CanvasArtifactVersionNav";
export function CanvasArtifactHeader() {
const { artifact } = useCanvasGraphContext();
const content = getCurrentArtifactContent(artifact);
if (!content) return null;
const isCode = content.type === "code";
const Icon = isCode ? Code2 : FileText;
return (
<div className="h-11 border-b flex items-center px-4 gap-2 shrink-0 bg-background">
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate flex-1 min-w-0">
{content.title}
</span>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="secondary" className="text-[10px] h-5 px-1.5">
{isCode ? (content as { language: string }).language : "文本"}
</Badge>
<CanvasArtifactVersionNav />
</div>
</div>
);
}
@@ -0,0 +1,113 @@
import { FileText, Loader2 } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import type { StreamingArtifactPartial } from "../../api/runs";
import {
getCurrentArtifactContent,
isCodeArtifact,
} from "../../utils/artifact";
import { CanvasArtifactHeader } from "./CanvasArtifactHeader";
import { CanvasEmptyState } from "./CanvasEmptyState";
import { CanvasQuickActionBar } from "./CanvasQuickActionBar";
import { TextArtifactRenderer } from "./TextArtifactRenderer";
import { CodeArtifactRenderer } from "./CodeArtifactRenderer";
import type {
CanvasArtifactCode,
CanvasArtifactText,
} from "../../types/artifact";
// ---------------------------------------------------------------------------
// StreamingOverlay — shown while update_artifact args are streaming in
// ---------------------------------------------------------------------------
function StreamingTextPreview({ partial }: { partial: StreamingArtifactPartial }) {
const hasContent = partial.content && partial.content.trim();
if (!hasContent) {
// Args started but content not yet arrived — show skeleton
return (
<div className="h-full flex flex-col items-center justify-center gap-4 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
<div className="space-y-1 text-center">
{partial.title ? (
<p className="text-sm font-medium text-foreground">{partial.title}</p>
) : null}
<p className="text-xs">AI 正在生成文档…</p>
</div>
</div>
);
}
// Stream content into the real renderer (read-only, isStreaming = true)
if (partial.type === "code") {
const fakeContent: CanvasArtifactCode = {
index: 0,
type: "code",
title: partial.title ?? "代码",
language: (partial.language as CanvasArtifactCode["language"]) ?? "other",
code: partial.content ?? "",
};
return <CodeArtifactRenderer content={fakeContent} isStreaming />;
}
const fakeContent: CanvasArtifactText = {
index: 0,
type: "text",
title: partial.title ?? "文档",
fullMarkdown: partial.content ?? "",
};
return <TextArtifactRenderer content={fakeContent} isStreaming />;
}
// ---------------------------------------------------------------------------
// CanvasArtifactPanel
// ---------------------------------------------------------------------------
export function CanvasArtifactPanel() {
const { artifact, streamingArtifact, isStreaming } = useCanvasGraphContext();
const [searchParams] = useSearchParams();
const initialType = searchParams.get("type") as "text" | "code" | null;
// While streaming and we have partial data, show the streaming preview
if (isStreaming && streamingArtifact) {
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 border-b shrink-0 bg-muted/30">
<FileText className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium truncate flex-1">
{streamingArtifact.title ?? "正在生成…"}
</span>
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary shrink-0" />
</div>
<div className="flex-1 overflow-hidden">
<StreamingTextPreview partial={streamingArtifact} />
</div>
</div>
);
}
if (!artifact) {
return (
<div className="h-full bg-muted/10">
<CanvasEmptyState initialType={initialType} />
</div>
);
}
const content = getCurrentArtifactContent(artifact);
return (
<div className="h-full flex flex-col overflow-hidden">
<CanvasArtifactHeader />
<CanvasQuickActionBar />
<div className="flex-1 overflow-hidden">
{content && isCodeArtifact(content) ? (
<CodeArtifactRenderer content={content as CanvasArtifactCode} />
) : content ? (
<TextArtifactRenderer content={content as CanvasArtifactText} />
) : null}
</div>
</div>
);
}
@@ -0,0 +1,50 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { setCurrentArtifactIndex } from "../../utils/artifact";
export function CanvasArtifactVersionNav() {
const { artifact, setArtifact } = useCanvasGraphContext();
if (!artifact || artifact.contents.length <= 1) return null;
const total = artifact.contents.length;
const current = artifact.currentIndex;
const canGoPrev = current > 1;
const canGoNext = current < total;
function go(delta: -1 | 1) {
if (!artifact) return;
setArtifact(setCurrentArtifactIndex(artifact, current + delta));
}
return (
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => go(-1)}
disabled={!canGoPrev}
title="上一个版本"
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<span className="text-xs text-muted-foreground tabular-nums w-10 text-center">
{current}/{total}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => go(1)}
disabled={!canGoNext}
title="下一个版本"
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
);
}
@@ -0,0 +1,168 @@
import { FileText, Code2, Globe, Atom, Terminal } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { createDraftArtifact } from "../../utils/artifact";
import type { ProgrammingLanguageOptions } from "../../types/artifact";
const HTML_TEMPLATE = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>页面标题</title>
<style>
body { font-family: sans-serif; margin: 2rem; color: #333; }
</style>
</head>
<body>
<h1>Hello World</h1>
<p>在这里开始编写你的页面</p>
</body>
</html>`;
const REACT_TEMPLATE = `import React from "react";
interface Props {
// 在这里定义 props
}
export default function MyComponent({}: Props) {
return (
<div>
<h1>My Component</h1>
<p>在这里开始编写你的组件</p>
</div>
);
}`;
const PYTHON_TEMPLATE = `def main():
"""主函数"""
print("Hello, World!")
if __name__ == "__main__":
main()`;
interface QuickStartOption {
icon: LucideIcon;
title: string;
description: string;
type: "text" | "code";
templateTitle: string;
content: string;
language?: ProgrammingLanguageOptions;
}
const QUICK_START_OPTIONS: QuickStartOption[] = [
{
icon: FileText,
title: "空白文档",
description: "从空白 Markdown 文档开始",
type: "text",
templateTitle: "未命名文档",
content: "",
},
{
icon: Code2,
title: "空白代码",
description: "从空白代码文件开始",
type: "code",
templateTitle: "未命名代码",
content: "",
language: "python",
},
{
icon: Globe,
title: "HTML 页面",
description: "基础 HTML 页面模板",
type: "code",
templateTitle: "HTML 页面",
content: HTML_TEMPLATE,
language: "html",
},
{
icon: Atom,
title: "React 组件",
description: "TypeScript React 组件模板",
type: "code",
templateTitle: "React 组件",
content: REACT_TEMPLATE,
language: "typescript",
},
{
icon: Terminal,
title: "Python 脚本",
description: "Python 入口脚本模板",
type: "code",
templateTitle: "Python 脚本",
content: PYTHON_TEMPLATE,
language: "python",
},
];
interface CanvasEmptyStateProps {
initialType?: "text" | "code" | null;
}
export function CanvasEmptyState({ initialType }: CanvasEmptyStateProps) {
const { setArtifact } = useCanvasGraphContext();
function handleSelect(option: QuickStartOption) {
setArtifact(
createDraftArtifact(option.type, {
title: option.templateTitle,
content: option.content,
language: option.language,
}),
);
}
const filtered =
initialType
? QUICK_START_OPTIONS.filter(
(o) => !initialType || o.type === initialType || o.type === "text",
)
: QUICK_START_OPTIONS;
return (
<div className="h-full flex flex-col items-center justify-center px-6">
<div className="w-full max-w-sm space-y-6">
<div className="text-center space-y-1">
<p className="text-sm font-medium">选择模板快速开始</p>
<p className="text-xs text-muted-foreground">
或直接在左侧聊天区发送消息
</p>
</div>
<div className="space-y-2">
{QUICK_START_OPTIONS.map((option) => {
const Icon = option.icon;
const isHighlighted = initialType && option.type === initialType && option.content === "";
return (
<button
key={option.title}
onClick={() => handleSelect(option)}
className={`w-full flex items-center gap-3 rounded-lg border px-4 py-3 text-left transition-all hover:border-primary/50 hover:bg-accent/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isHighlighted
? "border-primary/40 bg-primary/5"
: "bg-card"
}`}
>
<div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center shrink-0">
<Icon className="w-4 h-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{option.title}</p>
<p className="text-xs text-muted-foreground truncate">
{option.description}
</p>
</div>
</button>
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasQuickActions } from "../../hooks/useCanvasQuickActions";
import type { QuickAction } from "../../types/quick-action";
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
import { QUICK_ACTIONS_CODE, QUICK_ACTIONS_TEXT } from "../../constants";
export function CanvasQuickActionBar() {
const { artifact, isStreaming } = useCanvasGraphContext();
const { triggerQuickAction } = useCanvasQuickActions();
const content = getCurrentArtifactContent(artifact);
if (!content) return null;
const actions: QuickAction[] = isCodeArtifact(content)
? QUICK_ACTIONS_CODE
: QUICK_ACTIONS_TEXT;
return (
<div className="border-b px-4 py-1.5 flex items-center gap-1.5 flex-wrap shrink-0 bg-background">
{actions.map((action) => (
<button
key={action.id}
disabled={isStreaming}
onClick={() => void triggerQuickAction(action.id)}
className="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-medium transition-colors
bg-background text-foreground hover:bg-accent hover:text-accent-foreground
disabled:opacity-40 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{action.label}
</button>
))}
</div>
);
}
@@ -0,0 +1,121 @@
import { html } from "@codemirror/lang-html";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { python } from "@codemirror/lang-python";
import type { Extension } from "@codemirror/state";
import type { ViewUpdate } from "@codemirror/view";
import { basicLightInit } from "@uiw/codemirror-theme-basic";
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
import CodeMirror from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
import { useCallback, useMemo } from "react";
import type {
CanvasArtifactCode,
ProgrammingLanguageOptions,
} from "../../types/artifact";
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
// ---------------------------------------------------------------------------
// Themes — match code-editor.tsx defaults
// ---------------------------------------------------------------------------
const darkTheme = monokaiInit({
settings: {
background: "transparent",
gutterBackground: "transparent",
gutterForeground: "#555",
gutterActiveForeground: "#fff",
fontSize: "var(--text-sm)",
},
});
const lightTheme = basicLightInit({
settings: {
background: "transparent",
fontSize: "var(--text-sm)",
},
});
// ---------------------------------------------------------------------------
// Language detection
// ---------------------------------------------------------------------------
function getLanguageExtension(
lang: ProgrammingLanguageOptions,
): Extension | null {
switch (lang) {
case "typescript":
return javascript({ typescript: true, jsx: true });
case "javascript":
return javascript({ jsx: true });
case "python":
return python();
case "html":
return html();
case "json":
return json();
// cpp, java, rust, sql, xml, clojure, csharp, php → plain text fallback
default:
return null;
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface CodeArtifactRendererProps {
content: CanvasArtifactCode;
isStreaming?: boolean;
}
export function CodeArtifactRenderer({ content, isStreaming: _isStreaming }: CodeArtifactRendererProps) {
const { resolvedTheme } = useTheme();
const { handleCodeSelectionChange } = useCanvasSelection();
const extensions = useMemo<Extension[]>(() => {
const ext = getLanguageExtension(content.language);
return ext ? [ext] : [];
}, [content.language]);
const handleUpdate = useCallback(
(update: ViewUpdate) => {
if (!update.selectionSet) return;
const { from, to } = update.state.selection.main;
handleCodeSelectionChange(from, to);
},
[handleCodeSelectionChange],
);
if (!content.code) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-muted-foreground">
空白代码,发送消息让 AI 填充内容
</p>
</div>
);
}
return (
<div className="h-full overflow-auto">
<CodeMirror
value={content.code}
readOnly
height="100%"
className="h-full font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!"
theme={resolvedTheme === "dark" ? darkTheme : lightTheme}
extensions={extensions}
onUpdate={handleUpdate}
basicSetup={{
lineNumbers: true,
foldGutter: true,
highlightActiveLine: false,
highlightActiveLineGutter: false,
autocompletion: false,
}}
/>
</div>
);
}
@@ -0,0 +1,146 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { CanvasArtifactText } from "../../types/artifact";
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
interface TextArtifactRendererProps {
content: CanvasArtifactText;
isStreaming?: boolean;
}
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
const { handleTextPointerUp } = useCanvasSelection();
if (!content.fullMarkdown) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-muted-foreground">
空白文档,发送消息让 AI 填充内容
</p>
</div>
);
}
return (
<div
className="h-full overflow-y-auto"
onMouseUp={() => handleTextPointerUp(content.fullMarkdown)}
>
<div className="max-w-3xl mx-auto px-8 py-8">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0 text-foreground border-b border-border pb-2">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-semibold mb-3 mt-6 text-foreground">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-semibold mb-2 mt-4 text-foreground">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-sm font-semibold mb-2 mt-3 text-foreground">
{children}
</h4>
),
p: ({ children }) => (
<p className="mb-4 leading-7 text-sm text-foreground/90">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-4 ml-5 list-disc space-y-1.5 text-sm text-foreground/90">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="mb-4 ml-5 list-decimal space-y-1.5 text-sm text-foreground/90">
{children}
</ol>
),
li: ({ children }) => (
<li className="leading-7">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
{children}
</blockquote>
),
pre: ({ children }) => (
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
{children}
</pre>
),
code: ({ className, children }) => {
const isBlock = Boolean(className?.startsWith("language-"));
if (isBlock) {
return (
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
{children}
</code>
);
}
return (
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
{children}
</code>
);
},
table: ({ children }) => (
<div className="mb-4 overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm border-collapse">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-muted/50">{children}</thead>
),
th: ({ children }) => (
<th className="px-4 py-2 text-left text-xs font-semibold text-foreground border-b border-border">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-2 text-xs text-foreground/80 border-b border-border last:border-0">
{children}
</td>
),
hr: () => <hr className="my-6 border-border" />,
a: ({ href, children }) => (
<a
href={href}
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="font-semibold text-foreground">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic text-foreground/80">{children}</em>
),
}}
>
{content.fullMarkdown}
</ReactMarkdown>
{isStreaming && (
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
)}
</div>
</div>
);
}
@@ -0,0 +1,414 @@
import { Loader2, SendHorizonal, Sparkles, User, X } from "lucide-react";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
import { useCanvasStream } from "../../hooks/useCanvasStream";
import type { CanvasMessage } from "../../types/thread";
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
import {
buildMessageWithSelection,
hasActiveSelection,
resolveCodeSelection,
} from "../../utils/selection";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ClarificationOption = {
id: string;
label: string;
description?: string;
};
type ClarificationPayload = {
question?: string;
context?: string | null;
options?: ClarificationOption[];
allow_custom?: boolean;
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function stripThinkBlocks(text: string): string {
return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
}
function getClarificationPayload(msg: CanvasMessage): ClarificationPayload | null {
const c = msg.additional_kwargs?.clarification;
if (typeof c !== "object" || c === null) return null;
return c as ClarificationPayload;
}
function getTextContent(msg: CanvasMessage): string {
return typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content);
}
// ---------------------------------------------------------------------------
// ClarificationCard
// ---------------------------------------------------------------------------
function ClarificationCard({
message,
disabled,
onSubmit,
}: {
message: CanvasMessage;
disabled: boolean;
onSubmit: (answer: string) => void;
}) {
const payload = getClarificationPayload(message)!;
const [customAnswer, setCustomAnswer] = useState("");
const [submitted, setSubmitted] = useState(false);
const options = (payload.options ?? []).filter(
(o) => typeof o.label === "string" && o.label.trim(),
);
const busy = disabled || submitted;
function submitAnswer(answer: string) {
const trimmed = answer.trim();
if (!trimmed || busy) return;
setSubmitted(true);
onSubmit(trimmed);
}
const fallback = stripThinkBlocks(getTextContent(message));
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-2">
<div className="text-muted-foreground text-xs font-medium">需要你的确认</div>
{payload.context ? (
<p className="text-muted-foreground text-sm leading-6">{payload.context}</p>
) : null}
<div className="text-sm leading-6 font-medium">
{payload.question || fallback}
</div>
</div>
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
{options.map((option) => (
<Button
key={option.id || option.label}
type="button"
variant="outline"
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={busy}
onClick={() => submitAnswer(`我选择:${option.label}`)}
>
<span className="flex min-w-0 flex-col items-start gap-1">
<span className="text-sm">{option.label}</span>
{option.description ? (
<span className="text-muted-foreground text-xs font-normal leading-5">
{option.description}
</span>
) : null}
</span>
</Button>
))}
</div>
) : null}
{payload.allow_custom !== false ? (
<div className="mt-4 flex flex-col gap-2">
<Textarea
value={customAnswer}
onChange={(e) => setCustomAnswer(e.currentTarget.value)}
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
disabled={busy}
className="min-h-20 resize-none text-sm"
/>
<div className="flex justify-end">
<Button
type="button"
size="sm"
disabled={busy || !customAnswer.trim()}
onClick={() => submitAnswer(customAnswer)}
>
<SendHorizonal className="h-4 w-4" />
发送回答
</Button>
</div>
</div>
) : null}
</div>
);
}
// ---------------------------------------------------------------------------
// AiMessageContent — renders a single AI message bubble
// ---------------------------------------------------------------------------
function AiMessageContent({
message,
isActiveStream,
isStreaming,
onClarificationSubmit,
}: {
message: CanvasMessage;
isActiveStream: boolean;
isStreaming: boolean;
onClarificationSubmit: (answer: string) => void;
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isActiveStream);
const clarification = getClarificationPayload(message);
if (clarification) {
return (
<ClarificationCard
message={message}
disabled={isStreaming}
onSubmit={onClarificationSubmit}
/>
);
}
const displayText = stripThinkBlocks(getTextContent(message));
if (!displayText) return null;
return (
<MarkdownContent
content={displayText}
isLoading={isActiveStream}
rehypePlugins={rehypePlugins}
className="text-sm"
/>
);
}
// ---------------------------------------------------------------------------
// ChatWelcome
// ---------------------------------------------------------------------------
function ChatWelcome() {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-6 space-y-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-primary" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">开始创作</p>
<p className="text-xs text-muted-foreground leading-relaxed">
发送消息让 AI 帮你生成文档或代码,
<br />
或在右侧选择快速开始模板
</p>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// CanvasChatPanel
// ---------------------------------------------------------------------------
export function CanvasChatPanel() {
const { messages, isStreaming, artifact } = useCanvasGraphContext();
const { sendMessage } = useCanvasStream();
const { selection, clearSelection } = useCanvasSelection();
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const hasMessages = messages.length > 0;
const hasSelection = hasActiveSelection(selection);
// Resolve selected code snippet when artifact is a code artifact
const currentContent = getCurrentArtifactContent(artifact);
const resolvedCode =
selection.codeSelection && currentContent && isCodeArtifact(currentContent)
? resolveCodeSelection(
currentContent.code,
selection.codeSelection.startCharIndex,
selection.codeSelection.endCharIndex,
)
: undefined;
// Build a short preview for the selection badge
const selectionPreview = (() => {
if (selection.textSelection?.selectedText) {
const t = selection.textSelection.selectedText;
return t.length > 40 ? t.slice(0, 40) + "…" : t;
}
if (resolvedCode) {
return resolvedCode.length > 40
? resolvedCode.slice(0, 40) + "…"
: resolvedCode;
}
return null;
})();
// Scroll to bottom whenever messages change or streaming state updates
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isStreaming]);
async function handleSend() {
const text = input.trim();
if (!text || isStreaming) return;
setInput("");
const finalText = hasSelection
? buildMessageWithSelection(text, selection, resolvedCode)
: text;
clearSelection();
await sendMessage(finalText);
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}
// Index of the last AI message — used to decide which message gets streaming animation
const lastAiIndex = (() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]!.type !== "human") return i;
}
return -1;
})();
// Whether the last AI message has any visible text (not just think blocks / empty)
const lastAiHasVisibleContent = (() => {
if (lastAiIndex < 0) return false;
const msg = messages[lastAiIndex]!;
if (getClarificationPayload(msg)) return true;
return stripThinkBlocks(getTextContent(msg)).trim().length > 0;
})();
return (
<div className="h-full flex flex-col bg-background">
{/* Message list */}
<div className="flex-1 overflow-y-auto min-h-0">
{hasMessages ? (
<div className="p-4 space-y-4">
{messages.map((msg, i) => {
const isHuman = msg.type === "human";
return (
<div
key={msg.id ?? i}
className={`flex gap-2 ${isHuman ? "justify-end" : "justify-start"}`}
>
{!isHuman && (
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<Sparkles className="w-3 h-3 text-primary" />
</div>
)}
{isHuman ? (
<div className="max-w-[80%] rounded-lg px-3 py-2 text-sm bg-primary text-primary-foreground">
{getTextContent(msg)}
</div>
) : (
<div className="max-w-[85%] min-w-0">
<AiMessageContent
message={msg}
isActiveStream={isStreaming && i === lastAiIndex}
isStreaming={isStreaming}
onClarificationSubmit={(answer) => void sendMessage(answer)}
/>
</div>
)}
{isHuman && (
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center shrink-0 mt-0.5">
<User className="w-3 h-3" />
</div>
)}
</div>
);
})}
{isStreaming && !lastAiHasVisibleContent && (
<div className="flex gap-2 justify-start">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<Sparkles className="w-3 h-3 text-primary" />
</div>
<div className="bg-muted rounded-lg px-3 py-2 flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground">正在生成…</span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
) : (
<ChatWelcome />
)}
</div>
{/* Selection badge */}
{hasSelection && selectionPreview && (
<div className="px-3 pt-2 shrink-0">
<Badge
variant="secondary"
className="flex items-center gap-1.5 max-w-full text-xs font-normal py-1 px-2 h-auto"
>
<span className="shrink-0 text-muted-foreground">
{selection.textSelection ? "选中文本:" : "选中代码:"}
</span>
<span className="truncate min-w-0 font-mono">{selectionPreview}</span>
<button
onClick={clearSelection}
className="shrink-0 ml-auto hover:text-foreground text-muted-foreground transition-colors"
aria-label="清除选区"
>
<X className="w-3 h-3" />
</button>
</Badge>
</div>
)}
{/* Input area */}
<div className="border-t p-3 shrink-0">
<div className="flex gap-2 items-end">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
hasSelection
? "针对选中内容发出指令…"
: "发送消息以开始… (Enter 发送,Shift+Enter 换行)"
}
className="min-h-[40px] max-h-[160px] resize-none text-sm"
rows={1}
disabled={isStreaming}
/>
<Button
size="icon"
className="h-9 w-9 shrink-0"
disabled={!input.trim() || isStreaming}
onClick={() => void handleSend()}
>
{isStreaming ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SendHorizonal className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,73 @@
import { useNavigate } from "react-router-dom";
import { ArrowLeft, Loader2, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { getCurrentArtifactContent } from "../../utils/artifact";
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
export function CanvasHeader() {
const navigate = useNavigate();
const { threadId } = useCanvasThreadContext();
const { artifact, isStreaming } = useCanvasGraphContext();
const { isChatCollapsed, setIsChatCollapsed } = useCanvasUIContext();
const currentContent = getCurrentArtifactContent(artifact);
const title = currentContent?.title ?? "新 Canvas";
return (
<div className="h-11 border-b flex items-center px-3 gap-2 shrink-0 bg-background">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => navigate("/page/canvas")}
title="返回 Canvas 列表"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-4" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setIsChatCollapsed(!isChatCollapsed)}
>
{isChatCollapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isChatCollapsed ? "展开聊天区" : "收起聊天区"}
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-4" />
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-sm font-medium truncate">{title}</span>
{isStreaming && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground shrink-0" />
)}
</div>
<CanvasModelSelector />
</div>
);
}
@@ -0,0 +1,58 @@
import { useEffect, useRef } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { CanvasChatPanel } from "../chat/CanvasChatPanel";
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
export function CanvasResizableLayout() {
const { isChatCollapsed } = useCanvasUIContext();
const layoutRef = useRef<GroupImperativeHandle>(null);
useEffect(() => {
layoutRef.current?.setLayout(
isChatCollapsed ? LAYOUT_COLLAPSED : LAYOUT_DEFAULT,
);
}, [isChatCollapsed]);
return (
<div className="flex-1 min-h-0 overflow-hidden">
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
className="h-full"
>
<ResizablePanel
id="chat"
defaultSize={35}
minSize={0}
collapsible
collapsedSize={0}
className="overflow-hidden"
>
<CanvasChatPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
id="artifact"
defaultSize={65}
minSize={40}
className="overflow-hidden"
>
<CanvasArtifactPanel />
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
@@ -0,0 +1,50 @@
import { useEffect } from "react";
import { getCanvasThread } from "../../api/threads";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
import { CanvasHeader } from "./CanvasHeader";
import { CanvasResizableLayout } from "./CanvasResizableLayout";
export function CanvasShell() {
const { threadId, setThreadId } = useCanvasThreadContext();
const { setMessages, setArtifact } = useCanvasGraphContext();
// Load existing thread state when navigating to a saved thread.
// If the thread doesn't exist in the backend (e.g. pre-generated UUID from
// CanvasHomePage or stale URL after a DB reset), clear threadId so that
// the next sendMessage call creates a fresh thread instead of hitting 404.
useEffect(() => {
if (!threadId) return;
let cancelled = false;
getCanvasThread(threadId)
.then((values) => {
if (cancelled) return;
if (!values) {
setThreadId(null);
return;
}
if (values.messages?.length) {
setMessages(values.messages);
}
if (values.artifact) {
setArtifact(values.artifact);
}
})
.catch((err) => {
console.error("[canvas] failed to load thread state:", err);
});
return () => {
cancelled = true;
};
}, [threadId, setMessages, setArtifact, setThreadId]);
return (
<div className="h-full flex flex-col overflow-hidden bg-background">
<CanvasHeader />
<CanvasResizableLayout />
</div>
);
}
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { Cpu } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { loadCanvasModels } from "../../api/models";
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
import type { CanvasModel } from "../../types/model";
export function CanvasModelSelector() {
const { modelName, setModelName } = useCanvasThreadContext();
const [models, setModels] = useState<CanvasModel[]>([]);
useEffect(() => {
loadCanvasModels()
.then((list) => {
setModels(list);
if (!modelName && list.length > 0) {
setModelName(list[0].name);
}
})
.catch(() => {
// fail silently — backend may be unavailable during dev
});
// only run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (models.length === 0) return null;
const selected = models.find((m) => m.name === modelName) ?? models[0];
return (
<Select value={selected.name} onValueChange={setModelName}>
<SelectTrigger className="h-7 text-xs border-dashed w-auto min-w-[120px] max-w-[200px] gap-1.5">
<SelectValue>
<span className="flex items-center gap-1.5 truncate">
{selected.is_local && (
<Cpu className="h-3 w-3 text-muted-foreground shrink-0" />
)}
<span className="truncate">
{selected.display_name ?? selected.name}
</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent align="end">
{models.map((m) => (
<SelectItem key={m.name} value={m.name}>
<div className="flex items-center gap-2 min-w-0">
<span className="truncate">{m.display_name ?? m.name}</span>
{m.is_local && (
<Badge variant="secondary" className="text-[10px] h-4 px-1 shrink-0">
本地
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,43 @@
import type { ProgrammingLanguageOptions } from "../types/artifact";
import type { QuickAction } from "../types/quick-action";
export const CANVAS_AGENT_ID = "canvas_agent";
export const CANVAS_THREAD_METADATA = {
ui_mode: "canvas",
agent_id: CANVAS_AGENT_ID,
} as const;
export const PROGRAMMING_LANGUAGES: Array<{
language: ProgrammingLanguageOptions;
label: string;
}> = [
{ language: "typescript", label: "TypeScript" },
{ language: "javascript", label: "JavaScript" },
{ language: "python", label: "Python" },
{ language: "html", label: "HTML" },
{ language: "cpp", label: "C++" },
{ language: "java", label: "Java" },
{ language: "rust", label: "Rust" },
{ language: "sql", label: "SQL" },
{ language: "json", label: "JSON" },
{ language: "php", label: "PHP" },
{ language: "xml", label: "XML" },
{ language: "clojure", label: "Clojure" },
{ language: "csharp", label: "C#" },
{ language: "other", label: "Other" },
];
export const QUICK_ACTIONS_TEXT: QuickAction[] = [
{ id: "expand", label: "扩写", applicable_to: "text" },
{ id: "shorten", label: "精简", applicable_to: "text" },
{ id: "formalize", label: "改正式语气", applicable_to: "text" },
{ id: "casualize", label: "改口语语气", applicable_to: "text" },
];
export const QUICK_ACTIONS_CODE: QuickAction[] = [
{ id: "add_comments", label: "加注释", applicable_to: "code" },
{ id: "add_logs", label: "加日志", applicable_to: "code" },
{ id: "fix_bugs", label: "修复 Bug", applicable_to: "code" },
{ id: "optimize_code", label: "优化代码", applicable_to: "code" },
];
@@ -0,0 +1,79 @@
import {
createContext,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
import type { CanvasArtifact } from "../types/artifact";
import type { CanvasMessage } from "../types/thread";
import type { StreamingArtifactPartial } from "../api/runs";
export interface CanvasGraphContextValue {
messages: CanvasMessage[];
artifact: CanvasArtifact | null;
streamingArtifact: StreamingArtifactPartial | null;
isStreaming: boolean;
setMessages: (messages: CanvasMessage[]) => void;
/** Upsert a message by id — concatenates string content for streaming deltas. */
appendMessage: (msg: CanvasMessage) => void;
setArtifact: (artifact: CanvasArtifact | null) => void;
setStreamingArtifact: (partial: StreamingArtifactPartial | null) => void;
setIsStreaming: (v: boolean) => void;
}
const CanvasGraphContext = createContext<CanvasGraphContextValue | null>(null);
export function CanvasGraphProvider({ children }: { children: ReactNode }) {
const [messages, setMessages] = useState<CanvasMessage[]>([]);
const [artifact, setArtifact] = useState<CanvasArtifact | null>(null);
const [streamingArtifact, setStreamingArtifact] =
useState<StreamingArtifactPartial | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const appendMessage = useCallback((msg: CanvasMessage) => {
setMessages((prev) => {
if (!msg.id) return [...prev, msg];
const idx = prev.findIndex((m) => m.id === msg.id);
if (idx >= 0) {
const existing = prev[idx]!;
const existingText =
typeof existing.content === "string" ? existing.content : "";
const deltaText =
typeof msg.content === "string" ? msg.content : "";
const next = [...prev];
next[idx] = { ...existing, content: existingText + deltaText };
return next;
}
return [...prev, msg];
});
}, []);
return (
<CanvasGraphContext.Provider
value={{
messages,
artifact,
streamingArtifact,
isStreaming,
setMessages,
appendMessage,
setArtifact,
setStreamingArtifact,
setIsStreaming,
}}
>
{children}
</CanvasGraphContext.Provider>
);
}
export function useCanvasGraphContext() {
const ctx = useContext(CanvasGraphContext);
if (!ctx)
throw new Error(
"useCanvasGraphContext must be used within CanvasGraphProvider",
);
return ctx;
}
@@ -0,0 +1,40 @@
import { createContext, useContext, useState, type ReactNode } from "react";
export interface CanvasThreadContextValue {
threadId: string | null;
modelName: string | null;
setThreadId: (id: string | null) => void;
setModelName: (name: string | null) => void;
}
const CanvasThreadContext = createContext<CanvasThreadContextValue | null>(null);
export function CanvasThreadProvider({
children,
initialThreadId,
}: {
children: ReactNode;
initialThreadId?: string | null;
}) {
const [threadId, setThreadId] = useState<string | null>(
initialThreadId ?? null,
);
const [modelName, setModelName] = useState<string | null>(null);
return (
<CanvasThreadContext.Provider
value={{ threadId, setThreadId, modelName, setModelName }}
>
{children}
</CanvasThreadContext.Provider>
);
}
export function useCanvasThreadContext() {
const ctx = useContext(CanvasThreadContext);
if (!ctx)
throw new Error(
"useCanvasThreadContext must be used within CanvasThreadProvider",
);
return ctx;
}
@@ -0,0 +1,79 @@
import {
createContext,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
import type {
CanvasCodeSelection,
CanvasSelectionState,
CanvasTextSelection,
} from "../types/selection";
export interface CanvasUIContextValue {
isChatCollapsed: boolean;
isEditing: boolean;
isWebSearchOpen: boolean;
selection: CanvasSelectionState;
setIsChatCollapsed: (v: boolean) => void;
setIsEditing: (v: boolean) => void;
setIsWebSearchOpen: (v: boolean) => void;
/** Set text selection; clears any existing code selection. */
setTextSelection: (s: CanvasTextSelection | null) => void;
/** Set code selection; clears any existing text selection. */
setCodeSelection: (s: CanvasCodeSelection | null) => void;
clearSelection: () => void;
}
const EMPTY_SELECTION: CanvasSelectionState = {
textSelection: null,
codeSelection: null,
};
const CanvasUIContext = createContext<CanvasUIContextValue | null>(null);
export function CanvasUIProvider({ children }: { children: ReactNode }) {
const [isChatCollapsed, setIsChatCollapsed] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isWebSearchOpen, setIsWebSearchOpen] = useState(false);
const [selection, setSelection] =
useState<CanvasSelectionState>(EMPTY_SELECTION);
const setTextSelection = useCallback((s: CanvasTextSelection | null) => {
setSelection({ textSelection: s, codeSelection: null });
}, []);
const setCodeSelection = useCallback((s: CanvasCodeSelection | null) => {
setSelection({ textSelection: null, codeSelection: s });
}, []);
return (
<CanvasUIContext.Provider
value={{
isChatCollapsed,
isEditing,
isWebSearchOpen,
selection,
setIsChatCollapsed,
setIsEditing,
setIsWebSearchOpen,
setTextSelection,
setCodeSelection,
clearSelection: () => setSelection(EMPTY_SELECTION),
}}
>
{children}
</CanvasUIContext.Provider>
);
}
export function useCanvasUIContext() {
const ctx = useContext(CanvasUIContext);
if (!ctx)
throw new Error(
"useCanvasUIContext must be used within CanvasUIProvider",
);
return ctx;
}
@@ -0,0 +1,2 @@
// Phase 6-7: artifact version management helpers
export {};
@@ -0,0 +1,25 @@
import { useCallback } from "react";
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
import type { QuickActionId } from "../types/quick-action";
import { getCurrentArtifactContent } from "../utils/artifact";
import { buildQuickActionMessage } from "../utils/quick-actions";
import { useCanvasStream } from "./useCanvasStream";
export function useCanvasQuickActions() {
const { sendMessage } = useCanvasStream();
const { artifact } = useCanvasGraphContext();
const triggerQuickAction = useCallback(
async (actionId: QuickActionId) => {
const content = getCurrentArtifactContent(artifact);
if (!content) return;
const message = buildQuickActionMessage(actionId);
await sendMessage(message, { quick_action_id: actionId });
},
[sendMessage, artifact],
);
return { triggerQuickAction };
}
@@ -0,0 +1,43 @@
import { useCallback } from "react";
import { useCanvasUIContext } from "../contexts/CanvasUIContext";
import { captureTextSelection } from "../utils/selection";
export function useCanvasSelection() {
const { selection, setTextSelection, setCodeSelection, clearSelection } =
useCanvasUIContext();
/**
* Call from a text renderer's onMouseUp handler.
* Reads window.getSelection() and stores the result.
*/
const handleTextPointerUp = useCallback(
(fullMarkdown: string) => {
const captured = captureTextSelection(fullMarkdown);
setTextSelection(captured);
},
[setTextSelection],
);
/**
* Call from CodeMirror's onUpdate/onStatistics handler.
* Pass the selection range (from/to char indices).
*/
const handleCodeSelectionChange = useCallback(
(from: number, to: number) => {
if (from === to) {
setCodeSelection(null);
} else {
setCodeSelection({ startCharIndex: from, endCharIndex: to });
}
},
[setCodeSelection],
);
return {
selection,
handleTextPointerUp,
handleCodeSelectionChange,
clearSelection,
};
}
@@ -0,0 +1,105 @@
import { useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { streamCanvasRun } from "../api/runs";
import type { CanvasRunInput } from "../api/runs";
import { createCanvasThread } from "../api/threads";
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
import { useCanvasThreadContext } from "../contexts/CanvasThreadContext";
import type { CanvasMessage } from "../types/thread";
export function useCanvasStream() {
const navigate = useNavigate();
const { threadId, setThreadId, modelName } = useCanvasThreadContext();
const {
messages,
setMessages,
appendMessage,
setArtifact,
setStreamingArtifact,
setIsStreaming,
} = useCanvasGraphContext();
// Ref so callbacks inside streamCanvasRun always see the latest messages
// without adding `messages` to useCallback's dep array (avoids re-creation
// on every message append during streaming).
const messagesRef = useRef(messages);
messagesRef.current = messages;
const sendMessage = useCallback(
async (
text: string,
extra?: Partial<Omit<CanvasRunInput, "messages">>,
) => {
// 1. Ensure we have a thread
let activeThreadId = threadId;
if (!activeThreadId) {
activeThreadId = await createCanvasThread(
modelName ? { model_name: modelName } : undefined,
);
setThreadId(activeThreadId);
navigate(`/page/canvas/${activeThreadId}`, { replace: true });
}
// 2. Optimistic human message
const humanMsg: CanvasMessage = {
id: `human-${Date.now()}`,
type: "human",
content: text,
};
setMessages([...messagesRef.current, humanMsg]);
setIsStreaming(true);
// 3. Build run input
const input: CanvasRunInput = {
messages: [{ type: "human", content: text }],
...extra,
};
// 4. Stream
await streamCanvasRun(
activeThreadId,
input,
{
onMessage(msg) {
if (msg.type === "ai" || msg.type === "AIMessageChunk") {
appendMessage(msg);
}
},
onStreamingArtifact(partial) {
setStreamingArtifact(partial);
},
onArtifact(artifact) {
setArtifact(artifact);
setStreamingArtifact(null);
},
onDone() {
setIsStreaming(false);
setStreamingArtifact(null);
},
onError(err) {
console.error("[canvas] stream error:", err);
setIsStreaming(false);
setStreamingArtifact(null);
},
},
modelName ?? undefined,
);
},
// messagesRef replaces direct `messages` dep — intentional
// eslint-disable-next-line react-hooks/exhaustive-deps
[
threadId,
modelName,
setThreadId,
navigate,
appendMessage,
setMessages,
setArtifact,
setStreamingArtifact,
setIsStreaming,
],
);
return { sendMessage };
}
@@ -0,0 +1 @@
export { useCanvasThreadContext as useCanvasThread } from "../contexts/CanvasThreadContext";
@@ -0,0 +1,106 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { FileText, Code2, Sparkles, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { createCanvasThread } from "../api/threads";
const QUICK_START_ITEMS = [
{
type: "text" as const,
icon: FileText,
title: "新建文本文档",
description: "撰写和编辑 Markdown 文档,AI 实时协作修改",
},
{
type: "code" as const,
icon: Code2,
title: "新建代码",
description: "编写和重构多语言代码,AI 辅助生成与优化",
},
];
export default function CanvasHomePage() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
async function handleQuickStart(type: "text" | "code") {
if (loading) return;
setLoading(true);
try {
const threadId = await createCanvasThread();
navigate(`${threadId}?type=${type}`);
} catch (err) {
console.error("[canvas] failed to create thread:", err);
setLoading(false);
}
}
async function handleBlankCanvas() {
if (loading) return;
setLoading(true);
try {
const threadId = await createCanvasThread();
navigate(threadId);
} catch (err) {
console.error("[canvas] failed to create thread:", err);
setLoading(false);
}
}
return (
<div className="h-full flex flex-col items-center justify-center bg-background px-6">
<div className="w-full max-w-xl space-y-10">
<div className="text-center space-y-3">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 mb-2">
<Sparkles className="w-6 h-6 text-primary" />
</div>
<h1 className="text-3xl font-bold tracking-tight">Canvas 模式</h1>
<p className="text-muted-foreground text-base leading-relaxed">
在左侧聊天区与 AI 协作,右侧实时生成和编辑文档或代码
</p>
</div>
<div className="grid grid-cols-2 gap-4">
{QUICK_START_ITEMS.map(({ type, icon: Icon, title, description }) => (
<button
key={type}
onClick={() => void handleQuickStart(type)}
disabled={loading}
className="group flex flex-col gap-3 rounded-xl border bg-card p-5 text-left transition-all hover:border-primary/50 hover:shadow-md hover:bg-accent/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-muted group-hover:bg-primary/10 transition-colors">
{loading ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<Icon className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
)}
</div>
<div className="space-y-1">
<p className="font-medium text-sm">{title}</p>
<p className="text-xs text-muted-foreground leading-relaxed">
{description}
</p>
</div>
</button>
))}
</div>
<div className="text-center">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground text-xs"
onClick={() => void handleBlankCanvas()}
disabled={loading}
>
{loading ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : null}
或直接开始空白 Canvas
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import { useParams } from "react-router-dom";
import { CanvasThreadProvider } from "../contexts/CanvasThreadContext";
import { CanvasGraphProvider } from "../contexts/CanvasGraphContext";
import { CanvasUIProvider } from "../contexts/CanvasUIContext";
import { CanvasShell } from "../components/layout/CanvasShell";
export default function CanvasThreadPage() {
const { thread_id } = useParams<{ thread_id: string }>();
return (
<CanvasThreadProvider initialThreadId={thread_id}>
<CanvasGraphProvider>
<CanvasUIProvider>
<CanvasShell />
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
);
}
@@ -0,0 +1,13 @@
import { Route, Routes } from "react-router-dom";
import CanvasHomePage from "../pages/CanvasHomePage";
import CanvasThreadPage from "../pages/CanvasThreadPage";
export default function OpenCanvasRoutes() {
return (
<Routes>
<Route index element={<CanvasHomePage />} />
<Route path=":thread_id" element={<CanvasThreadPage />} />
</Routes>
);
}
@@ -0,0 +1,39 @@
export type ProgrammingLanguageOptions =
| "typescript"
| "javascript"
| "cpp"
| "java"
| "php"
| "python"
| "html"
| "sql"
| "json"
| "rust"
| "xml"
| "clojure"
| "csharp"
| "other";
export type ArtifactType = "code" | "text";
export type CanvasArtifactText = {
index: number;
type: "text";
title: string;
fullMarkdown: string;
};
export type CanvasArtifactCode = {
index: number;
type: "code";
title: string;
language: ProgrammingLanguageOptions;
code: string;
};
export type CanvasArtifactContent = CanvasArtifactText | CanvasArtifactCode;
export type CanvasArtifact = {
currentIndex: number;
contents: CanvasArtifactContent[];
};
@@ -0,0 +1,15 @@
export type CanvasModel = {
name: string;
display_name: string;
provider?: string;
is_local?: boolean;
supports_vision?: boolean;
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
supports_tool_calling?: boolean;
supports_canvas_generation?: boolean;
supports_canvas_rewrite?: boolean;
supports_canvas_code_edit?: boolean;
supports_streaming_text?: boolean;
supports_streaming_tool_calls?: boolean;
};
@@ -0,0 +1,20 @@
export type QuickActionApplicableTo = "text" | "code" | "both";
export type QuickAction = {
id: string;
label: string;
applicable_to: QuickActionApplicableTo;
prompt_template?: string;
};
export type QuickActionId =
| "expand"
| "shorten"
| "formalize"
| "casualize"
| "add_comments"
| "add_logs"
| "fix_bugs"
| "optimize_code"
| "port_language"
| string;
@@ -0,0 +1,15 @@
export type CanvasTextSelection = {
fullMarkdown: string;
markdownBlock: string;
selectedText: string;
};
export type CanvasCodeSelection = {
startCharIndex: number;
endCharIndex: number;
};
export type CanvasSelectionState = {
textSelection: CanvasTextSelection | null;
codeSelection: CanvasCodeSelection | null;
};
@@ -0,0 +1,22 @@
import type { CanvasArtifact } from "./artifact";
export type CanvasMessage = {
id?: string;
type: string;
content: string | unknown[];
additional_kwargs?: Record<string, unknown>;
};
export type CanvasThreadValues = {
title?: string;
messages?: CanvasMessage[];
artifact?: CanvasArtifact;
web_search_results?: unknown[];
todos?: unknown[];
};
export type CanvasThreadMetadata = {
ui_mode: "canvas";
agent_id: "canvas_agent";
model_name?: string;
};
@@ -0,0 +1,84 @@
import type {
CanvasArtifact,
CanvasArtifactContent,
CanvasArtifactText,
CanvasArtifactCode,
ProgrammingLanguageOptions,
} from "../types/artifact";
export function getCurrentArtifactContent(
artifact: CanvasArtifact | null,
): CanvasArtifactContent | null {
if (!artifact || artifact.contents.length === 0) return null;
return (
artifact.contents.find((c) => c.index === artifact.currentIndex) ??
artifact.contents[artifact.contents.length - 1]
);
}
export function isTextArtifact(
content: CanvasArtifactContent,
): content is CanvasArtifactText {
return content.type === "text";
}
export function isCodeArtifact(
content: CanvasArtifactContent,
): content is CanvasArtifactCode {
return content.type === "code";
}
export function appendArtifactVersion(
artifact: CanvasArtifact,
content: Omit<CanvasArtifactContent, "index">,
): CanvasArtifact {
const nextIndex = artifact.contents.length + 1;
const newContent = { ...content, index: nextIndex } as CanvasArtifactContent;
return {
currentIndex: nextIndex,
contents: [...artifact.contents, newContent],
};
}
export function setCurrentArtifactIndex(
artifact: CanvasArtifact,
index: number,
): CanvasArtifact {
return { ...artifact, currentIndex: index };
}
export function createDraftArtifact(
type: "text" | "code",
options?: {
title?: string;
content?: string;
language?: ProgrammingLanguageOptions;
},
): CanvasArtifact {
const { title, content = "", language = "python" } = options ?? {};
if (type === "text") {
return {
currentIndex: 1,
contents: [
{
index: 1,
type: "text",
title: title ?? "未命名文档",
fullMarkdown: content,
},
],
};
}
return {
currentIndex: 1,
contents: [
{
index: 1,
type: "code",
title: title ?? "未命名代码",
language,
code: content,
},
],
};
}
@@ -0,0 +1,16 @@
import type { QuickActionId } from "../types/quick-action";
const QUICK_ACTION_MESSAGES: Record<string, string> = {
expand: "请对当前内容进行扩写,增加深度和细节,保持原有结构不变",
shorten: "请对当前内容进行精简,提炼核心要点,去除冗余",
formalize: "请将当前内容改写为正式、专业的语气",
casualize: "请将当前内容改写为口语化、友好的语气",
add_comments: "请为当前代码添加清晰实用的注释",
add_logs: "请为当前代码在关键位置添加适当的日志语句",
fix_bugs: "请检查并修复当前代码中的所有 Bug",
optimize_code: "请对当前代码进行性能和可读性优化",
};
export function buildQuickActionMessage(id: QuickActionId): string {
return QUICK_ACTION_MESSAGES[id] ?? `请对当前内容执行操作:${id}`;
}
@@ -0,0 +1,98 @@
import type {
CanvasSelectionState,
CanvasTextSelection,
} from "../types/selection";
export function hasActiveSelection(selection: CanvasSelectionState): boolean {
return selection.textSelection !== null || selection.codeSelection !== null;
}
/**
* Find the nearest line-level block in markdown that contains selectedText.
* Falls back to the selectedText itself when no clean boundary is found.
*/
function findMarkdownBlock(fullMarkdown: string, selectedText: string): string {
const idx = fullMarkdown.indexOf(selectedText);
if (idx === -1) return selectedText;
let blockStart = idx;
while (blockStart > 0 && fullMarkdown[blockStart - 1] !== "\n") {
blockStart--;
}
let blockEnd = idx + selectedText.length;
while (blockEnd < fullMarkdown.length && fullMarkdown[blockEnd] !== "\n") {
blockEnd++;
}
return fullMarkdown.slice(blockStart, blockEnd).trim();
}
/**
* Read the browser selection and map it to a CanvasTextSelection.
* Returns null when nothing meaningful is selected.
*/
export function captureTextSelection(
fullMarkdown: string,
): CanvasTextSelection | null {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return null;
const selectedText = sel.toString().trim();
if (!selectedText) return null;
return {
fullMarkdown,
markdownBlock: findMarkdownBlock(fullMarkdown, selectedText),
selectedText,
};
}
/**
* Extract the code snippet from a full code string given char indices.
*/
export function resolveCodeSelection(
code: string,
start: number,
end: number,
): string {
return code.slice(start, end).trim();
}
/**
* Build the human message with embedded selection context so the canvas_agent
* knows exactly which portion to rewrite.
*/
export function buildMessageWithSelection(
userText: string,
selection: CanvasSelectionState,
resolvedCode?: string,
): string {
const { textSelection, codeSelection } = selection;
if (textSelection?.selectedText) {
return [
"请针对以下选中的文本内容进行操作:",
"",
"<selection>",
textSelection.selectedText,
"</selection>",
"",
`用户指令:${userText}`,
].join("\n");
}
if (codeSelection && resolvedCode) {
return [
"请针对以下选中的代码片段进行操作:",
"",
"<selection>",
resolvedCode,
"</selection>",
"",
`用户指令:${userText}`,
].join("\n");
}
return userText;
}
@@ -0,0 +1,2 @@
// Phase 6: parse LangGraph stream events into messages and artifact updates
export {};
@@ -0,0 +1,2 @@
// Phase 5: thread creation, metadata tagging, and state recovery helpers
export {};
+42 -12
View File
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts";
import {
ChatBox,
useSpecificChatMode,
@@ -44,6 +44,7 @@ export default function ChatPage() {
const { tokenUsageEnabled } = useModels();
const { data: promptPrefixSettings } = usePromptPrefixSettings();
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
const { selectedArtifact } = useArtifacts();
useSpecificChatMode();
useEffect(() => {
@@ -91,9 +92,22 @@ export default function ChatPage() {
},
});
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
!!path && /\.(md|markdown)(?:$|[?#])/i.test(path);
if (isMarkdown(selectedArtifact)) {
return selectedArtifact ?? undefined;
}
return [...(thread.values.artifacts ?? [])]
.reverse()
.find((path) => isMarkdown(path));
}, [selectedArtifact, thread.values.artifacts]);
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
// Only the opening turn of a brand-new thread may carry the prefix.
const shouldApplyPrefix =
isNewThread &&
!hasStartedConversation &&
promptPrefixOn &&
promptPrefixSettings?.enabled === true;
@@ -101,19 +115,34 @@ export default function ChatPage() {
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
const messageToSend = shouldApplyPrefix
? {
...message,
text: `${promptPrefixSettings?.prompt_prefix ?? ""}\n\n${message.text}`,
}
: message;
// The prefix is intentionally NOT concatenated into the message text:
// doing so would make the user see it. We only flag the request via
// context; the backend tags the message and PromptPrefixMiddleware
// injects the prefix into the LLM request only. Writing mode adds the
// active markdown artifact path so the agent keeps editing the canvas.
const extraContext: Record<string, unknown> = {};
if (shouldApplyPrefix) {
extraContext.prompt_prefix_enabled = true;
}
if (settings.context.writing_mode && writingArtifactPath) {
extraContext.writing_artifact_path = writingArtifactPath;
}
void sendMessage(
threadId,
messageToSend,
shouldApplyPrefix ? { prompt_prefix_enabled: true } : undefined,
message,
Object.keys(extraContext).length > 0 ? extraContext : undefined,
);
},
[sendMessage, threadId, hasStartedConversation, promptPrefixOn, promptPrefixSettings],
[
sendMessage,
threadId,
isNewThread,
hasStartedConversation,
promptPrefixOn,
promptPrefixSettings,
settings.context.writing_mode,
writingArtifactPath,
],
);
const handleClarificationSubmit = useCallback(
@@ -129,13 +158,14 @@ export default function ChatPage() {
const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined;
if (hasStartedConversation) return undefined;
// The prefix toggle is offered only on the opening turn of a new thread.
if (!isNewThread || hasStartedConversation) return undefined;
return {
label: promptPrefixSettings.switch_label || "前缀",
on: promptPrefixOn,
onToggle: () => setPromptPrefixOn((v) => !v),
};
}, [promptPrefixSettings, hasStartedConversation, promptPrefixOn]);
}, [promptPrefixSettings, isNewThread, hasStartedConversation, promptPrefixOn]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
+1 -1
View File
@@ -54,7 +54,7 @@ export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm">
<h1 className="text-xl font-semibold">正在进入专家模式</h1>
<h1 className="text-xl font-semibold">正在进入智能体模式</h1>
{status === "error" && (
<p className="mt-4 text-sm text-red-600">
登录失败:{errorMessage || "未知错误"}
+12 -12
View File
@@ -153,7 +153,7 @@ export default function NewAgentPage() {
// the user wants the name box to stay blank while they chat, and we
// refill it later (with the model's proposed name) on setup_agent
// approval.
name = `未命名专家-${Date.now().toString(36)}`;
name = `未命名智能体-${Date.now().toString(36)}`;
}
const modelOverride = modelInput.trim() || null;
@@ -226,7 +226,7 @@ export default function NewAgentPage() {
if (!saved) {
return;
}
toast.success(`专家「${saved.name}」已保存`);
toast.success(`智能体「${saved.name}」已保存`);
}, [ensureAgent]);
const handleChatSubmit = useCallback(
@@ -270,7 +270,7 @@ export default function NewAgentPage() {
const handleRejectSetupAgent = useCallback(
async () => {
await handleChatSubmit("我暂时不批准创建这个专家配置,请继续帮我调整。");
await handleChatSubmit("我暂时不批准创建这个智能体配置,请继续帮我调整。");
},
[handleChatSubmit],
);
@@ -300,7 +300,7 @@ export default function NewAgentPage() {
>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h1 className="text-sm font-semibold">创建专家</h1>
<h1 className="text-sm font-semibold">创建智能体</h1>
</div>
{agent ? (
<Button
@@ -353,7 +353,7 @@ export default function NewAgentPage() {
onSubmit={({ text }) => void handleChatSubmit(text)}
>
<PromptInputTextarea
placeholder="直接告诉模型你想要什么样的专家,或回答模型的问题"
placeholder="直接告诉模型你想要什么样的智能体,或回答模型的问题"
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
@@ -366,16 +366,16 @@ export default function NewAgentPage() {
<aside className="bg-background min-h-0 overflow-y-auto">
<div className="space-y-5 p-4">
<div>
<h2 className="text-sm font-semibold">专家配置</h2>
<h2 className="text-sm font-semibold">智能体配置</h2>
<p className="text-muted-foreground mt-1 text-xs">
这些内容会作为创建专家时的初始配置。
这些内容会作为创建智能体时的初始配置。
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">名称</label>
<Input
placeholder="例如:合同审查专家"
placeholder="例如:合同审查智能体"
value={nameInput}
onChange={(event) => {
setNameInput(event.currentTarget.value);
@@ -388,7 +388,7 @@ export default function NewAgentPage() {
<div className="space-y-2">
<label className="text-sm font-medium">描述</label>
<Textarea
placeholder="简要说明这个专家擅长什么"
placeholder="简要说明这个智能体擅长什么"
value={descriptionInput}
onChange={(event) =>
setDescriptionInput(event.currentTarget.value)
@@ -415,14 +415,14 @@ export default function NewAgentPage() {
))}
</select>
<p className="text-muted-foreground text-xs">
该专家被使用时调用的模型。
该智能体被使用时调用的模型。
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">SOUL.md</label>
<Textarea
placeholder="写下专家的人设、边界、工作方式和输出要求"
placeholder="写下智能体的人设、边界、工作方式和输出要求"
value={soulInput}
onChange={(event) => setSoulInput(event.currentTarget.value)}
className="min-h-48 max-h-[400px] resize-y overflow-y-auto font-mono text-xs"
@@ -479,7 +479,7 @@ export default function NewAgentPage() {
disabled={busy || isSkillsLoading}
>
<SaveIcon className="h-4 w-4" />
{agentId ? "保存专家" : "创建专家"}
{agentId ? "保存智能体" : "创建智能体"}
</Button>
</div>
</aside>
+2
View File
@@ -6,6 +6,7 @@ import { PageSidebar } from "@/components/page-sidebar";
import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes";
import Header from "@/strategy-components/Header";
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
// 设为 true 时隐藏侧边栏(开发中,暂未完成)
const SIDEBAR_ENABLED = false;
@@ -41,6 +42,7 @@ function PageLayout() {
}
/>
<Route path="strategy/*" element={<StrategyRoutes />} />
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
</Routes>
</main>
</div>
+45 -1
View File
@@ -15,6 +15,7 @@ import {
Play,
Plus,
RefreshCw,
TagsIcon,
Trash2,
} from "lucide-react";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
@@ -35,7 +36,11 @@ import {
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import {
downloadScheduledTaskRun,
type PublishedScheduledTask,
@@ -283,7 +288,10 @@ const emptyForm = {
export default function ScheduledTasksPage() {
const navigate = useNavigate();
const { data: tasks = [], isLoading } = useScheduledTasks();
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagId });
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
const { agents } = useAgents();
const actions = useScheduledTaskActions();
@@ -299,6 +307,8 @@ export default function ScheduledTasksPage() {
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
const [subscribeNotify, setSubscribeNotify] = useState(false);
const [tagPickerTask, setTagPickerTask] = useState<ScheduledTask | null>(null);
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const sortedTasks = useMemo(
() =>
@@ -466,6 +476,14 @@ export default function ScheduledTasksPage() {
<TabsTrigger value="plaza" className="flex-1">广场</TabsTrigger>
</TabsList>
</Tabs>
{activeTab === "mine" && (
<TagSearchBar
targetType="scheduled_task"
onSearchChange={setSearch}
tagId={tagId}
onTagIdChange={setTagId}
/>
)}
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
@@ -527,7 +545,23 @@ export default function ScheduledTasksPage() {
</div>
)}
</div>
{(task.tags?.length ?? 0) > 0 && (
<TagChipList tags={task.tags!} className="mt-3" />
)}
<div className="mt-3 flex flex-wrap gap-2">
{isAdmin && (
<Button
size="icon"
variant="outline"
title="编辑标签"
onClick={(event) => {
event.stopPropagation();
setTagPickerTask(task);
}}
>
<TagsIcon className="size-4" />
</Button>
)}
{isOwner ? (
<>
<Button
@@ -934,6 +968,16 @@ export default function ScheduledTasksPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{isAdmin && tagPickerTask && (
<TagPickerDialog
open={!!tagPickerTask}
onOpenChange={(open) => !open && setTagPickerTask(null)}
targetType="scheduled_task"
targetId={tagPickerTask.task_id}
currentTags={tagPickerTask.tags ?? []}
/>
)}
</WorkspaceContainer>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { TagManagementPage } from "@/components/workspace/admin/tag-management-page";
export default function TagsPage() {
return <TagManagementPage />;
}
@@ -26,6 +26,7 @@ import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
export function WorkspaceLayout({
@@ -192,6 +193,18 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<TagsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/recommended-questions"
element={