源码
packages/ui/src/components/InputContext.tsx
import { type Attachment, type ImageAttachment, isImageAttachment } from "@zpi-dev/coding-agent/input";
import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ConversationSelection } from "../conversation-selections.ts";
import type { FileLocation } from "../link-target.ts";
import { userErrorMessage } from "../user-error.ts";
import { EditorHistory } from "./editor-history.ts";
import { FileIcon } from "./Reference.tsx";
export interface ComposerDraft {
selections?: ConversationSelection[];
selection?: [number, number];
warnings?: string[];
text: string;
fileReferences: string[];
attachments: Attachment[];
pending: number;
error?: string;
}
export class ComposerDraftStore extends Map<string, ComposerDraft> {
readonly submitting = new Set<string>();
readonly histories = new Map<string, EditorHistory>();
private listeners = new Set<(id: string) => void>();
private contentRevisions = new Map<string, number>();
private errorVersions = new Map<string, number>();
errorVersion(id: string): number {
return this.errorVersions.get(id) ?? 0;
}
revision(id: string): number {
return this.contentRevisions.get(id) ?? 0;
}
history(id: string): EditorHistory {
let history = this.histories.get(id);
if (!history) {
history = new EditorHistory();
this.histories.set(id, history);
}
return history;
}
subscribe(listener: (id: string) => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
override set(id: string, draft: ComposerDraft): this {
if (draft.selection) {
const clamp = (offset: number) => Math.max(0, Math.min(draft.text.length, offset));
const start = clamp(draft.selection[0]),
end = clamp(draft.selection[1]);
if (start !== draft.selection[0] || end !== draft.selection[1])
draft = { ...draft, selection: [start, end] };
}
const previous = this.get(id);
// Clearing and reporting an identical error can be batched into one React render.
if (draft.error && draft.error !== previous?.error) this.errorVersions.set(id, this.errorVersion(id) + 1);
if (
!previous ||
previous.text !== draft.text ||
JSON.stringify(previous.fileReferences) !== JSON.stringify(draft.fileReferences) ||
JSON.stringify(previous.selections) !== JSON.stringify(draft.selections) ||
// Attachments are part of the draft; late submit results must see them as a newer revision.
JSON.stringify(previous.attachments.map((attachment) => attachment.id)) !==
JSON.stringify(draft.attachments.map((attachment) => attachment.id))
)
this.contentRevisions.set(id, this.revision(id) + 1);
super.set(id, draft);
for (const listener of this.listeners) listener(id);
return this;
}
override delete(id: string): boolean {
const deleted = super.delete(id);
this.histories.delete(id);
this.contentRevisions.delete(id);
this.errorVersions.delete(id);
for (const listener of this.listeners) listener(id);
return deleted;
}
}
export interface ComposerContext {
readFileChanges?(sessionId: string, runId: string): Promise<import("../types.ts").FileChangeSummary[]>;
listSessionMentions?(sessionId: string): Promise<import("../types.ts").SessionMentionSuggestion[]>;
searchFiles(
sessionId: string,
query: string,
): Promise<{ path: string; name: string; absolutePath: string; type: "file" | "directory" }[]>;
pickAttachments(sessionId: string): Promise<Attachment[]>;
importAttachment(sessionId: string, file: File): Promise<Attachment>;
removeAttachment(sessionId: string, id: string): Promise<void>;
readAttachment(sessionId: string, id: string): Promise<string>;
readImage?(sessionId: string, path: string, location?: FileLocation): Promise<string>;
readWebSourceIcon?(sessionId: string, runId: string, sourceUrl: string): Promise<string | undefined>;
downloadImage?(src: string): Promise<void>;
}
export function AttachmentPreview({
image,
...props
}: {
sessionId: string;
image: Attachment;
read: ComposerContext["readAttachment"];
remove?: () => void;
}) {
if (isImageAttachment(image)) return <ImagePreview image={image} {...props} />;
const dot = image.name.lastIndexOf(".");
const type = (
dot > 0 ? image.name.slice(dot + 1) : (image.mimeType.split("/").at(-1) ?? image.mimeType)
).toUpperCase();
return (
<div className="file-attachment" title={image.path}>
<span className="file-attachment-icon">
<FileIcon path={image.name} />
</span>
<span className="file-attachment-info">
<strong title={image.name}>{image.name}</strong>
<small>{type}</small>
</span>
{props.remove && (
<button
type="button"
aria-label={`移除 ${image.name}`}
className="chip-remove"
onClick={props.remove}
>
<X size={10} />
</button>
)}
</div>
);
}
export function ImagePreview({
sessionId,
image,
read,
remove,
}: {
sessionId: string;
image: ImageAttachment;
read: ComposerContext["readAttachment"];
remove?: () => void;
}) {
const root = useRef<HTMLDivElement>(null);
const [src, setSrc] = useState("");
const [expanded, setExpanded] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
let requested = false;
const load = () => {
if (requested) return;
requested = true;
void read(sessionId, image.id)
.then((value) => {
if (active) setSrc(value);
})
.catch((e) => {
if (active) setError(userErrorMessage(e));
});
};
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
load();
observer.disconnect();
}
},
{ rootMargin: "100px" },
);
if (root.current) observer.observe(root.current);
return () => {
active = false;
observer.disconnect();
};
}, [sessionId, image.id, read]);
return (
<div
ref={root}
className="image-chip"
title={`${image.name} · ${image.width}×${image.height}${image.warning ? ` · ${image.warning}` : ""}`}
>
<button type="button" aria-label={`预览 ${image.name}`} onClick={() => setExpanded(true)}>
{src ? <img src={src} alt={image.name} /> : <span>{error || "加载图片…"}</span>}
</button>
{remove && (
<button type="button" aria-label={`移除 ${image.name}`} className="chip-remove" onClick={remove}>
<X size={12} />
</button>
)}
{expanded && (
<dialog
ref={(element) => {
if (element && !element.open) element.showModal();
}}
className="image-lightbox"
aria-label="图片预览"
onClick={(event) => {
if (event.target === event.currentTarget) setExpanded(false);
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
setExpanded(false);
}
}}
onCancel={(event) => {
event.preventDefault();
setExpanded(false);
}}
>
<button
type="button"
ref={(element) => element?.focus()}
aria-label="关闭图片预览"
onClick={() => setExpanded(false)}
>
<X size={20} />
</button>
{src && <img src={src} alt={image.name} />}
<span>
{image.name}
{error}
</span>
</dialog>
)}
</div>
);
}