feat(agent-ui): add inline skill and canvas references

This commit is contained in:
yu
2026-08-06 19:11:40 +08:00
parent 10dc65d2d6
commit 89a4e192e5
15 changed files with 758 additions and 232 deletions
@@ -0,0 +1,36 @@
import { FileText, Image as ImageIcon, Music2, Video } from "lucide-react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { canvasThemes } from "@/lib/canvas-theme";
import type { AgentCanvasReference } from "@/stores/use-agent-store";
export function AgentCanvasReferencePreview({ reference, previewUrl, previewText, theme }: { reference: AgentCanvasReference; previewUrl?: string; previewText?: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const { t } = useTranslation();
const Icon = canvasReferenceIcon(reference.kind);
return (
<div className="w-64" style={{ color: theme.node.text }}>
<div className="mb-2 flex min-w-0 items-center gap-2">
<Icon className="size-4 shrink-0" style={{ color: theme.node.muted }} />
<span className="truncate text-sm font-medium">{reference.title}</span>
</div>
{reference.kind === "image" && previewUrl ? <img src={previewUrl} alt={reference.title} className="max-h-64 w-full rounded-md object-contain" /> : null}
{reference.kind === "video" && previewUrl ? <video src={previewUrl} controls preload="metadata" className="max-h-64 w-full rounded-md" /> : null}
{reference.kind === "audio" && previewUrl ? <audio src={previewUrl} controls preload="metadata" className="w-full" /> : null}
{reference.kind === "text" && previewText ? (
<div className="thin-scrollbar max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-md border px-3 py-2 text-xs leading-5" style={{ borderColor: theme.node.stroke }}>
{previewText}
</div>
) : null}
{!previewUrl && !(reference.kind === "text" && previewText) ? <div className="py-3 text-center text-xs" style={{ color: theme.node.muted }}>{t("agent.composer.mentions.previewUnavailable")}</div> : null}
</div>
);
}
export function canvasReferenceIcon(kind: AgentCanvasReference["kind"]) {
return kind === "audio" ? Music2 : kind === "video" ? Video : kind === "image" ? ImageIcon : FileText;
}
export function canvasReferenceKindLabel(kind: AgentCanvasReference["kind"]) {
return i18n.t(`agent.composer.mentions.kind.${kind}`);
}
@@ -5,11 +5,9 @@ import { useTranslation } from "react-i18next";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { canvasThemes } from "@/lib/canvas-theme";
import { isPlainEnterKey } from "@/lib/keyboard-event";
import { useAgentSkillStore } from "@/stores/use-agent-skill-store";
import type { AgentModel, AgentPermissionMode, AgentReasoningEffort } from "@/stores/use-agent-store";
import { useAgentStore, type AgentModel, type AgentPermissionMode, type AgentReasoningEffort } from "@/stores/use-agent-store";
import type { AgentChatAttachment } from "./agent-chat-message";
import { AgentSkillPicker } from "./agent-skill-picker";
import { AgentChatPromptInput } from "./agent-chat-prompt-input";
export function AgentChatComposer({
prompt,
@@ -58,9 +56,8 @@ export function AgentChatComposer({
}) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const selectedSkill = useAgentSkillStore((state) => state.selectedSkill);
const clearSkillSelection = useAgentSkillStore((state) => state.clearSelection);
const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length);
const canvasReferences = useAgentStore((state) => state.canvasReferences);
const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length || canvasReferences.length);
return (
<div className="px-2 pb-2 pt-2" onWheelCapture={(event) => event.stopPropagation()}>
<div className="rounded-[24px] border px-3 pb-3 pt-3 shadow-lg" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke }}>
@@ -78,36 +75,7 @@ export function AgentChatComposer({
))}
</div>
) : null}
{selectedSkill ? (
<div className="mb-2 flex items-center px-1">
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<span className="truncate">{selectedSkill.interface?.displayName || selectedSkill.name}</span>
<button type="button" className="grid size-4 shrink-0 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" onClick={() => clearSkillSelection()} aria-label={t("agent.composer.removeSkill")}>
<X className="size-3" />
</button>
</span>
</div>
) : null}
<textarea
value={prompt}
onChange={(event) => onPromptChange(event.target.value)}
onPaste={(event) => {
if (!onAddFiles) return;
const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
if (!images.length) return;
event.preventDefault();
void onAddFiles(images);
}}
onKeyDown={(event) => {
if (!isPlainEnterKey(event)) return;
event.preventDefault();
if (!canSubmit) return;
void onSubmit();
}}
className="thin-scrollbar max-h-32 min-h-20 w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
style={{ color: theme.node.text }}
placeholder={placeholder}
/>
<AgentChatPromptInput value={prompt} disabled={disabled || sending} placeholder={placeholder} theme={theme} onChange={onPromptChange} onSubmit={() => { if (canSubmit) void onSubmit(); }} onAddFiles={onAddFiles} />
<div className="@container mt-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1">
{onAddFiles ? (
@@ -121,7 +89,6 @@ export function AgentChatComposer({
</Tooltip>
</>
) : null}
<AgentSkillPicker />
{onConfirmToolsChange ? <ToolConfirmationMenu confirmTools={Boolean(confirmTools)} theme={theme} onChange={onConfirmToolsChange} /> : null}
{permissionMode && onPermissionModeChange ? <PermissionModeMenu permissionMode={permissionMode} theme={theme} onChange={onPermissionModeChange} /> : null}
{models?.length && model && reasoningEffort && onModelChange && onReasoningEffortChange ? <AgentModelControls models={models} model={model} reasoningEffort={reasoningEffort} onModelChange={onModelChange} onReasoningEffortChange={onReasoningEffortChange} /> : null}
@@ -0,0 +1,48 @@
import type { AgentCanvasReference, AgentSkillReference } from "@/stores/use-agent-store";
export const agentInlineTokenClass = "mx-0.5 inline-block h-7 whitespace-nowrap rounded-md border px-1.5 align-baseline text-sm leading-[26px]";
export const agentInlineTokenMediaClass = "mr-1 inline-block size-5 rounded object-cover align-middle";
export const agentInlineTokenIconClass = "mr-1 inline-block size-4 align-middle opacity-65";
export type AgentInlineToken =
| { type: "text"; value: string }
| { type: "skill"; skill: AgentSkillReference }
| { type: "reference"; reference: AgentCanvasReference };
export function agentSkillMarker(skill: Pick<AgentSkillReference, "name">) {
return `$${skill.name}`;
}
export function agentReferenceMarker(reference: Pick<AgentCanvasReference, "label">) {
return `@${reference.label}`;
}
export function parseAgentInlineTokens(text: string, references: AgentCanvasReference[], skill?: AgentSkillReference): AgentInlineToken[] {
const markers = [
...(skill ? [{ marker: agentSkillMarker(skill), token: { type: "skill", skill } as AgentInlineToken }] : []),
...references.map((reference) => ({ marker: agentReferenceMarker(reference), token: { type: "reference", reference } as AgentInlineToken })),
].sort((a, b) => b.marker.length - a.marker.length);
if (!markers.length) return [{ type: "text", value: text }];
const tokens: AgentInlineToken[] = [];
let cursor = 0;
while (cursor < text.length) {
let nextIndex = -1;
let nextMarker: (typeof markers)[number] | undefined;
markers.forEach((item) => {
const index = text.indexOf(item.marker, cursor);
if (index >= 0 && (nextIndex < 0 || index < nextIndex)) {
nextIndex = index;
nextMarker = item;
}
});
if (!nextMarker || nextIndex < 0) {
tokens.push({ type: "text", value: text.slice(cursor) });
break;
}
if (nextIndex > cursor) tokens.push({ type: "text", value: text.slice(cursor, nextIndex) });
tokens.push(nextMarker.token);
cursor = nextIndex + nextMarker.marker.length;
}
return tokens.length ? tokens : [{ type: "text", value: "" }];
}
@@ -1,5 +1,5 @@
import { useEffect, useId, useState, type ReactNode } from "react";
import { App, Button, Image, Modal } from "antd";
import { App, Button, Image, Modal, Popover } from "antd";
import { Brain, CheckCircle2, ChevronDown, ChevronRight, Circle, CircleAlert, Copy, ExternalLink, FilePenLine, FileText, FolderOpen, ListChecks, LoaderCircle, Search, ShieldAlert, TerminalSquare, Wrench, XCircle } from "lucide-react";
import { Streamdown, type LinkSafetyModalProps } from "streamdown";
import { useTranslation } from "react-i18next";
@@ -7,8 +7,10 @@ import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { useCopyText } from "@/hooks/use-copy-text";
import { canvasThemes } from "@/lib/canvas-theme";
import { useAgentStore, type AgentPendingApproval } from "@/stores/use-agent-store";
import { revealAgentLocalFile } from "@/services/api/canvas-agent";
import { useAgentStore, type AgentCanvasReference, type AgentPendingApproval, type AgentSkillReference } from "@/stores/use-agent-store";
import { resolveAgentMessageAssetUrl, revealAgentLocalFile } from "@/services/api/canvas-agent";
import { AgentCanvasReferencePreview, canvasReferenceIcon, canvasReferenceKindLabel } from "./agent-canvas-reference-preview";
import { agentInlineTokenClass, agentInlineTokenIconClass, agentInlineTokenMediaClass, agentReferenceMarker, parseAgentInlineTokens } from "./agent-chat-inline-tokens";
const streamdownProps = () => ({
className: "agent-streamdown",
@@ -94,6 +96,8 @@ export type AgentChatMessageItem = {
meta?: string;
detail?: unknown;
attachments?: AgentChatAttachment[];
canvasReferences?: AgentCanvasReference[];
skill?: AgentSkillReference;
/** Present while the message is actively streaming; cleared on completion. */
streamId?: string;
};
@@ -123,7 +127,7 @@ export function AgentChatMessage({ item, theme, onRejectTool, onApproveTool }: {
style={{ color }}
>
{isUser ? (
<div className="whitespace-pre-wrap break-words">{item.text}</div>
<AgentUserMessageContent text={item.text} references={item.canvasReferences || []} skill={item.skill} theme={theme} />
) : (
<Streamdown {...streamdownProps()} animated={streamdownAnimation} isAnimating={!!item.streamId}>{item.text}</Streamdown>
)}
@@ -134,6 +138,52 @@ export function AgentChatMessage({ item, theme, onRejectTool, onApproveTool }: {
);
}
function AgentUserMessageContent({ text, references, skill, theme }: { text: string; references: AgentCanvasReference[]; skill?: AgentSkillReference; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const tokens = parseAgentInlineTokens(text, references, skill);
return (
<div className="whitespace-pre-wrap break-words">
{tokens.map((token, index) => token.type === "text"
? token.value
: token.type === "skill"
? <AgentSkillMention key={`skill:${index}`} skill={token.skill} theme={theme} />
: <AgentCanvasMention key={`${token.reference.nodeId}:${index}`} reference={token.reference} theme={theme} />)}
</div>
);
}
function AgentSkillMention({ skill, theme }: { skill: AgentSkillReference; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return <span className={agentInlineTokenClass} style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text }} title={skill.path}>/{skill.displayName || skill.name}</span>;
}
function AgentCanvasMention({ reference, theme }: { reference: AgentCanvasReference; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const node = useAgentStore((state) => state.canvasContext?.snapshot.nodes.find((item) => item.id === reference.nodeId));
const endpoint = useAgentStore((state) => state.url);
const token = useAgentStore((state) => state.token);
const previewUrl = resolveAgentMessageAssetUrl(endpoint, token, reference.previewUrl || node?.metadata?.content || "");
const previewText = reference.text || node?.metadata?.content || node?.metadata?.prompt;
const Icon = canvasReferenceIcon(reference.kind);
return (
<Popover
trigger={["hover", "focus"]}
placement="top"
mouseEnterDelay={0.15}
content={<AgentCanvasReferencePreview reference={reference} previewUrl={previewUrl} previewText={previewText} theme={theme} />}
>
<span
tabIndex={0}
className={`${agentInlineTokenClass} max-w-56 cursor-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/10 dark:focus-visible:ring-white/15`}
style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text }}
aria-label={i18n.t("agent.composer.mentions.referenceLabel", { kind: canvasReferenceKindLabel(reference.kind), title: reference.title })}
>
{reference.kind === "image" && previewUrl
? <img src={previewUrl} alt="" className={agentInlineTokenMediaClass} />
: <Icon className={agentInlineTokenIconClass} />}
<span>{agentReferenceMarker(reference)}</span>
</span>
</Popover>
);
}
export function AgentPendingToolCard({ summary, detail, theme, onReject, onApprove }: { summary: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onReject?: () => void; onApprove?: () => void }) {
const { t } = useTranslation();
const view = userDetail(detail);
@@ -0,0 +1,433 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent, type PointerEvent } from "react";
import { Popover } from "antd";
import { Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme";
import { buildCanvasResourceReferences, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { isImeComposing, isPlainEnterKey } from "@/lib/keyboard-event";
import type { AgentSkillSummary } from "@/services/api/canvas-agent";
import { useAgentSkillStore } from "@/stores/use-agent-skill-store";
import { useAgentStore, type AgentCanvasReference, type AgentSkillReference } from "@/stores/use-agent-store";
import { AgentCanvasReferencePreview, canvasReferenceIcon, canvasReferenceKindLabel } from "./agent-canvas-reference-preview";
import { agentInlineTokenClass, agentInlineTokenMediaClass, agentReferenceMarker, agentSkillMarker, parseAgentInlineTokens } from "./agent-chat-inline-tokens";
type ComposerCommand = { type: "skill" | "resource"; query: string; length: number };
type ComposerCandidate = { type: "skill"; skill: AgentSkillSummary } | { type: "resource"; reference: CanvasResourceReference };
type ReferenceHover = { reference: AgentCanvasReference; left: number; top: number; width: number; height: number };
export function AgentChatPromptInput({ value, disabled, placeholder, theme, onChange, onSubmit, onAddFiles }: {
value: string;
disabled?: boolean;
placeholder: string;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onChange: (value: string) => void;
onSubmit: () => void;
onAddFiles?: (files: FileList | File[] | null) => void | Promise<void>;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
const composingRef = useRef(false);
const lastEmittedRef = useRef(value);
const skills = useAgentSkillStore((state) => state.skills);
const skillsLoading = useAgentSkillStore((state) => state.loading);
const selectedSkill = useAgentSkillStore((state) => state.selectedSkill);
const canvasReferences = useAgentStore((state) => state.canvasReferences);
const [command, setCommand] = useState<ComposerCommand | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [resourceCandidates, setResourceCandidates] = useState<CanvasResourceReference[]>([]);
const [referenceHover, setReferenceHover] = useState<ReferenceHover | null>(null);
const messageSkill = useMemo<AgentSkillReference | undefined>(() => selectedSkill ? {
name: selectedSkill.name,
path: selectedSkill.path,
displayName: selectedSkill.interface?.displayName || undefined,
} : undefined, [selectedSkill]);
const tokens = useMemo(() => parseAgentInlineTokens(value, canvasReferences, messageSkill), [canvasReferences, messageSkill, value]);
const selectedReferenceIds = useMemo(() => new Set(canvasReferences.map((item) => item.nodeId)), [canvasReferences]);
const candidates = useMemo<ComposerCandidate[]>(() => {
if (!command) return [];
const query = command.query.trim().toLowerCase();
if (command.type === "skill") {
return skills
.filter((skill) => skill.enabled && (!query || [skill.name, skill.description, skill.interface?.displayName, skill.interface?.shortDescription, skill.shortDescription].some((item) => item?.toLowerCase().includes(query))))
.map((skill) => ({ type: "skill", skill }));
}
return resourceCandidates
.filter((reference) => !selectedReferenceIds.has(reference.nodeId) && (!query || `${reference.label} ${reference.title} ${reference.kind} ${reference.text || ""}`.toLowerCase().includes(query)))
.map((reference) => ({ type: "resource", reference }));
}, [command, resourceCandidates, selectedReferenceIds, skills]);
useEffect(() => {
const editor = editorRef.current;
if (!editor || document.activeElement === editor && value === lastEmittedRef.current) return;
editor.replaceChildren(...tokens.map((token) => {
if (token.type === "text") return document.createTextNode(token.value);
return token.type === "skill"
? createSkillToken(token.skill, theme)
: createReferenceToken(token.reference, theme);
}));
lastEmittedRef.current = value;
}, [theme, tokens, value]);
const emit = (next: string) => {
lastEmittedRef.current = next;
onChange(next);
};
const closeCommand = () => {
setCommand(null);
setActiveIndex(0);
};
const syncCommand = () => {
const text = textBeforeCaret(editorRef.current);
const match = /(^|\s)([/@])([^\s/@]*)$/.exec(text);
if (!match) return closeCommand();
const type = match[2] === "/" ? "skill" : "resource";
setCommand({ type, query: match[3] || "", length: (match[3] || "").length + 1 });
setActiveIndex(0);
if (type === "skill") {
const agent = useAgentStore.getState();
const skillState = useAgentSkillStore.getState();
if (agent.connected && !skillState.loaded && !skillState.loading) void skillState.loadSkills(agent.url.trim().replace(/\/$/, ""), agent.token);
return;
}
const snapshot = useAgentStore.getState().canvasContext?.snapshot;
const references = buildCanvasResourceReferences(snapshot?.nodes || []);
const selectedIds = new Set(snapshot?.selectedNodeIds || []);
setResourceCandidates([...references.filter((item) => selectedIds.has(item.nodeId)), ...references.filter((item) => !selectedIds.has(item.nodeId))]);
};
const syncFromEditor = () => {
const editor = editorRef.current;
if (!editor) return;
const next = serializeEditor(editor);
emit(next);
syncSelectedMetadata(editor);
syncCommand();
};
const insertCandidate = (candidate: ComposerCandidate) => {
const editor = editorRef.current;
if (!editor || !command) return;
if (candidate.type === "skill") editor.querySelector<HTMLElement>("[data-agent-token-kind='skill']")?.remove();
removeTextBeforeCaret(command.length);
if (candidate.type === "skill") {
const base = serializeEditor(editor);
const defaultPrompt = candidate.skill.interface?.defaultPrompt?.trim();
if (!base.trim() && defaultPrompt) {
emit("");
useAgentSkillStore.getState().selectSkill(candidate.skill);
closeCommand();
return;
}
insertTokenAtCaret(editor, createSkillToken({ name: candidate.skill.name, path: candidate.skill.path, displayName: candidate.skill.interface?.displayName || undefined }, theme));
const next = serializeEditor(editor);
emit(next);
useAgentSkillStore.getState().selectSkill(candidate.skill);
} else {
const current = useAgentStore.getState().canvasReferences;
if (!current.some((item) => item.nodeId === candidate.reference.nodeId)) useAgentStore.getState().setAgentState({ canvasReferences: [...current, candidate.reference] });
insertTokenAtCaret(editor, createReferenceToken(candidate.reference, theme));
emit(serializeEditor(editor));
}
closeCommand();
};
const handleCommandKey = (event: KeyboardEvent<HTMLDivElement>) => {
if (!command || isImeComposing(event)) return false;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (candidates.length) setActiveIndex((index) => (index + (event.key === "ArrowDown" ? 1 : candidates.length - 1)) % candidates.length);
return true;
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
if (candidates.length) insertCandidate(candidates[Math.min(activeIndex, candidates.length - 1)]);
return true;
}
if (event.key === "Escape") {
event.preventDefault();
closeCommand();
return true;
}
return false;
};
const showReferencePreview = (event: MouseEvent<HTMLDivElement>) => {
const token = event.target instanceof Element ? event.target.closest<HTMLElement>("[data-agent-token-kind='resource']") : null;
const container = containerRef.current;
if (!token || !container?.contains(token)) return setReferenceHover(null);
const reference = useAgentStore.getState().canvasReferences.find((item) => item.nodeId === token.dataset.nodeId);
if (!reference) return setReferenceHover(null);
const tokenRect = token.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
setReferenceHover({ reference, left: tokenRect.left - containerRect.left, top: tokenRect.top - containerRect.top, width: tokenRect.width, height: tokenRect.height });
};
return (
<div ref={containerRef} className="relative">
{!value.trim() ? <div className="pointer-events-none absolute left-1 top-1 text-sm leading-6" style={{ color: theme.node.placeholder }}>{placeholder}</div> : null}
<div
ref={editorRef}
contentEditable={!disabled}
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label={placeholder}
className="thin-scrollbar max-h-32 min-h-20 w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent px-1 py-1 text-sm leading-6 outline-none"
style={{ color: theme.node.text, cursor: disabled ? "default" : "text" }}
onInput={() => {
if (!composingRef.current) syncFromEditor();
}}
onCompositionStart={() => { composingRef.current = true; }}
onCompositionEnd={() => {
composingRef.current = false;
syncFromEditor();
}}
onPaste={(event) => {
const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
if (images.length && onAddFiles) {
event.preventDefault();
void onAddFiles(images);
return;
}
event.preventDefault();
insertTextAtCaret(event.clipboardData.getData("text/plain"));
syncFromEditor();
}}
onKeyDown={(event) => {
event.stopPropagation();
if (isImeComposing(event) || handleCommandKey(event)) return;
if ((event.key === "Backspace" || event.key === "Delete") && deleteAdjacentToken(event.key)) {
event.preventDefault();
requestAnimationFrame(syncFromEditor);
return;
}
if (isPlainEnterKey(event)) {
event.preventDefault();
onSubmit();
return;
}
requestAnimationFrame(syncCommand);
}}
onKeyUp={(event) => {
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) syncCommand();
}}
onClick={syncCommand}
onMouseOver={showReferencePreview}
onMouseLeave={() => setReferenceHover(null)}
onBlur={(event) => {
if (event.relatedTarget instanceof HTMLElement && event.relatedTarget.closest("[data-agent-command-menu]")) return;
window.setTimeout(closeCommand, 120);
}}
/>
{referenceHover ? (
<Popover
open
placement="top"
content={<AgentCanvasReferencePreview reference={referenceHover.reference} previewUrl={referenceHover.reference.previewUrl} previewText={referenceHover.reference.text} theme={theme} />}
>
<span
aria-hidden
className="pointer-events-none absolute"
style={{ left: referenceHover.left, top: referenceHover.top, width: referenceHover.width, height: referenceHover.height }}
/>
</Popover>
) : null}
{command ? <AgentCommandMenu command={command} candidates={candidates} activeIndex={Math.min(activeIndex, Math.max(candidates.length - 1, 0))} loading={command.type === "skill" && skillsLoading} theme={theme} onSelect={insertCandidate} /> : null}
</div>
);
}
function AgentCommandMenu({ command, candidates, activeIndex, loading, theme, onSelect }: { command: ComposerCommand; candidates: ComposerCandidate[]; activeIndex: number; loading: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (candidate: ComposerCandidate) => void }) {
const { t } = useTranslation();
const activeItemRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => { activeItemRef.current?.scrollIntoView({ block: "nearest" }); }, [activeIndex]);
const stopPropagation = (event: PointerEvent | MouseEvent) => event.stopPropagation();
return (
<div data-agent-command-menu className="absolute bottom-[calc(100%+8px)] left-0 z-[120] w-full min-w-64 overflow-hidden rounded-xl border shadow-xl" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} onPointerDown={stopPropagation} onMouseDown={stopPropagation}>
<div className="border-b px-3 py-2 text-xs" style={{ borderColor: theme.toolbar.border, color: theme.node.muted }}>
{t(command.type === "skill" ? "agent.composer.mentions.selectSkill" : "agent.composer.mentions.selectResource")}{command.query ? ` · ${command.query}` : ""}
</div>
<div className="thin-scrollbar max-h-[min(21rem,52vh)] overflow-y-auto p-1">
{candidates.length ? candidates.map((candidate, index) => {
const skill = candidate.type === "skill" ? candidate.skill : null;
const reference = candidate.type === "resource" ? candidate.reference : null;
const title = skill ? skill.interface?.displayName || skill.name : reference?.title || "";
const description = skill ? skill.interface?.shortDescription || skill.shortDescription || skill.description : reference ? `${agentReferenceMarker(reference)} · ${canvasReferenceKindLabel(reference.kind)}` : "";
return (
<button key={skill ? `${skill.name}:${skill.path}` : reference?.nodeId} ref={index === activeIndex ? activeItemRef : undefined} type="button" className="flex w-full min-w-0 items-center gap-2.5 rounded-lg px-2 py-2 text-left transition hover:bg-black/5 dark:hover:bg-white/10" style={{ background: index === activeIndex ? theme.toolbar.activeBg : undefined, color: index === activeIndex ? theme.toolbar.activeText : theme.node.text }} onPointerDown={(event) => { event.preventDefault(); onSelect(candidate); }}>
{skill ? <span className="grid size-9 shrink-0 place-items-center"><Sparkles className="size-4" /></span> : reference ? <ReferencePreview reference={reference} /> : null}
<span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{title}</span><span className="mt-0.5 block truncate text-xs" style={{ color: theme.node.muted }}>{description}</span></span>
</button>
);
}) : <div className="px-3 py-6 text-center text-xs" style={{ color: theme.node.muted }}>{t(loading ? "agent.composer.mentions.loadingSkills" : command.type === "skill" ? "agent.composer.mentions.noSkills" : "agent.composer.mentions.noResources")}</div>}
</div>
</div>
);
}
function ReferencePreview({ reference }: { reference: CanvasResourceReference }) {
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-9 rounded-md object-cover" />;
const Icon = canvasReferenceIcon(reference.kind);
return <span className="grid size-9 shrink-0 place-items-center"><Icon className="size-4" /></span>;
}
function createSkillToken(skill: AgentSkillReference, theme: (typeof canvasThemes)[keyof typeof canvasThemes]) {
const token = createToken("skill", agentSkillMarker(skill), theme);
token.dataset.skillName = skill.name;
token.title = skill.path;
token.textContent = `/${skill.displayName || skill.name}`;
return token;
}
function createReferenceToken(reference: AgentCanvasReference, theme: (typeof canvasThemes)[keyof typeof canvasThemes]) {
const token = createToken("resource", agentReferenceMarker(reference), theme);
token.dataset.nodeId = reference.nodeId;
token.title = reference.title;
if (reference.kind === "image" && reference.previewUrl) {
const image = document.createElement("img");
image.src = reference.previewUrl;
image.alt = "";
image.className = agentInlineTokenMediaClass;
token.append(image);
}
token.append(document.createTextNode(agentReferenceMarker(reference)));
return token;
}
function createToken(kind: "skill" | "resource", marker: string, theme: (typeof canvasThemes)[keyof typeof canvasThemes]) {
const token = document.createElement("span");
token.contentEditable = "false";
token.dataset.agentToken = marker;
token.dataset.agentTokenKind = kind;
token.className = agentInlineTokenClass;
Object.assign(token.style, { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text } as CSSProperties);
return token;
}
function insertTokenAtCaret(editor: HTMLElement, token: HTMLElement) {
const selection = window.getSelection();
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
const space = document.createTextNode(" ");
if (!range || !editor.contains(range.startContainer)) {
editor.append(token, space);
placeCaretAfter(space);
return;
}
range.deleteContents();
range.insertNode(space);
range.insertNode(token);
placeCaretAfter(space);
}
function insertTextAtCaret(text: string) {
const selection = window.getSelection();
if (!selection?.rangeCount) return;
const range = selection.getRangeAt(0);
range.deleteContents();
const node = document.createTextNode(text);
range.insertNode(node);
placeCaretAfter(node);
}
function placeCaretAfter(node: Node) {
const selection = window.getSelection();
const range = document.createRange();
range.setStartAfter(node);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
}
function removeTextBeforeCaret(length: number) {
const selection = window.getSelection();
if (!selection?.rangeCount) return;
const range = selection.getRangeAt(0);
if (range.startContainer.nodeType !== Node.TEXT_NODE || range.startOffset < length) return;
range.setStart(range.startContainer, range.startOffset - length);
range.deleteContents();
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
function deleteAdjacentToken(key: string) {
const selection = window.getSelection();
if (!selection?.rangeCount || !selection.isCollapsed) return false;
const range = selection.getRangeAt(0);
const previous = key === "Backspace";
const target = adjacentToken(range, previous);
if (!target) return false;
const caret = document.createTextNode("");
target.replaceWith(caret);
const nextRange = document.createRange();
nextRange.setStart(caret, 0);
nextRange.collapse(true);
selection.removeAllRanges();
selection.addRange(nextRange);
return true;
}
function adjacentToken(range: Range, previous: boolean) {
const container = range.startContainer;
const offset = range.startOffset;
if (container.nodeType === Node.TEXT_NODE) {
const text = container.textContent || "";
if (previous ? offset > 0 : offset < text.length) return null;
return tokenSibling(container, previous);
}
const children = Array.from(container.childNodes);
const node = children[previous ? offset - 1 : offset];
return node instanceof HTMLElement && node.dataset.agentToken ? node : tokenSibling(node || container, previous);
}
function tokenSibling(node: Node, previous: boolean) {
let current: Node | null = previous ? node.previousSibling : node.nextSibling;
while (current?.nodeType === Node.TEXT_NODE && !(current.textContent || "").trim()) current = previous ? current.previousSibling : current.nextSibling;
return current instanceof HTMLElement && current.dataset.agentToken ? current : null;
}
function syncSelectedMetadata(editor: HTMLElement) {
const state = useAgentStore.getState();
const nodeIds = new Set(Array.from(editor.querySelectorAll<HTMLElement>("[data-agent-token-kind='resource']")).map((item) => item.dataset.nodeId));
const references = state.canvasReferences.filter((item) => nodeIds.has(item.nodeId));
if (references.length !== state.canvasReferences.length) state.setAgentState({ canvasReferences: references });
const selectedSkill = useAgentSkillStore.getState().selectedSkill;
const skillName = editor.querySelector<HTMLElement>("[data-agent-token-kind='skill']")?.dataset.skillName;
if (selectedSkill && skillName !== selectedSkill.name) useAgentSkillStore.getState().clearSelection();
}
function textBeforeCaret(editor: HTMLElement | null) {
const selection = window.getSelection();
if (!editor || !selection?.rangeCount) return "";
const range = selection.getRangeAt(0).cloneRange();
if (!editor.contains(range.startContainer)) return "";
range.setStart(editor, 0);
return range.toString();
}
function serializeEditor(editor: HTMLElement) {
return serializeNodes(editor.childNodes).replace(//g, "");
}
function serializeNodes(nodes: NodeListOf<ChildNode>) {
let result = "";
nodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
result += node.textContent || "";
return;
}
if (!(node instanceof HTMLElement)) return;
const marker = node.dataset.agentToken;
if (marker) result += marker;
else if (node.tagName === "BR") result += "\n";
else {
if (["DIV", "P"].includes(node.tagName) && result && !result.endsWith("\n")) result += "\n";
result += serializeNodes(node.childNodes);
}
});
return result;
}
+3 -1
View File
@@ -114,9 +114,11 @@ export function AgentTaskProgress({ theme, busy }: { theme: (typeof canvasThemes
}
const AgentChatMessageRow = memo(function AgentChatMessageRow({ item, theme }: { item: AgentChatItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const endpoint = useAgentStore((state) => state.url);
const token = useAgentStore((state) => state.token);
return (
<div style={item.streamId ? undefined : historyMessageStyle}>
<AgentChatMessage item={agentMessageToChatMessage(item)} theme={theme} />
<AgentChatMessage item={agentMessageToChatMessage(item, endpoint, token)} theme={theme} />
</div>
);
});
@@ -1,8 +1,10 @@
import { isSiteTool, SITE_TOOL_LABELS } from "@/lib/agent/agent-site-tools";
import i18n from "@/i18n";
import { summarizeCanvasAgentOps, type CanvasAgentOp } from "@/lib/canvas/canvas-agent-ops";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { randomId } from "@/lib/utils";
import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentTokenUsage } from "@/stores/use-agent-store";
import { resolveAgentMessageAssetUrl } from "@/services/api/canvas-agent";
import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentMessageAttachment, type AgentTokenUsage } from "@/stores/use-agent-store";
import type { AgentChatAttachment } from "./agent-chat-message";
export const REASONING_PLACEHOLDER = i18n.t("agent.events.analyzing");
@@ -55,12 +57,12 @@ export type AgentUserDetail = { kind: string; status: string; rows?: Array<{ lab
export type AgentLogContext = { endpoint: string; connected: boolean; enabled: boolean; activity: string; waiting: boolean; sending: boolean; messages: number; pendingTool?: string };
export function agentMessageToChatMessage(item: AgentChatItem) {
return { ...item, meta: item.role === "user" || item.role === "assistant" ? undefined : item.meta, attachments: item.attachments?.map(agentAttachmentToChatAttachment) };
export function agentMessageToChatMessage(item: AgentChatItem, endpoint: string, token: string) {
return { ...item, meta: item.role === "user" || item.role === "assistant" ? undefined : item.meta, attachments: item.attachments?.map((attachment) => agentAttachmentToChatAttachment(attachment, endpoint, token)) };
}
export function agentAttachmentToChatAttachment(item: AgentAttachment): AgentChatAttachment {
return { id: item.id, name: item.name, url: item.dataUrl || item.url };
export function agentAttachmentToChatAttachment(item: AgentMessageAttachment, endpoint: string, token: string): AgentChatAttachment {
return { id: item.id, name: item.name, url: resolveAgentMessageAssetUrl(endpoint, token, item.dataUrl || item.url) };
}
export function formatAgentEvent(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
@@ -486,6 +488,14 @@ export function promptWithAttachments(text: string, attachments: AgentAttachment
return text || (attachments.length ? tr("attachmentPrompt") : "");
}
export function promptWithCanvasReferences(text: string, references: CanvasResourceReference[]) {
if (!references.length) return text;
const task = text || "请处理引用的画布素材。";
const list = references.map((item, index) => `${index + 1}. mention=${JSON.stringify(`@${item.label}`)}, nodeId=${JSON.stringify(item.nodeId)}, title=${JSON.stringify(item.title)}, type=${item.kind}`).join("\n");
const imageHint = references.some((item) => item.kind === "image") ? "\n其中图片素材的实际内容已作为本轮图片附件提供,可直接查看;nodeId 用于后续画布操作。" : "";
return `${task}\n\n本轮引用的当前画布素材:\n${list}${imageHint}\n需要读取或操作这些素材时,先调用 canvas_get_state,并使用上面的 nodeId 精确定位,不要按标题猜测节点。`;
}
export function attachmentPayloadBytes(attachments: AgentAttachment[]) {
return attachments.reduce((total, item) => total + item.dataUrl.length, 0);
}
@@ -529,7 +539,7 @@ export function upsertAgentMessage(messages: AgentChatItem[], item: AgentChatIte
const index = messages.findIndex((current) => current.id === item.id);
if (index < 0) return [...messages, item];
const current = messages[index];
const next = { ...current, ...item, attachments: item.attachments || current.attachments, historyText: item.historyText || current.historyText };
const next = { ...current, ...item, ...mergeLocalMessageMetadata(current, item) };
return messages.map((message, itemIndex) => itemIndex === index ? next : message);
}
@@ -543,9 +553,8 @@ export function mergeAgentMessages(snapshot: AgentChatItem[], current: AgentChat
return;
}
const history = messages[index];
const next = live
? { ...history, ...item, attachments: item.attachments || history.attachments, historyText: item.historyText || history.historyText }
: { ...history, attachments: item.attachments || history.attachments, historyText: item.historyText || history.historyText };
const metadata = mergeLocalMessageMetadata(history, item);
const next = live ? { ...history, ...item, ...metadata } : { ...history, ...metadata };
messages = messages.map((message, itemIndex) => itemIndex === index ? next : message);
});
return messages;
@@ -557,27 +566,12 @@ export function normalizeHistoryMessages(messages: AgentChatItem[]) {
.map(({ streamId: _streamId, ...item }) => scopeChatItem({ ...item, text: normalizeText(item.text) } as AgentChatItem, item.threadId!, item.turnId!));
}
export function mergeHistoryAttachments(messages: AgentChatItem[], currentMessages: AgentChatItem[]) {
const currentUsers = currentMessages.filter((item) => item.role === "user" && item.attachments?.length).reverse();
return [...messages]
.reverse()
.map((item) => {
if (item.role !== "user") return item;
let index = item.clientMessageId
? currentUsers.findIndex((current) => current.clientMessageId === item.clientMessageId && current.threadId === item.threadId)
: -1;
if (index < 0 && item.turnId) index = currentUsers.findIndex((current) => current.turnId === item.turnId && current.threadId === item.threadId);
if (index < 0) {
const candidates = currentUsers
.map((current, candidateIndex) => ({ current, candidateIndex }))
.filter(({ current }) => current.threadId === item.threadId && (current.text === item.text || current.historyText === item.text));
if (new Set(candidates.map(({ current }) => current.clientMessageId || current.id)).size === 1) index = candidates[0]?.candidateIndex ?? -1;
}
if (index < 0) return item;
const current = currentUsers.splice(index, 1)[0];
return { ...item, text: current.text, historyText: current.historyText, attachments: current.attachments };
})
.reverse();
function mergeLocalMessageMetadata(current: AgentChatItem, incoming: AgentChatItem) {
return {
attachments: incoming.attachments || current.attachments,
canvasReferences: incoming.canvasReferences || current.canvasReferences,
skill: incoming.skill || current.skill,
};
}
export function reasoningActivityText(items: Record<string, string>, fallback = "") {
@@ -1,87 +0,0 @@
import { useMemo, useState } from "react";
import { Button, Input, Popover, Tooltip } from "antd";
import { Check, Search, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme";
import { useAgentSkillStore } from "@/stores/use-agent-skill-store";
import { useAgentStore } from "@/stores/use-agent-store";
import { useThemeStore } from "@/stores/use-theme-store";
export function AgentSkillPicker() {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const connected = useAgentStore((state) => state.connected);
const url = useAgentStore((state) => state.url);
const token = useAgentStore((state) => state.token);
const skills = useAgentSkillStore((state) => state.skills);
const selectedSkill = useAgentSkillStore((state) => state.selectedSkill);
const loading = useAgentSkillStore((state) => state.loading);
const loadSkills = useAgentSkillStore((state) => state.loadSkills);
const selectSkill = useAgentSkillStore((state) => state.selectSkill);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const endpoint = url.trim().replace(/\/$/, "");
const enabledSkills = useMemo(() => {
const keyword = query.trim().toLowerCase();
return skills.filter((skill) => skill.enabled && (!keyword || [skill.name, skill.description, skill.interface?.displayName, skill.interface?.shortDescription, skill.shortDescription].some((value) => value?.toLowerCase().includes(keyword))));
}, [query, skills]);
const content = (
<div className="w-72" style={{ color: theme.node.text }}>
<div className="mb-2 px-1 text-xs font-medium" style={{ color: theme.node.muted }}>{t("agent.skills.selectLocal")}</div>
<Input allowClear size="small" value={query} onChange={(event) => setQuery(event.target.value)} prefix={<Search className="size-3.5" />} placeholder={t("agent.skills.search")} />
<div className="thin-scrollbar mt-2 max-h-64 overflow-y-auto">
{enabledSkills.length ? enabledSkills.map((skill) => {
const selected = selectedSkill?.name === skill.name && selectedSkill.path === skill.path;
return (
<button
key={`${skill.name}:${skill.path}`}
type="button"
className="flex w-full items-start gap-2.5 rounded-lg px-2 py-2 text-left transition hover:bg-black/5 dark:hover:bg-white/10"
onClick={() => {
selectSkill(skill);
setOpen(false);
}}
>
<Sparkles className="mt-0.5 size-4 shrink-0" style={{ color: selected ? theme.node.text : theme.node.muted }} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{skill.interface?.displayName || skill.name}</span>
<span className="mt-0.5 block truncate text-xs" style={{ color: theme.node.muted }}>{skill.interface?.shortDescription || skill.shortDescription || skill.description}</span>
</span>
{selected ? <Check className="mt-0.5 size-4 shrink-0" /> : null}
</button>
);
}) : (
<div className="px-2 py-6 text-center text-xs leading-5" style={{ color: theme.node.muted }}>
{t(loading ? "agent.skills.loading" : skills.length ? "agent.skills.noMatch" : "agent.skills.none")}
</div>
)}
</div>
</div>
);
return (
<Tooltip title={t(connected ? "agent.skills.select" : "agent.skills.connectHint")} placement="top" open={open ? false : undefined}>
<Popover
arrow={false}
trigger="click"
placement="topLeft"
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen && connected && !skills.length && !loading) void loadSkills(endpoint, token);
}}
content={content}
>
<Button
type="text"
shape="circle"
className="!h-9 !w-9 !min-w-9"
disabled={!connected}
style={{ color: selectedSkill ? theme.node.text : theme.node.muted, background: selectedSkill ? theme.toolbar.activeBg : undefined }}
icon={<Sparkles className="size-4" />}
aria-label={t("agent.skills.select")}
/>
</Popover>
</Tooltip>
);
}
+67 -57
View File
@@ -7,16 +7,17 @@ import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { canvasThemes } from "@/lib/canvas-theme";
import { upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
import { imageMetadata } from "@/lib/canvas/canvas-node-factory";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { resolveCanvasReferenceImages } from "@/lib/canvas/canvas-resource-references";
import { readImageMeta } from "@/lib/image-utils";
import { randomId } from "@/lib/utils";
import { uploadImage } from "@/services/image-storage";
import { bindPendingAgentUserMessage, deleteAgentThreadMessages, deletePendingAgentUserMessage, moveAgentUserMessage, readAgentUserMessages, savePendingAgentUserMessage } from "@/services/agent-chat-storage";
import { useThemeStore } from "@/stores/use-theme-store";
import { useAgentSkillStore } from "@/stores/use-agent-skill-store";
import { useShallow } from "zustand/react/shallow";
import { useAgentStore, type AgentBootstrapStatus, type AgentCanvasContext, type AgentChatItem, type AgentConversationState, type AgentModel, type AgentPendingApproval, type AgentPendingToolCall, type AgentPermissionMode, type AgentReasoningEffort, type AgentThreadSummary } from "@/stores/use-agent-store";
import { useAgentStore, type AgentAttachment, type AgentBootstrapStatus, type AgentCanvasContext, type AgentCanvasReference, type AgentChatItem, type AgentConversationState, type AgentModel, type AgentPendingApproval, type AgentPendingToolCall, type AgentPermissionMode, type AgentReasoningEffort, type AgentThreadSummary } from "@/stores/use-agent-store";
import { type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { isSiteTool, runSiteTool } from "@/lib/agent/agent-site-tools";
import { acknowledgeCodexHistory, activateAgentClient, AgentApiError, discoverAgentConfig, fetchAgentJson, interruptCodexTurn, postCodexApproval, postState, postToolResult } from "@/services/api/canvas-agent";
@@ -44,12 +45,12 @@ import {
isCurrentThreadEvent,
isReasoningSummary,
mergeAgentMessages,
mergeHistoryAttachments,
mergeStreamText,
normalizeHistoryMessages,
normalizeText,
parseEventData,
promptWithAttachments,
promptWithCanvasReferences,
reasoningActivityText,
registerLiveAgentTurn,
scopeChatItem,
@@ -67,6 +68,8 @@ import { AgentSkillsView } from "./agent-skills-view";
const MAX_ATTACHMENTS = 6;
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
const MESSAGE_PREVIEW_LONG_EDGE = 192;
const MESSAGE_PREVIEW_MAX_LENGTH = 500_000;
const DEFAULT_AGENT_URL = "http://127.0.0.1:17371";
const AGENT_PROTOCOL_VERSION = 5;
const HISTORY_RETRY_DELAYS_MS = [0, 150, 350, 700, 1200];
@@ -198,7 +201,6 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
return () => { disposed = true; };
}, []);
const loadThreadSnapshot = useCallback(async (threadId: string, sequence: number, response?: AgentThreadResponse, expectedTurnId = "") => {
const storedMessagesPromise = readAgentUserMessages(threadId).catch(() => []);
let thread = response;
let lastError: unknown;
for (const delayMs of HISTORY_RETRY_DELAYS_MS) {
@@ -213,7 +215,6 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
continue;
}
const history = normalizeHistoryMessages(thread.messages || []);
const storedMessages = await storedMessagesPromise;
const latest = useAgentStore.getState();
if (sequence !== loadThreadsSequenceRef.current || latest.activeThreadId !== threadId) return false;
const historyTurns = authoritativeHistoryTurnKeys(threadId, thread.settledTurnIds || []);
@@ -221,9 +222,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
historyTurns.forEach((key) => liveTurnKeysRef.current.delete(key));
if (latest.activeTurnId) liveTurnKeysRef.current.add(`${threadId}\0${latest.activeTurnId}`);
authoritativeHistoryTurnsRef.current = historyTurns;
const attachmentSources = [...latest.messages, ...storedMessages.map((item) => ({ ...item, threadId }))];
const snapshot = mergeHistoryAttachments(history, attachmentSources);
const messages = mergeAgentMessages(snapshot, latest.messages, threadId, liveTurnKeysRef.current);
const messages = mergeAgentMessages(history, latest.messages, threadId, liveTurnKeysRef.current);
threadMessagesRef.current.set(threadId, messages);
setAgentState({ messages, connectError: "" });
const coveredTurnIds = [...historyTurns].map((key) => key.slice(threadId.length + 1));
@@ -496,15 +495,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
source.addEventListener("workspace_changed", (event) => {
const data = parseEventData<AgentWorkspaceEvent>(event);
if (!data) return;
enqueueEvent(async () => {
const nextThreadId = data.activeThreadId ?? data.threadId ?? "";
const current = useAgentStore.getState();
const pendingMessage = [...current.messages].reverse().find((item) => item.role === "user" && !item.turnId);
const keepPendingMessage = Boolean(data.emptyThread && pendingMessage && (current.sending || current.waiting) && (!data.sourceClientId || data.sourceClientId === clientIdRef.current));
const pendingThreadId = pendingMessage?.threadId || current.activeThreadId;
if (keepPendingMessage && nextThreadId) {
await moveAgentUserMessage(pendingThreadId, nextThreadId, pendingMessage!.clientMessageId || pendingMessage!.itemId || pendingMessage!.id).catch(() => undefined);
}
enqueueEvent(() => {
if (data.conversation) applyConversationState(data.conversation);
else applyWorkspaceChange(data);
if (!data.draftThread) void loadThreads(Boolean(data.emptyThread));
@@ -521,25 +512,12 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const turnId = data.turnId ?? data.message!.turnId ?? "";
const clientMessageId = data.message!.clientMessageId || data.message!.itemId || data.message!.id;
if (current.activeThreadId !== threadId) return;
const pending = data.message!.role === "user"
? [...current.messages].reverse().find((item) => item.role === "user" && item.threadId === threadId && !item.turnId && (!clientMessageId || item.clientMessageId === clientMessageId))
: undefined;
const next = scopeChatItem({
...data.message!,
...(pending ? { clientMessageId: pending.clientMessageId, text: pending.text, historyText: pending.historyText, attachments: pending.attachments } : {}),
}, threadId, turnId);
const currentMessages = pending && turnId ? current.messages.filter((item) => item.id !== pending.id) : current.messages;
const next = scopeChatItem(data.message!, threadId, turnId);
const currentMessages = data.message!.role === "user" && clientMessageId
? current.messages.filter((item) => item.role !== "user" || item.clientMessageId !== clientMessageId || item.id === next.id)
: current.messages;
const messages = upsertAgentMessage(currentMessages, next);
setAgentState({ messages });
if (next.role === "user" && clientMessageId) void bindPendingAgentUserMessage(threadId, clientMessageId, turnId).catch(() => undefined);
if (next.role === "user" && !next.attachments?.length) {
void readAgentUserMessages(threadId).then((storedMessages) => {
const stored = storedMessages.find((item) => item.id === clientMessageId || Boolean(turnId && item.turnId === turnId));
const latest = useAgentStore.getState();
if (!stored || latest.activeThreadId !== threadId || !latest.messages.some((item) => item.id === next.id)) return;
setAgentState({ messages: upsertAgentMessage(latest.messages, { ...next, text: stored.text, historyText: stored.historyText, attachments: stored.attachments }) });
}).catch(() => undefined);
}
});
});
source.addEventListener("agent_log", (event) => {
@@ -649,26 +627,60 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const skillState = useAgentSkillStore.getState();
const selectedSkill = skillState.selectedSkill;
const selectedSkillRevision = skillState.selectionRevision;
const requestPrompt = promptWithAttachments(text, files);
const currentState = useAgentStore.getState();
const canvasNodeIds = new Set(currentState.canvasContext?.snapshot.nodes.map((node) => node.id) || []);
const canvasReferences = currentState.canvasReferences.filter((item) => canvasNodeIds.has(item.nodeId));
if (canvasReferences.length !== currentState.canvasReferences.length) {
setAgentState({ canvasReferences });
message.warning(rt(canvasReferences.length ? "someCanvasReferencesMissing" : "canvasReferencesMissing"));
}
const requestPrompt = promptWithCanvasReferences(promptWithAttachments(text, files), canvasReferences);
if (!currentState.connected || !requestPrompt || currentState.sending || currentState.waiting || currentState.loadingThreads || !["ready", "warning"].includes(currentState.conversation.status)) return;
if (attachmentPayloadBytes(files) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
let referenceImages: AgentAttachment[] = [];
if (canvasReferences.some((item) => item.kind === "image")) {
setAgentState({ sending: true, activity: rt("readingCanvasImages") });
try {
referenceImages = await resolveCanvasReferenceImages(canvasReferences, currentState.canvasContext?.snapshot.nodes || []);
} catch (error) {
setAgentState({ sending: false, activity: rt("canvasImageReadFailed") });
addMessage({ role: "error", title: rt("canvasImageReadFailed"), text: error instanceof Error ? error.message : rt("canvasImageReadFailed") });
return;
}
}
const requestFiles = [...files, ...referenceImages.filter((reference) => !files.some((file) => file.dataUrl === reference.dataUrl))];
if (requestFiles.length > MAX_ATTACHMENTS) {
setAgentState({ sending: false, activity: rt("tooManyImages") });
addMessage({ role: "error", title: rt("tooManyImages"), text: rt("imageCountLimit", { count: MAX_ATTACHMENTS }) });
return;
}
if (attachmentPayloadBytes(requestFiles) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
setAgentState({ sending: false, activity: rt("imageTooLarge") });
addMessage({ role: "error", title: rt("imageTooLarge"), text: rt("imagePayloadTooLarge") });
return;
}
const messageId = createId();
const userText = text || rt("imagesSent", { count: files.length });
const userText = text || rt(files.length ? "imagesSent" : "canvasReferencesSent", { count: files.length || canvasReferences.length });
const messageReferences: AgentCanvasReference[] = await Promise.all(canvasReferences.map(async ({ nodeId, label, title, kind, previewUrl, text }) => {
const image = referenceImages.find((item) => item.id === `canvas:${nodeId}`);
return { nodeId, label, title, kind, previewUrl: image ? (await createMessageAttachmentMetadata(image)).url : previewUrl, text };
}));
const messageSkill = selectedSkill ? { name: selectedSkill.name, path: selectedSkill.path, displayName: selectedSkill.interface?.displayName || undefined } : undefined;
loadThreadsSequenceRef.current += 1;
const currentBeforeSend = useAgentStore.getState();
const requestThreadId = currentBeforeSend.activeThreadId;
setAgentState({ prompt: "", attachments: [], activity: rt("sending"), sending: true, loadingThreads: false, activeTurnId: "", messages: currentBeforeSend.messages });
addMessage({ id: messageId, itemId: "synthetic:user", clientMessageId: messageId, threadId: requestThreadId, turnId: "", role: "user", text: userText, historyText: requestPrompt, attachments: files });
setAgentState({ prompt: "", attachments: [], canvasReferences: [], activity: rt("sending"), sending: true, loadingThreads: false, activeTurnId: "", messages: currentBeforeSend.messages });
addMessage({ id: messageId, itemId: "synthetic:user", clientMessageId: messageId, threadId: requestThreadId, turnId: "", role: "user", text: userText, attachments: files, canvasReferences: messageReferences, skill: messageSkill });
let threadId = requestThreadId;
try {
if (files.length) await savePendingAgentUserMessage({ id: messageId, role: "user", text: userText, historyText: requestPrompt, attachments: files });
const messageAttachments = await Promise.all(files.map(createMessageAttachmentMetadata));
const messageMetadata = {
...(messageAttachments.length ? { attachments: messageAttachments } : {}),
...(messageReferences.length ? { canvasReferences: messageReferences } : {}),
...(messageSkill ? { skill: messageSkill } : {}),
};
const modelName = models.find((item) => item.model === model)?.displayName || model || rt("defaultModel");
const effortName = reasoningEffort ? i18n.t(`agent.composer.effort.${reasoningEffort}`) : rt("defaultEffort");
addEventLog(rt("sendTask"), `${modelName} · ${effortName}${selectedSkill ? ` · Skill ${selectedSkill.name}` : ""}${files.length ? ` · ${rt("attachmentCount", { count: files.length })}` : ""} · ${compactText(text) || rt("attachmentsOnly")}`);
addEventLog(rt("sendTask"), `${modelName} · ${effortName}${selectedSkill ? ` · Skill ${selectedSkill.name}` : ""}${files.length ? ` · ${rt("attachmentCount", { count: files.length })}` : ""}${canvasReferences.length ? ` · ${rt("canvasReferenceCount", { count: canvasReferences.length })}` : ""} · ${compactText(text) || rt(canvasReferences.length ? "canvasReferencesOnly" : "attachmentsOnly")}`);
const accepted = await fetchAgentJson<AgentTurnResponse>(endpoint, token, "/agent/codex/turn", {
method: "POST",
headers: { "content-type": "application/json" },
@@ -684,23 +696,18 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
model,
effort: reasoningEffort,
skill: selectedSkill ? { name: selectedSkill.name, path: selectedSkill.path } : undefined,
attachments: files.map(({ id, name, type, size, width, height, dataUrl }) => ({ id, name, type, size, width, height, dataUrl })),
attachments: requestFiles.map(({ id, name, type, size, width, height, dataUrl }) => ({ id, name, type, size, width, height, dataUrl })),
messageMetadata,
}),
});
threadId = accepted.threadId || threadId;
if (!threadId) throw new Error(rt("startConversationFailed"));
if (selectedSkill) clearSkillSelection(selectedSkillRevision);
if (files.length) {
const latestMessage = useAgentStore.getState().messages.find((item) => item.clientMessageId === messageId);
const acceptedThreadId = latestMessage?.threadId || threadId;
await bindPendingAgentUserMessage(acceptedThreadId, messageId, latestMessage?.turnId || "").catch((error) => addEventLog(rt("attachmentHistoryFailed"), error));
}
files.forEach((item) => {
URL.revokeObjectURL(item.url);
attachmentUrlsRef.current.delete(item.url);
});
} catch (error) {
if (files.length) await deletePendingAgentUserMessage(messageId).catch(() => undefined);
const text = error instanceof Error ? error.message : rt("sendFailed");
const response = error instanceof AgentApiError ? error.response as { code?: string; state?: AgentConversationState } : undefined;
if (response?.state) applyConversationState(response.state);
@@ -713,7 +720,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
if (next.length !== messages.length) threadMessagesRef.current.set(cachedThreadId, next);
});
const ownsCurrentThread = state.activeThreadId === (threadId || requestThreadId);
const restoreDraft = state.prompt || state.attachments.length ? {} : { prompt, attachments: files };
const restoreDraft = state.prompt || state.attachments.length || state.canvasReferences.length ? {} : { prompt, attachments: files, canvasReferences };
if (ownsCurrentThread) {
setAgentState({
activity: rt(stale ? "conversationSynced" : busy ? "codexRunning" : "sendFailed"),
@@ -1029,20 +1036,16 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
const deleteThreads = async (threadIds: string[]) => {
if (!connected || !threadIds.length || sending || waiting || loadingThreads) return;
const operation = beginThreadOperation();
const deletedThreadIds: string[] = [];
let deletedCount = 0;
try {
for (const threadId of new Set(threadIds)) {
await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientId: clientIdRef.current }) });
threadMessagesRef.current.delete(threadId);
deletedThreadIds.push(threadId);
deletedCount += 1;
}
void deleteAgentThreadMessages(deletedThreadIds).catch(() => undefined);
await loadThreads();
message.success(rt("recordsDeleted", { count: deletedThreadIds.length }));
message.success(rt("recordsDeleted", { count: deletedCount }));
} catch (error) {
if (deletedThreadIds.length) {
void deleteAgentThreadMessages(deletedThreadIds).catch(() => undefined);
}
await loadThreads();
addEventLog(rt("deleteConversationFailed"), error);
message.error(error instanceof Error ? error.message : rt("deleteConversationFailed"));
@@ -1373,7 +1376,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
{tokenUsage ? <AgentUsageBar usage={tokenUsage} theme={theme} /> : null}
<AgentChatComposer
prompt={prompt}
attachments={attachments.map(agentAttachmentToChatAttachment)}
attachments={attachments.map((attachment) => agentAttachmentToChatAttachment(attachment, endpoint, token))}
disabled={!connected || !conversationReady || loadingThreads}
sending={sending || waiting}
placeholder={conversation.status === "idle" || conversation.status === "preparing"
@@ -1522,6 +1525,13 @@ function createId() {
return randomId();
}
async function createMessageAttachmentMetadata(item: AgentAttachment) {
const url = Math.max(item.width, item.height) > MESSAGE_PREVIEW_LONG_EDGE || item.dataUrl.length > MESSAGE_PREVIEW_MAX_LENGTH
? await upscaleDataUrl(item.dataUrl, { targetLongEdge: MESSAGE_PREVIEW_LONG_EDGE, algorithm: "high" })
: item.dataUrl;
return { id: item.id, name: item.name, type: item.type, size: item.size, width: item.width, height: item.height, url };
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
+3 -3
View File
@@ -586,8 +586,8 @@ export default {
fromConversation: "Generate draft from this conversation", availableAfterRun: "Available when Codex finishes running", conversationDescription: "Extract reusable workflows from this conversation", noCompletedContent: "This conversation has no completed content", startConversation: "Start a conversation first", fromCanvas: "Generate draft from this canvas", canvasDescription: "Extract nodes and generation workflows from this page", canvasUnavailable: "There is no available canvas on this page", blankCreate: "Create from scratch", blankDescription: "Start with an empty form", localSkills: "Local Skills", localDescription: "Installed locally and run directly by Codex", reload: "Reload", reloadSkill: "Reload Skills", createSkill: "Create Skill", filterBySource: "Filter Skills by source", scopes: { all: "All sources", repo: "Project", user: "Personal", system: "System", admin: "Admin" }, loadErrors: "{{count}} Skills could not be loaded", externalReadonly: "External Skills can only be used or enabled and disabled", noDescription: "No description", enabled: "Enabled", disabled: "Disabled", selected: "Selected", use: "Use", editNamed: "Edit {{name}}", deleteNamed: "Delete {{name}}", connectToView: "Connect the Agent to view Skills", noMatch: "No matching Skills", none: "No local Skills yet", connectDescription: "Installed local Skills will load after connection", tryAnotherFilter: "Try another keyword or source", createOrInstall: "Create one, or install one locally and refresh", saveChanges: "Save changes", saveLocation: "Save to the local Agent workspace", basicInfo: "Basic information",
identifier: "Skill identifier", identifierExtra: "Used as the folder name and for $skill-name invocation.", identifierRequired: "Enter a Skill identifier", identifierMax: "The Skill identifier cannot exceed 64 characters", identifierPattern: "Use lowercase letters, numbers, and hyphens only; hyphens cannot repeat or appear at either end", identifierPlaceholder: "For example, product-grid", displayName: "Display name", displayNameMax: "The display name cannot exceed 64 characters", displayNamePlaceholder: "For example, Product grid generator", whenToUse: "When to use", whenToUseExtra: "Describe the Skill's capabilities and use cases so Codex can decide when to invoke it.", whenToUseRequired: "Describe when to use this Skill", whenToUseMax: "The use-case description cannot exceed 1,024 characters", noAngleBrackets: "The use-case description cannot contain angle brackets", whenToUsePlaceholder: "For example: Use when planning and generating a set of product images from product information", instructions: "Instructions", instructionsExtra: "Describe the steps, constraints, and output requirements in execution order.", instructionsRequired: "Enter the instructions", instructionsPlaceholder: "Describe execution steps, required checks, and the final output", advanced: "Advanced settings", shortDescription: "Card summary", shortDescriptionExtra: "Use 25–64 characters for easy scanning.", shortDescriptionMin: "The card summary must contain at least 25 characters", shortDescriptionMax: "The card summary cannot exceed 64 characters", shortDescriptionPlaceholder: "Optional; shown in the list", defaultPrompt: "Default prompt", defaultPromptExtra: "Must include the exact $skill-name, such as $product-grid.", defaultPromptMax: "The default prompt cannot exceed 1,024 characters", defaultPromptPlaceholder: "Optional; prefilled when the Skill is selected",
},
composer: { removeImage: "Remove image", removeSkill: "Remove Skill", uploadImage: "Upload image", stop: "Stop", send: "Send", model: "Model: {{model}}", selectModel: "Select a Codex model. Current: {{model}}", reasoning: "Reasoning effort: {{effort}}", selectReasoning: "Select reasoning effort. Current: {{effort}}", permissionLabel: "Permissions: {{mode}}", selectPermission: "Select Codex permission mode. Current: {{mode}}", effort: { minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high", max: "Maximum", ultra: "Ultra" }, permission: { request: "Ask for approval", requestDescription: "Always ask before editing outside the workspace or accessing the network", automatic: "Automatic review", automaticDescription: "Codex reviews risky actions and asks only when needed", full: "Full access", fullShort: "Full access", fullDescription: "Unrestricted access to the network and local files" }, tools: { label: "Tool confirmation: {{mode}}", manual: "Manual", manualDescription: "Ask before the Agent writes to the canvas", automatic: "Automatic", automaticDescription: "Let the Agent perform canvas writes automatically", select: "Select tool confirmation mode. Current: {{mode}}" } },
panel: { connectionSettings: "Connection settings · {{status}}", connectionSettingsLabel: "Connection settings. Current status: {{status}}", chat: "Chat", history: "History", skills: "Skills", logs: "Logs", collapse: "Collapse chat", collapseLabel: "Collapse Agent panel", resize: "Resize right panel", content: "Agent content", mcpInitializing: "MCP is initializing. You can send messages when it is ready.", initFailed: "Codex conversation initialization failed. Start or resume a conversation.", placeholder: "Ask Codex or have it operate the site or canvas" },
composer: { removeImage: "Remove image", removeSkill: "Remove Skill", uploadImage: "Upload image", stop: "Stop", send: "Send", model: "Model: {{model}}", selectModel: "Select a Codex model. Current: {{model}}", reasoning: "Reasoning effort: {{effort}}", selectReasoning: "Select reasoning effort. Current: {{effort}}", permissionLabel: "Permissions: {{mode}}", selectPermission: "Select Codex permission mode. Current: {{mode}}", effort: { minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high", max: "Maximum", ultra: "Ultra" }, permission: { request: "Ask for approval", requestDescription: "Always ask before editing outside the workspace or accessing the network", automatic: "Automatic review", automaticDescription: "Codex reviews risky actions and asks only when needed", full: "Full access", fullShort: "Full access", fullDescription: "Unrestricted access to the network and local files" }, tools: { label: "Tool confirmation: {{mode}}", manual: "Manual", manualDescription: "Ask before the Agent writes to the canvas", automatic: "Automatic", automaticDescription: "Let the Agent perform canvas writes automatically", select: "Select tool confirmation mode. Current: {{mode}}" }, mentions: { selectSkill: "Select a Skill", selectResource: "Reference canvas assets", loadingSkills: "Loading Skills…", noSkills: "No enabled Skills match", noResources: "No matching canvas assets", previewUnavailable: "This asset cannot be previewed", referenceLabel: "Referenced {{kind}}: {{title}}", resourceMissing: "Canvas asset “{{title}}” no longer exists", imageReadFailed: "Could not read canvas image “{{title}}”", kind: { image: "Image", video: "Video", audio: "Audio", text: "Text" } } },
panel: { connectionSettings: "Connection settings · {{status}}", connectionSettingsLabel: "Connection settings. Current status: {{status}}", chat: "Chat", history: "History", skills: "Skills", logs: "Logs", collapse: "Collapse chat", collapseLabel: "Collapse Agent panel", resize: "Resize right panel", content: "Agent content", mcpInitializing: "MCP is initializing. You can send messages when it is ready.", initFailed: "Codex conversation initialization failed. Start or resume a conversation.", placeholder: "Ask Codex, type / for Skills, or @ to reference canvas assets" },
chat: { latestMessages: "View latest messages", latestCall: "Latest call", input: "Input", cached: "Cached", output: "Output" },
logs: { copied: "Logs copied", selectedManual: "Logs selected; copy them manually", copyFailed: "Copy failed. Switch to raw JSON and copy manually.", title: "Runtime logs", diagnostics: "Diagnostics", rawJson: "Raw JSON", messages: "{{count}} messages", tool: "Tool: {{tool}}", noPendingTool: "No pending tool", all: "All {{count}}", errors: "Errors {{count}}", warnings: "Warnings {{count}}", info: "Info {{count}}", list: "Diagnostics log list", noFiltered: "No logs match this filter", empty: "No event logs", new: "{{count}} new logs", newLabel: "{{count}} new logs. View latest logs.", latest: "View latest logs", fullData: "Full diagnostics · {{count}} entries", copyAll: "Copy all logs", copyLastError: "Copy latest error", lastErrorCopied: "Latest error copied", clear: "Clear logs", repeated: "Repeated {{count}} times", details: "Details", skillLoading: "Skill loading", plugin: "Plugin", terminal: "Terminal", conversationStorage: "Conversation storage" },
message: { close: "Close", copied: "Copied", copyCode: "Copy code", copyLink: "Copy link", externalWarning: "You are about to open an external link. Make sure you trust it.", openExternal: "Open external link?", continueOpen: "Open", revealed: "Revealed in file manager", openLocalFailed: "Could not open the local file", openLocal: "Open local file?", localDescription: "This path will be revealed in your local file manager and will not be opened in the browser.", externalDescription: "You are about to open an external link. Make sure you trust it.", pathCopied: "Path copied", linkCopied: "Link copied", copyPath: "Copy path", showInFolder: "Show in file manager", toolCall: "Tool call", awaitingConfirmation: "Awaiting confirmation", reject: "Reject", approve: "Approve", networkApproval: "Network access request", fileApproval: "File edit request", permissionApproval: "Extended permission request", commandApproval: "Command execution request", decline: "Decline", allowOnce: "Allow once", allowSession: "Allow for session", thinking: "Thinking", commandsRunning: "Running {{count}} commands", commandRunning: "Running command", commandsCompleted: "Ran {{count}} commands{{failed}}", commandsFailed: " · {{count}} failed", failed: "Failed", running: "In progress", completed: "Completed", command: "Command", slowResponse: "The response is taking longer, but the task is still running. You can keep waiting or use the stop button beside the input to end this turn.", waitingSeconds: "Waited {{seconds}} seconds", waitingMinutes: "Waited {{minutes}}m {{seconds}}s", files: "Files", errorInfo: "Error", output: "Output", viewLarge: "View full image", attachmentPreview: "Image attachment preview", noEffect: "No effect", canceled: "Canceled", recorded: "Recorded", stopped: "Stopped", finished: "Finished", pending: "Pending" },
@@ -597,7 +597,7 @@ export default {
runtime: {
mcpStarting: "Starting MCP: {{name}}", mcpConnecting: "Connecting tools and loading the available tool list", mcpReadyNamed: "MCP ready: {{name}}", toolsReady: "Tool list loaded. You can start chatting.", mcpFailedNamed: "MCP failed to start: {{name}}", mcpCanceledNamed: "MCP startup canceled: {{name}}", toolInitFailed: "The tool service could not finish initializing", mcpServicesStarting: "Starting MCP services", toolServicesPending: "{{count}} tool services are still initializing", checkingToolServices: "Checking tool service status", conversationInitializing: "Initializing the Codex conversation", conversationCreating: "Creating the conversation and starting canvas tools", someMcpFailed: "Some MCP services failed to initialize", remainingToolsReady: "The remaining tools are ready. You can start chatting.", conversationInitFailed: "Codex conversation initialization failed", conversationCreateFailed: "Could not create the Codex conversation", mcpServicesReady: "{{count}} MCP services initialized", toolInitCanceled: "Tool service initialization was canceled", mcpStatusComplete: "MCP status check completed", mcpListRead: "Loaded the complete tool service list returned by Codex",
historyReadFailed: "Failed to read history", conversationSyncFailed: "Failed to sync conversation", agentOutdated: "The local Agent is outdated. Restart Canvas Agent and reconnect.", restartRequired: "Agent restart required", versionMismatch: "Agent version mismatch", awaitingApproval: "Awaiting approval", codexRunning: "Codex is running", connected: "Connected", localAgentConnected: "Local Agent connected", processingFailed: "Processing failed", completed: "Completed", approvalGranted: "Permission approved", approvalCanceled: "Permission canceled", log: "Log", connectionLostDescription: "The local Agent connection failed or was interrupted", connectionFailedDescription: "Connection failed. Check the address and token.", connectionLost: "Connection lost", connectionFailed: "Connection failed", modelListFailed: "Failed to load model list",
imageTooLarge: "Image too large", imagePayloadTooLarge: "Image attachments exceed 30 MB. Remove some before sending.", imagesSent: "Sent {{count}} images", sending: "Sending", defaultModel: "Default model", defaultEffort: "Default effort", sendTask: "Send task", attachmentCount: "{{count}} attachments", attachmentsOnly: "Attachments only", startConversationFailed: "Failed to start conversation", attachmentHistoryFailed: "Failed to save attachment history", sendFailed: "Send failed", conversationSynced: "Conversation synced", taskStillRunning: "Task is still running", stopping: "Stopping", stopTask: "Stop task", taskStopped: "Task stopped", stopFailed: "Failed to stop", imageLimit: "Image attachments are limited to about 30 MB.", imageReadFailed: "Failed to read image", pendingCanvasTool: "Another canvas tool call is awaiting confirmation", awaitingConfirmation: "Awaiting confirmation", toolCompleted: "{{tool}} completed", toolExecutionFailed: "Tool execution failed", openCanvasFirst: "The canvas is not open. Use site_navigate to open it first.", canvasOperationFailed: "Canvas operation failed", canvasToolCanceled: "The user canceled the canvas tool call",
imageTooLarge: "Image too large", imagePayloadTooLarge: "Image attachments exceed 30 MB. Remove some before sending.", imagesSent: "Sent {{count}} images", canvasReferencesSent: "Referenced {{count}} canvas assets", someCanvasReferencesMissing: "Some referenced canvas assets no longer exist", canvasReferencesMissing: "The referenced canvas assets no longer exist. Select them again.", readingCanvasImages: "Reading canvas images", canvasImageReadFailed: "Failed to read canvas images", tooManyImages: "Too many images", imageCountLimit: "Uploaded and referenced canvas images are limited to {{count}} in total.", sending: "Sending", defaultModel: "Default model", defaultEffort: "Default effort", sendTask: "Send task", attachmentCount: "{{count}} attachments", canvasReferenceCount: "{{count}} canvas assets", attachmentsOnly: "Attachments only", canvasReferencesOnly: "Canvas assets", startConversationFailed: "Failed to start conversation", attachmentHistoryFailed: "Failed to save attachment history", sendFailed: "Send failed", conversationSynced: "Conversation synced", taskStillRunning: "Task is still running", stopping: "Stopping", stopTask: "Stop task", taskStopped: "Task stopped", stopFailed: "Failed to stop", imageLimit: "Image attachments are limited to about 30 MB.", imageReadFailed: "Failed to read image", pendingCanvasTool: "Another canvas tool call is awaiting confirmation", awaitingConfirmation: "Awaiting confirmation", toolCompleted: "{{tool}} completed", toolExecutionFailed: "Tool execution failed", openCanvasFirst: "The canvas is not open. Use site_navigate to open it first.", canvasOperationFailed: "Canvas operation failed", canvasToolCanceled: "The user canceled the canvas tool call",
submittingApproval: "Submitting permission decision", waitingCodexApproval: "Waiting for Codex to confirm permission", approvalFailed: "Permission approval failed", enableFullAccess: "Enable full access", fullAccessDescription: "Codex will run without sandbox restrictions and can access the internet and any local files. Use this only when you trust the current task.", enableFullAccessAction: "Enable full access", offline: "Offline", addressRequired: "Enter the local Agent address", agentNotFound: "No local Agent was found. Use the Codex plugin or start Canvas Agent manually.", invalidAddress: "The local Agent address is invalid", connecting: "Connecting", creatingConversation: "Creating conversation", newConversation: "New conversation", newConversationFailed: "Failed to create conversation", conversationResumed: "Conversation resumed", resumeConversationFailed: "Failed to resume conversation", recordsDeleted: "Deleted {{count}} records", deleteConversationFailed: "Failed to delete conversation", deleteConversations: "Delete {{count}} conversations", deleteConversationsDescription: "This cannot be undone. Continue?", importGeneratedImages: "Import generated images", addedToSourceCanvas: "Added to the source canvas", imageGenerated: "Image generated", noImageAttachments: "No image attachments to add", invalidAttachmentNode: "Invalid image attachment node parameters", attachmentReadFailed: "Failed to read image attachment", referenceImage: "Reference image", generatedImageReadFailed: "Failed to read the image generated by Codex", generatedImageName: "Generated image {{index}}",
},
},
+3 -3
View File
@@ -586,8 +586,8 @@ export default {
fromConversation: "从当前对话生成草稿", availableAfterRun: "Codex 运行结束后可用", conversationDescription: "整理当前对话中的可复用流程", noCompletedContent: "当前对话还没有已完成内容", startConversation: "请先开始一段对话", fromCanvas: "从当前画布生成草稿", canvasDescription: "整理当前页面的节点与生成流程", canvasUnavailable: "当前页面没有可用画布", blankCreate: "空白创建", blankDescription: "从空白表单开始编写", localSkills: "本地 Skill", localDescription: "安装在本机,由 Codex 直接执行", reload: "重新读取", reloadSkill: "重新读取 Skill", createSkill: "创建 Skill", filterBySource: "按来源筛选 Skill", scopes: { all: "全部来源", repo: "项目", user: "个人", system: "系统", admin: "管理员" }, loadErrors: "{{count}} 个 Skill 未能加载", externalReadonly: "外部 Skill 只能使用或启停", noDescription: "暂无说明", enabled: "已启用", disabled: "已停用", selected: "已选择", use: "使用", editNamed: "编辑 {{name}}", deleteNamed: "删除 {{name}}", connectToView: "连接 Agent 后查看 Skill", noMatch: "没有匹配的 Skill", none: "还没有本地 Skill", connectDescription: "连接成功后会读取本机已安装的 Skill", tryAnotherFilter: "换个关键词或来源试试", createOrInstall: "创建一个,或在本机安装后刷新", saveChanges: "保存更改", saveLocation: "保存到本地 Agent 工作区", basicInfo: "基本信息",
identifier: "Skill 标识", identifierExtra: "用于文件夹名和 $skill-name 调用。", identifierRequired: "请输入 Skill 标识", identifierMax: "Skill 标识不能超过 64 个字符", identifierPattern: "仅支持小写字母、数字和连字符,连字符不能连续或位于首尾", identifierPlaceholder: "例如 product-grid", displayName: "显示名称", displayNameMax: "显示名称不能超过 64 个字符", displayNamePlaceholder: "例如 产品九宫格生成", whenToUse: "何时使用", whenToUseExtra: "说明这个 Skill 的能力和适用场景,Codex 会据此判断是否调用。", whenToUseRequired: "请输入使用场景", whenToUseMax: "使用场景不能超过 1024 个字符", noAngleBrackets: "使用场景不能包含尖括号", whenToUsePlaceholder: "例如:当用户需要基于商品信息规划并生成一组产品图时使用", instructions: "执行说明", instructionsExtra: "按实际执行顺序写清步骤、约束和输出要求。", instructionsRequired: "请输入执行说明", instructionsPlaceholder: "写清楚执行步骤、必要检查和最终输出", advanced: "高级设置", shortDescription: "卡片短说明", shortDescriptionExtra: "填写时控制在 25–64 个字符,便于快速浏览。", shortDescriptionMin: "卡片短说明不能少于 25 个字符", shortDescriptionMax: "卡片短说明不能超过 64 个字符", shortDescriptionPlaceholder: "可选,用于列表展示", defaultPrompt: "默认提示词", defaultPromptExtra: "填写时必须准确包含 $skill-name,例如 $product-grid。", defaultPromptMax: "默认提示词不能超过 1024 个字符", defaultPromptPlaceholder: "可选,选择 Skill 时预填到输入框",
},
composer: { removeImage: "移除图片", removeSkill: "移除 Skill", uploadImage: "上传图片", stop: "停止", send: "发送", model: "模型:{{model}}", selectModel: "选择 Codex 模型,当前为 {{model}}", reasoning: "思考程度:{{effort}}", selectReasoning: "选择思考程度,当前为 {{effort}}", permissionLabel: "权限:{{mode}}", selectPermission: "选择 Codex 权限模式,当前为 {{mode}}", effort: { minimal: "最低", low: "轻度", medium: "中", high: "高", xhigh: "极高", max: "最高", ultra: "Ultra" }, permission: { request: "请求批准", requestDescription: "编辑工作区外文件或联网时始终询问", automatic: "自动审查", automaticDescription: "由 Codex 审查风险操作,必要时再询问", full: "完全访问权限", fullShort: "完全访问", fullDescription: "不受限制地访问网络和本机文件" }, tools: { label: "工具确认:{{mode}}", manual: "手动确认", manualDescription: "Agent 执行画布写入前会请求确认", automatic: "自动确认", automaticDescription: "Agent 会自动执行画布写入操作", select: "选择工具确认模式,当前为 {{mode}}" } },
panel: { connectionSettings: "连接设置 · {{status}}", connectionSettingsLabel: "连接设置,当前{{status}}", chat: "对话", history: "历史", skills: "技能", logs: "日志", collapse: "收起对话", collapseLabel: "收起 Agent 面板", resize: "调整右侧面板宽度", content: "Agent 内容", mcpInitializing: "MCP 初始化中,完成后即可发送", initFailed: "Codex 对话初始化失败,请新建或恢复对话", placeholder: "询问 Codex,或让它操作网站/画布" },
composer: { removeImage: "移除图片", removeSkill: "移除 Skill", uploadImage: "上传图片", stop: "停止", send: "发送", model: "模型:{{model}}", selectModel: "选择 Codex 模型,当前为 {{model}}", reasoning: "思考程度:{{effort}}", selectReasoning: "选择思考程度,当前为 {{effort}}", permissionLabel: "权限:{{mode}}", selectPermission: "选择 Codex 权限模式,当前为 {{mode}}", effort: { minimal: "最低", low: "轻度", medium: "中", high: "高", xhigh: "极高", max: "最高", ultra: "Ultra" }, permission: { request: "请求批准", requestDescription: "编辑工作区外文件或联网时始终询问", automatic: "自动审查", automaticDescription: "由 Codex 审查风险操作,必要时再询问", full: "完全访问权限", fullShort: "完全访问", fullDescription: "不受限制地访问网络和本机文件" }, tools: { label: "工具确认:{{mode}}", manual: "手动确认", manualDescription: "Agent 执行画布写入前会请求确认", automatic: "自动确认", automaticDescription: "Agent 会自动执行画布写入操作", select: "选择工具确认模式,当前为 {{mode}}" }, mentions: { selectSkill: "选择 Skill", selectResource: "引用画布素材", loadingSkills: "正在读取 Skill…", noSkills: "没有匹配的已启用 Skill", noResources: "当前画布没有匹配的可引用素材", previewUnavailable: "当前无法预览该素材", referenceLabel: "引用{{kind}}:{{title}}", resourceMissing: "画布素材“{{title}}”已不存在", imageReadFailed: "无法读取画布图片“{{title}}”", kind: { image: "图片", video: "视频", audio: "音频", text: "文本" } } },
panel: { connectionSettings: "连接设置 · {{status}}", connectionSettingsLabel: "连接设置,当前{{status}}", chat: "对话", history: "历史", skills: "技能", logs: "日志", collapse: "收起对话", collapseLabel: "收起 Agent 面板", resize: "调整右侧面板宽度", content: "Agent 内容", mcpInitializing: "MCP 初始化中,完成后即可发送", initFailed: "Codex 对话初始化失败,请新建或恢复对话", placeholder: "询问 Codex,输入 / 使用技能,@ 引用画布素材" },
chat: { latestMessages: "查看最新消息", latestCall: "最新调用", input: "输入", cached: "缓存", output: "输出" },
logs: { copied: "日志已复制", selectedManual: "已选中日志,请手动复制", copyFailed: "复制失败,请切换到原始 JSON 后手动复制", title: "运行日志", diagnostics: "排查日志", rawJson: "原始 JSON", messages: "{{count}} 条消息", tool: "工具:{{tool}}", noPendingTool: "无待处理工具", all: "全部 {{count}}", errors: "错误 {{count}}", warnings: "警告 {{count}}", info: "信息 {{count}}", list: "排查日志列表", noFiltered: "当前筛选下没有日志", empty: "暂无事件日志", new: "{{count}} 条新日志", newLabel: "{{count}} 条新日志,查看最新日志", latest: "查看最新日志", fullData: "完整诊断数据 · {{count}} 条", copyAll: "复制全部日志", copyLastError: "复制最近错误", lastErrorCopied: "最近错误已复制", clear: "清空日志", repeated: "重复 {{count}} 次", details: "详细信息", skillLoading: "技能加载", plugin: "插件", terminal: "终端", conversationStorage: "会话存储" },
message: { close: "关闭", copied: "已复制", copyCode: "复制代码", copyLink: "复制链接", externalWarning: "即将打开以下外部链接,请确认链接可信。", openExternal: "打开外部链接?", continueOpen: "继续打开", revealed: "已在文件管理器中定位", openLocalFailed: "无法打开本地文件", openLocal: "打开本地文件?", localDescription: "将在本机文件管理器中定位该路径,不会通过浏览器打开。", externalDescription: "即将打开以下外部链接,请确认链接可信。", pathCopied: "路径已复制", linkCopied: "链接已复制", copyPath: "复制路径", showInFolder: "在文件管理器中显示", toolCall: "工具调用", awaitingConfirmation: "等待确认", reject: "拒绝执行", approve: "批准执行", networkApproval: "请求网络访问", fileApproval: "请求编辑文件", permissionApproval: "请求扩展权限", commandApproval: "请求执行命令", decline: "拒绝", allowOnce: "允许一次", allowSession: "本会话允许", thinking: "正在思考", commandsRunning: "正在执行 {{count}} 条命令", commandRunning: "正在执行命令", commandsCompleted: "已执行 {{count}} 条命令{{failed}}", commandsFailed: " · {{count}} 条失败", failed: "执行失败", running: "进行中", completed: "已完成", command: "命令", slowResponse: "响应时间较长,但任务仍在运行。可以继续等待,或点击输入框右侧的停止按钮结束本轮。", waitingSeconds: "已等待 {{seconds}} 秒", waitingMinutes: "已等待 {{minutes}} 分 {{seconds}} 秒", files: "涉及文件", errorInfo: "错误信息", output: "运行输出", viewLarge: "点击查看大图", attachmentPreview: "图片附件预览", noEffect: "未生效", canceled: "已取消", recorded: "已记录", stopped: "已停止", finished: "已结束", pending: "待处理" },
@@ -597,7 +597,7 @@ export default {
runtime: {
mcpStarting: "正在启动 MCP:{{name}}", mcpConnecting: "正在建立工具连接并读取可用工具列表", mcpReadyNamed: "MCP 已就绪:{{name}}", toolsReady: "工具列表加载完成,可以开始对话", mcpFailedNamed: "MCP 启动失败:{{name}}", mcpCanceledNamed: "MCP 启动已取消:{{name}}", toolInitFailed: "工具服务未能完成初始化", mcpServicesStarting: "正在启动 MCP 服务", toolServicesPending: "还有 {{count}} 个工具服务正在初始化", checkingToolServices: "正在确认工具服务状态", conversationInitializing: "正在初始化 Codex 对话", conversationCreating: "正在创建会话并启动画布工具服务", someMcpFailed: "部分 MCP 服务初始化失败", remainingToolsReady: "其余工具已就绪,可以开始对话", conversationInitFailed: "Codex 对话初始化失败", conversationCreateFailed: "无法创建 Codex 会话", mcpServicesReady: "{{count}} 个 MCP 服务已完成初始化", toolInitCanceled: "工具服务初始化已取消", mcpStatusComplete: "MCP 状态确认完成", mcpListRead: "已读取 Codex 返回的完整工具服务清单",
historyReadFailed: "读取历史失败", conversationSyncFailed: "同步会话失败", agentOutdated: "本地 Agent 版本过旧,请重启 Canvas Agent 后重新连接", restartRequired: "需要重启 Agent", versionMismatch: "Agent 版本不匹配", awaitingApproval: "等待权限确认", codexRunning: "Codex 正在运行", connected: "已连接", localAgentConnected: "本地 Agent 已连接", processingFailed: "处理失败", completed: "完成", approvalGranted: "已批准权限", approvalCanceled: "已取消权限", log: "日志", connectionLostDescription: "本地 Agent 连接失败或已断开", connectionFailedDescription: "连接失败,请检查地址和 token", connectionLost: "连接断开", connectionFailed: "连接失败", modelListFailed: "读取模型列表失败",
imageTooLarge: "图片过大", imagePayloadTooLarge: "图片附件超过 30MB,请删减后再发送。", imagesSent: "发送了 {{count}} 张图片", sending: "发送中", defaultModel: "默认模型", defaultEffort: "默认强度", sendTask: "发送任务", attachmentCount: "附件 {{count}}", attachmentsOnly: "仅附件", startConversationFailed: "启动对话失败", attachmentHistoryFailed: "保存附件历史失败", sendFailed: "发送失败", conversationSynced: "会话已同步", taskStillRunning: "任务仍在运行", stopping: "停止中", stopTask: "停止任务", taskStopped: "任务已停止", stopFailed: "停止失败", imageLimit: "图片附件最多约 30MB。", imageReadFailed: "图片读取失败", pendingCanvasTool: "仍有待确认的画布工具调用", awaitingConfirmation: "等待确认", toolCompleted: "{{tool}}完成", toolExecutionFailed: "工具执行失败", openCanvasFirst: "当前不在画布页,请先用 site_navigate 打开画布", canvasOperationFailed: "画布操作失败", canvasToolCanceled: "用户取消了画布工具调用",
imageTooLarge: "图片过大", imagePayloadTooLarge: "图片附件超过 30MB,请删减后再发送。", imagesSent: "发送了 {{count}} 张图片", canvasReferencesSent: "引用了 {{count}} 个画布素材", someCanvasReferencesMissing: "部分引用的画布素材已不存在", canvasReferencesMissing: "引用的画布素材已不存在,请重新选择", readingCanvasImages: "正在读取画布图片", canvasImageReadFailed: "画布图片读取失败", tooManyImages: "图片数量过多", imageCountLimit: "上传图片和引用的画布图片合计最多 {{count}} 张。", sending: "发送中", defaultModel: "默认模型", defaultEffort: "默认强度", sendTask: "发送任务", attachmentCount: "附件 {{count}}", canvasReferenceCount: "画布素材 {{count}}", attachmentsOnly: "仅附件", canvasReferencesOnly: "画布素材", startConversationFailed: "启动对话失败", attachmentHistoryFailed: "保存附件历史失败", sendFailed: "发送失败", conversationSynced: "会话已同步", taskStillRunning: "任务仍在运行", stopping: "停止中", stopTask: "停止任务", taskStopped: "任务已停止", stopFailed: "停止失败", imageLimit: "图片附件最多约 30MB。", imageReadFailed: "图片读取失败", pendingCanvasTool: "仍有待确认的画布工具调用", awaitingConfirmation: "等待确认", toolCompleted: "{{tool}}完成", toolExecutionFailed: "工具执行失败", openCanvasFirst: "当前不在画布页,请先用 site_navigate 打开画布", canvasOperationFailed: "画布操作失败", canvasToolCanceled: "用户取消了画布工具调用",
submittingApproval: "正在提交权限决定", waitingCodexApproval: "等待 Codex 确认权限", approvalFailed: "权限审批失败", enableFullAccess: "启用完全访问权限", fullAccessDescription: "Codex 将不受沙箱限制,可访问互联网及本机任意文件。请仅在信任当前任务时使用。", enableFullAccessAction: "启用完全访问", offline: "离线", addressRequired: "请填写本地 Agent 地址", agentNotFound: "没有发现本地 Agent,请先在 Codex 使用插件或手动启动 Canvas Agent", invalidAddress: "本地 Agent 地址格式不正确", connecting: "连接中", creatingConversation: "正在新建对话", newConversation: "新对话", newConversationFailed: "新建对话失败", conversationResumed: "已恢复会话", resumeConversationFailed: "恢复对话失败", recordsDeleted: "已删除 {{count}} 条记录", deleteConversationFailed: "删除对话失败", deleteConversations: "删除 {{count}} 条对话记录", deleteConversationsDescription: "删除后无法恢复,确定继续吗?", importGeneratedImages: "导入生成图片", addedToSourceCanvas: "已添加到发起任务的画布", imageGenerated: "图片已生成", noImageAttachments: "没有可添加的图片附件", invalidAttachmentNode: "图片附件节点参数无效", attachmentReadFailed: "读取图片附件失败", referenceImage: "参考图", generatedImageReadFailed: "读取 Codex 生成图片失败", generatedImageName: "生成图片 {{index}}",
},
},
@@ -2,6 +2,8 @@ import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import i18n from "@/i18n";
import { seedanceReferenceLabel } from "@/lib/seedance-video";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
import { imageToDataUrl } from "@/services/image-storage";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
export type CanvasResourceKind = "image" | "video" | "audio" | "text";
@@ -21,6 +23,34 @@ export function buildNodeMentionReferences(node: CanvasNodeData, nodes: CanvasNo
return labelResourceNodes(getMentionResourceNodes(node.id, nodes, connections), true);
}
export function buildCanvasResourceReferences(nodes: CanvasNodeData[]) {
return labelResourceNodes(nodes, true);
}
export async function resolveCanvasReferenceImages(references: CanvasResourceReference[], nodes: CanvasNodeData[]) {
const nodesById = new Map(nodes.map((node) => [node.id, node]));
return Promise.all(references.filter((reference) => reference.kind === "image").map(async (reference) => {
const node = nodesById.get(reference.nodeId);
if (!node) throw new Error(i18n.t("agent.composer.mentions.resourceMissing", { title: reference.title }));
const metadata = node.metadata;
const dataUrl = await imageToDataUrl({ storageKey: metadata?.storageKey, url: reference.previewUrl });
if (!dataUrl.startsWith("data:image/")) throw new Error(i18n.t("agent.composer.mentions.imageReadFailed", { title: reference.title }));
const meta = metadata?.naturalWidth && metadata.naturalHeight
? { width: metadata.naturalWidth, height: metadata.naturalHeight, mimeType: metadata.mimeType || dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png" }
: await readImageMeta(dataUrl);
return {
id: `canvas:${node.id}`,
name: reference.title,
type: metadata?.mimeType || meta.mimeType,
size: metadata?.bytes || getDataUrlByteSize(dataUrl),
width: meta.width,
height: meta.height,
url: reference.previewUrl || dataUrl,
dataUrl,
};
}));
}
export function getMentionResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const configInputs = getConnectedConfigResourceNodes(nodeId, nodes, connections);
if (configInputs.length) return configInputs;
@@ -56,6 +86,7 @@ function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
return nodes.flatMap((node): CanvasResourceReference[] => {
const kind = resourceKind(node);
if (!kind) return [];
const resource = getNodeDefinition(node.type)?.resource?.(node);
const index = counts[kind]++;
const label = labelForKind(kind, index);
return [
@@ -65,7 +96,7 @@ function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
kind,
label,
title: node.title || label,
previewUrl: node.metadata?.content,
previewUrl: node.metadata?.content || resource?.url,
text: resourceText(node),
active,
},
+8
View File
@@ -3,6 +3,7 @@ import type { CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import type { AgentReasoningEffort } from "@/stores/use-agent-store";
type AgentConfigResponse = { ok?: boolean; protocolVersion?: number; url?: string; token?: string; hasToken?: boolean };
const AGENT_MESSAGE_ASSET_PATTERN = /^agent-asset:([a-f0-9]{64})\/([a-f0-9]{64}\.(?:gif|jpe?g|png|webp))$/;
export class AgentApiError<T = unknown> extends Error {
constructor(readonly status: number, readonly response: T & { code?: string; error?: string; msg?: string }) {
@@ -79,6 +80,13 @@ export async function revealAgentLocalFile(endpoint: string, token: string, path
await fetchAgentJson(endpoint, token, "/agent/local-file/reveal", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) });
}
export function resolveAgentMessageAssetUrl(endpoint: string, token: string, value: string) {
const match = AGENT_MESSAGE_ASSET_PATTERN.exec(value);
if (!match) return value.startsWith("agent-asset:") ? "" : value;
const baseUrl = endpoint.trim().replace(/\/$/, "");
return baseUrl && token ? `${baseUrl}/agent/message-assets/${match[1]}/${match[2]}?token=${encodeURIComponent(token)}` : "";
}
export function fetchCodexSkills(endpoint: string, token: string, forceReload = false) {
return fetchAgentJson<AgentSkillsResponse>(endpoint, token, `/agent/codex/skills${forceReload ? "?forceReload=1" : ""}`);
}
+35 -7
View File
@@ -18,7 +18,7 @@ type AgentSkillStore = {
loaded: boolean;
errors: string[];
loadSkills: (endpoint: string, token: string, forceReload?: boolean) => Promise<void>;
selectSkill: (skill: AgentSkillSummary | null) => void;
selectSkill: (skill: AgentSkillSummary | null, prompt?: string) => void;
clearSelection: (expectedRevision?: number) => void;
setDraft: (draft: AgentSkillDraft | null) => void;
setGeneratingSource: (source: "conversation" | "canvas" | null) => void;
@@ -47,24 +47,41 @@ export const useAgentSkillStore = create<AgentSkillStore>((set, get) => ({
const selected = current.selectedSkill;
const selectedSkill = selected ? skills.find((item) => item.name === selected.name && item.path === selected.path && item.enabled) || null : null;
const selectionChanged = Boolean(selected && !selectedSkill);
if (selectionChanged) clearSelectedSkillPrompt(current);
const autoPrompt = selectedSkill
? replaceAutoPrompt(current.autoPrompt, selectedSkill.interface?.defaultPrompt?.trim() || "", false)
: replaceAutoPrompt(current.autoPrompt, "", false);
? replaceAutoPrompt(current.autoPrompt, selectedSkill.interface?.defaultPrompt?.trim() || `$${selectedSkill.name}`, false)
: "";
set({ skills, selectedSkill, autoPrompt, selectionRevision: current.selectionRevision + (selectionChanged ? 1 : 0), loading: false, loaded: true, errors: (response.errors || []).map(skillErrorText) });
} catch (error) {
if (sequence !== loadSequence) return;
set({ loading: false, loaded: true, errors: [error instanceof Error ? error.message : i18n.t("agent.state.skillReadFailed")] });
}
},
selectSkill: (selectedSkill) => {
selectSkill: (selectedSkill, prompt) => {
const current = get();
const autoPrompt = replaceAutoPrompt(current.autoPrompt, selectedSkill?.interface?.defaultPrompt?.trim() || "", true);
const agent = useAgentStore.getState();
const marker = selectedSkill ? `$${selectedSkill.name}` : "";
const previousMarker = current.selectedSkill ? `$${current.selectedSkill.name}` : "";
let nextPrompt = prompt;
let autoPrompt = "";
if (nextPrompt === undefined && selectedSkill) {
const defaultPrompt = selectedSkill.interface?.defaultPrompt?.trim() || marker;
if (!agent.prompt.trim() || Boolean(current.autoPrompt && agent.prompt === current.autoPrompt)) {
nextPrompt = defaultPrompt;
autoPrompt = defaultPrompt;
} else if (previousMarker && agent.prompt.includes(previousMarker)) {
nextPrompt = agent.prompt.replace(previousMarker, marker);
} else {
nextPrompt = agent.prompt.includes(marker) ? agent.prompt : `${agent.prompt}${/\s$/.test(agent.prompt) ? "" : " "}${marker}`;
}
}
if (nextPrompt !== undefined && agent.prompt !== nextPrompt) agent.setAgentState({ prompt: nextPrompt });
set({ selectedSkill, autoPrompt, selectionRevision: current.selectionRevision + 1 });
},
clearSelection: (expectedRevision) => {
const current = get();
if (expectedRevision !== undefined && expectedRevision !== current.selectionRevision) return;
replaceAutoPrompt(current.autoPrompt, "", false);
clearSelectedSkillPrompt(current);
set({ selectedSkill: null, autoPrompt: "", selectionRevision: current.selectionRevision + 1 });
},
setDraft: (draft) => set({ draft }),
@@ -72,7 +89,7 @@ export const useAgentSkillStore = create<AgentSkillStore>((set, get) => ({
reset: () => {
loadSequence += 1;
const current = get();
replaceAutoPrompt(current.autoPrompt, "", false);
clearSelectedSkillPrompt(current);
set({ skills: [], selectedSkill: null, selectionRevision: current.selectionRevision + 1, connectionRevision: current.connectionRevision + 1, autoPrompt: "", draft: null, generatingSource: null, loading: false, loaded: false, errors: [] });
},
}));
@@ -85,6 +102,17 @@ function replaceAutoPrompt(previous: string, next: string, fillEmpty: boolean) {
return next;
}
function clearSelectedSkillPrompt(selection: Pick<AgentSkillStore, "selectedSkill" | "autoPrompt">) {
const agent = useAgentStore.getState();
if (selection.autoPrompt && agent.prompt === selection.autoPrompt) {
agent.setAgentState({ prompt: "" });
return;
}
if (!selection.selectedSkill) return;
const marker = `$${selection.selectedSkill.name}`;
if (agent.prompt.includes(marker)) agent.setAgentState({ prompt: agent.prompt.replace(marker, "") });
}
function skillErrorText(value: unknown) {
if (typeof value === "string") return value;
if (!value || typeof value !== "object") return i18n.t("agent.state.skillParseFailed");
+7 -1
View File
@@ -2,10 +2,14 @@ import { create } from "zustand";
import i18n from "@/i18n";
import type { CanvasAgentOp, CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
export type AgentChatRole = "user" | "assistant" | "system" | "tool" | "error";
export type AgentAttachment = { id: string; name: string; type: string; size: number; width: number; height: number; url: string; dataUrl: string };
export type AgentChatItem = { id: string; itemId?: string; clientMessageId?: string; threadId?: string; turnId?: string; role: AgentChatRole; title?: string; text: string; historyText?: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string; activityItems?: Record<string, string> };
export type AgentMessageAttachment = Pick<AgentAttachment, "id" | "name" | "url"> & Partial<Pick<AgentAttachment, "type" | "size" | "width" | "height" | "dataUrl">>;
export type AgentCanvasReference = Pick<CanvasResourceReference, "nodeId" | "label" | "title" | "kind" | "previewUrl" | "text">;
export type AgentSkillReference = { name: string; path: string; displayName?: string };
export type AgentChatItem = { id: string; itemId?: string; clientMessageId?: string; threadId?: string; turnId?: string; role: AgentChatRole; title?: string; text: string; meta?: string; detail?: unknown; attachments?: AgentMessageAttachment[]; canvasReferences?: AgentCanvasReference[]; skill?: AgentSkillReference; streamId?: string; activityItems?: Record<string, string> };
export type AgentEventLog = { id: string; time: string; title: string; text: string; raw?: unknown };
export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[]; path?: string } & Record<string, unknown> };
export type AgentPermissionMode = "request" | "automatic" | "full";
@@ -52,6 +56,7 @@ type AgentStore = {
silentConnect: boolean;
prompt: string;
attachments: AgentAttachment[];
canvasReferences: CanvasResourceReference[];
sending: boolean;
waiting: boolean;
messages: AgentChatItem[];
@@ -102,6 +107,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
silentConnect: false,
prompt: "",
attachments: [],
canvasReferences: [],
sending: false,
waiting: false,
messages: [],