feat(agent): support chat aborts and attachments

This commit is contained in:
Ma
2026-07-03 18:19:02 +08:00
parent a12736001d
commit d5aa2a4509
9 changed files with 524 additions and 19 deletions
@@ -185,7 +185,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
};
});
import { runAgentSession, evictAgentCache } from "../agent/agent-session.js";
import { abortAgentSession, runAgentSession, evictAgentCache } from "../agent/agent-session.js";
import {
appendManualSessionMessages,
appendTranscriptEvent,
@@ -233,6 +233,7 @@ describe("runAgentSession cache — bookId switch", () => {
evictAgentCache("play-session");
evictAgentCache("play-active-session");
evictAgentCache("play-confirmed-session");
evictAgentCache("abort-session");
await rm(projectRoot, { recursive: true, force: true });
if (otherProjectRoot) await rm(otherProjectRoot, { recursive: true, force: true });
});
@@ -1215,6 +1216,30 @@ describe("runAgentSession cache — bookId switch", () => {
expect(JSON.stringify(streamCalls.at(-1)?.context.messages)).not.toContain("model error");
});
it("aborts and evicts an active cached agent session", async () => {
const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
const pipeline = {} as any;
await runAgentSession(
{ sessionId: "abort-session", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
"hello",
);
const abortSpy = vi.spyOn(agentInstances.at(-1), "abort");
const clearSpy = vi.spyOn(agentInstances.at(-1), "clearAllQueues");
expect(abortAgentSession(projectRoot, "abort-session")).toBe(true);
expect(abortSpy).toHaveBeenCalledOnce();
expect(clearSpy).toHaveBeenCalledOnce();
const instancesAfterAbort = agentInstances.length;
await runAgentSession(
{ sessionId: "abort-session", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
"again",
);
expect(agentInstances).toHaveLength(instancesAfterAbort + 1);
});
it("serializes concurrent turns before assigning transcript seq", async () => {
const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
const pipeline = {} as any;
+78 -2
View File
@@ -8,6 +8,7 @@ import type {
AssistantMessage,
AssistantMessageEventStream,
Context as PiContext,
ImageContent,
Message,
SimpleStreamOptions,
ToolResultMessage,
@@ -97,6 +98,8 @@ export interface AgentSessionConfig {
onEvent?: (event: AgentEvent) => void;
/** Optional listener for context compression lifecycle events. */
onContextCompression?: ContextCompressionCallback;
/** Attachments uploaded with this user turn. Text is injected as protected user context; images use pi-ai ImageContent. */
attachments?: ReadonlyArray<AgentSessionAttachment>;
}
export interface AgentSessionResult {
@@ -108,6 +111,19 @@ export interface AgentSessionResult {
errorMessage?: string;
}
export interface AgentSessionAttachment {
readonly id: string;
readonly filename: string;
readonly mimeType: string;
readonly size: number;
readonly storedPath?: string;
readonly text?: string;
readonly image?: {
readonly data: string;
readonly mimeType: string;
};
}
// ---------------------------------------------------------------------------
// Cache
// ---------------------------------------------------------------------------
@@ -243,6 +259,46 @@ function agentCacheKey(projectRoot: string, sessionId: string): string {
return sessionQueueKey(projectRoot, sessionId);
}
function buildAttachmentUserBlock(attachments: ReadonlyArray<AgentSessionAttachment> | undefined, language: string): string {
if (!attachments?.length) return "";
const isEn = language === "en";
const lines = [
isEn
? "\n\n## Uploaded Files (host-provided, user-authorized)"
: "\n\n## 用户上传文件(宿主已接收,用户授权本轮使用)",
];
for (const attachment of attachments) {
lines.push(`\n### ${attachment.filename}`);
lines.push(`- id: ${attachment.id}`);
lines.push(`- mime: ${attachment.mimeType || "application/octet-stream"}`);
lines.push(`- size: ${attachment.size}`);
if (attachment.storedPath) lines.push(`- stored_path: ${attachment.storedPath}`);
if (attachment.text) {
lines.push(isEn ? "\nContent:" : "\n内容:");
lines.push("```");
lines.push(attachment.text);
lines.push("```");
} else if (attachment.image) {
lines.push(isEn ? "- image: attached as multimodal input" : "- 图片:已作为多模态输入附加");
} else {
lines.push(isEn
? "- content: stored only; no extractor is available for this MIME type yet"
: "- 内容:已保存;当前 MIME 类型暂未配置文本抽取器");
}
}
return lines.join("\n");
}
function attachmentImages(attachments: ReadonlyArray<AgentSessionAttachment> | undefined): ImageContent[] {
return (attachments ?? [])
.filter((attachment) => attachment.image)
.map((attachment) => ({
type: "image",
data: attachment.image!.data,
mimeType: attachment.image!.mimeType,
}));
}
function guardedStreamSimple<TApi extends Api>(
model: Model<TApi>,
context: PiContext,
@@ -978,6 +1034,9 @@ async function runAgentSessionUnlocked(
cached.lastActive = Date.now();
const { agent } = cached;
const attachmentBlock = buildAttachmentUserBlock(config.attachments, language);
const promptMessage = attachmentBlock ? `${userMessage}${attachmentBlock}` : userMessage;
const promptImages = attachmentImages(config.attachments);
// ----- Prepare transcript persistence -----
const requestId = randomUUID();
@@ -990,7 +1049,7 @@ async function runAgentSessionUnlocked(
seq,
timestamp: Date.now(),
sessionKind,
input: userMessage,
input: promptMessage,
}));
let parentUuid: string | null = null;
@@ -1059,7 +1118,11 @@ async function runAgentSessionUnlocked(
let errorMessage: string | undefined;
try {
await agent.prompt(userMessage);
if (promptImages.length > 0) {
await agent.prompt(promptMessage, promptImages);
} else {
await agent.prompt(promptMessage);
}
finalAssistant = lastAssistantMessage(agent.state.messages);
errorMessage = assistantErrorMessage(finalAssistant);
@@ -1129,3 +1192,16 @@ export function evictAgentCache(sessionId: string): boolean {
}
return deleted;
}
/** Abort an active cached pi-agent session and evict it from cache. */
export function abortAgentSession(projectRoot: string, sessionId: string): boolean {
let aborted = false;
for (const [key, entry] of agentCache) {
if (entry.projectRoot !== projectRoot || entry.sessionId !== sessionId) continue;
entry.agent.abort();
entry.agent.clearAllQueues?.();
agentCache.delete(key);
aborted = true;
}
return aborted;
}
+8 -1
View File
@@ -19,7 +19,14 @@ export {
createGrepTool,
createLsTool,
} from "./agent-tools.js";
export { runAgentSession, evictAgentCache, type AgentSessionConfig, type AgentSessionResult } from "./agent-session.js";
export {
abortAgentSession,
runAgentSession,
evictAgentCache,
type AgentSessionAttachment,
type AgentSessionConfig,
type AgentSessionResult,
} from "./agent-session.js";
export { createBookContextTransform } from "./context-transform.js";
export {
createSetWorldAnchorTool,
+69
View File
@@ -30,6 +30,7 @@ const createInteractionToolsFromDepsMock = vi.fn(() => ({}));
const loadProjectSessionMock = vi.fn();
const resolveSessionActiveBookMock = vi.fn();
const runAgentSessionMock = vi.fn();
const abortAgentSessionMock = vi.fn();
const playRunnerStepMock = vi.fn();
const playRunnerCtorArgs: unknown[] = [];
const generatePlayImageMock = vi.fn();
@@ -249,6 +250,7 @@ vi.mock("@actalk/inkos-core", async (importOriginal) => {
loadProjectSession: loadProjectSessionMock,
resolveSessionActiveBook: resolveSessionActiveBookMock,
runAgentSession: runAgentSessionMock,
abortAgentSession: abortAgentSessionMock,
createSubAgentTool: actual.createSubAgentTool,
createShortFictionRunTool: actual.createShortFictionRunTool,
createGenerateCoverTool: actual.createGenerateCoverTool,
@@ -511,6 +513,7 @@ describe("createStudioServer daemon lifecycle", () => {
rollbackToChapterMock.mockResolvedValue([]);
pipelineConfigs.length = 0;
runAgentSessionMock.mockReset();
abortAgentSessionMock.mockReset();
playRunnerStepMock.mockReset();
playRunnerCtorArgs.length = 0;
playRunnerStepMock.mockResolvedValue({
@@ -2600,6 +2603,20 @@ describe("createStudioServer daemon lifecycle", () => {
await expect(response.json()).resolves.toEqual({ ok: true });
});
it("aborts a cached agent session through POST /api/v1/sessions/:sessionId/abort", async () => {
abortAgentSessionMock.mockReturnValueOnce(true);
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/v1/sessions/agent-session-1/abort", {
method: "POST",
});
expect(response.status).toBe(200);
expect(abortAgentSessionMock).toHaveBeenCalledWith(root, "agent-session-1");
await expect(response.json()).resolves.toEqual({ ok: true, aborted: true });
});
it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
runAgentSessionMock.mockImplementationOnce(async (config: { onEvent?: (event: unknown) => void }) => {
config.onEvent?.({
@@ -2652,6 +2669,58 @@ describe("createStudioServer daemon lifecycle", () => {
);
});
it("stores uploaded attachments and forwards them to the agent session", async () => {
const note = Buffer.from("# 参考资料\n主角必须保留第一人称。", "utf-8").toString("base64");
const image = Buffer.from("fakepng", "utf-8").toString("base64");
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/v1/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
instruction: "按附件继续讨论",
activeBookId: "demo-book",
sessionId: "agent-session-1",
attachments: [
{
id: "note-1",
filename: "brief.md",
mediaType: "text/markdown",
size: Buffer.byteLength(note, "base64"),
dataUrl: `data:text/markdown;base64,${note}`,
},
{
id: "img-1",
filename: "reference.png",
mediaType: "image/png",
size: Buffer.byteLength(image, "base64"),
dataUrl: `data:image/png;base64,${image}`,
},
],
}),
});
expect(response.status).toBe(200);
const agentConfig = runAgentSessionMock.mock.calls.at(-1)?.[0] as { attachments?: Array<Record<string, unknown>> };
expect(agentConfig.attachments).toHaveLength(2);
expect(agentConfig.attachments?.[0]).toMatchObject({
id: "note-1",
filename: "brief.md",
mimeType: "text/markdown",
text: "# 参考资料\n主角必须保留第一人称。",
});
expect(agentConfig.attachments?.[1]).toMatchObject({
id: "img-1",
filename: "reference.png",
mimeType: "image/png",
image: { data: image, mimeType: "image/png" },
});
const storedPath = agentConfig.attachments?.[0]?.storedPath;
expect(typeof storedPath).toBe("string");
await expect(access(join(root, storedPath as string))).resolves.toBeUndefined();
});
it("executes confirmed create-book action directly without asking the chat model to call tools", async () => {
loadBookSessionMock.mockResolvedValueOnce({
sessionId: "agent-session-1",
+134 -1
View File
@@ -22,6 +22,7 @@ import {
deleteBookSession,
migrateBookSession,
SessionAlreadyMigratedError,
abortAgentSession,
runAgentSession,
resolveServicePreset,
resolveServiceProviderFamily,
@@ -101,6 +102,7 @@ import {
type LogEntry,
type RequestedIntent,
type SessionKind,
type AgentSessionAttachment,
} from "@actalk/inkos-core";
import { access, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -483,6 +485,126 @@ function normalizeStudioSkillId(value: unknown, field = "skillId"): string {
return id;
}
type StudioAgentAttachmentPayload = {
readonly id?: string;
readonly filename?: string;
readonly mediaType?: string;
readonly size?: number;
readonly dataUrl?: string;
};
const MAX_AGENT_ATTACHMENTS = 8;
const MAX_AGENT_ATTACHMENT_BYTES = 4 * 1024 * 1024;
const MAX_AGENT_ATTACHMENT_TEXT_CHARS = 120_000;
function safeUploadFileName(value: string): string {
const trimmed = value.trim().replace(/[/\\\0]/g, "_").replace(/\s+/g, " ");
const safe = trimmed.replace(/[^\p{L}\p{N}._ -]+/gu, "_").slice(0, 120).trim();
return safe || "upload";
}
function isTextAttachment(filename: string, mimeType: string): boolean {
const lower = filename.toLowerCase();
return mimeType.startsWith("text/")
|| [
".txt",
".md",
".markdown",
".json",
".csv",
".tsv",
".yaml",
".yml",
".log",
].some((suffix) => lower.endsWith(suffix));
}
function parseDataUrl(dataUrl: string): { mimeType: string; buffer: Buffer } {
const match = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s.exec(dataUrl);
if (!match) {
throw new ApiError(400, "INVALID_ATTACHMENT_DATA_URL", "Attachment must be a base64 data URL");
}
const mimeType = match[1]?.trim() || "application/octet-stream";
return { mimeType, buffer: Buffer.from(match[2] ?? "", "base64") };
}
async function normalizeAgentAttachments(
root: string,
sessionId: string,
value: unknown,
): Promise<AgentSessionAttachment[]> {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) {
throw new ApiError(400, "INVALID_ATTACHMENTS", "attachments must be an array");
}
if (value.length > MAX_AGENT_ATTACHMENTS) {
throw new ApiError(413, "TOO_MANY_ATTACHMENTS", `At most ${MAX_AGENT_ATTACHMENTS} files can be attached to one message`);
}
const uploadDir = join(root, ".inkos", "uploads", safeUploadFileName(sessionId));
const out: AgentSessionAttachment[] = [];
for (const [index, raw] of value.entries()) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new ApiError(400, "INVALID_ATTACHMENT", "Each attachment must be an object");
}
const payload = raw as StudioAgentAttachmentPayload;
const filename = safeUploadFileName(payload.filename || `upload-${index + 1}`);
if (!payload.dataUrl) {
throw new ApiError(400, "INVALID_ATTACHMENT", `Attachment ${filename} is missing dataUrl`);
}
const parsed = parseDataUrl(payload.dataUrl);
const mimeType = payload.mediaType?.trim() || parsed.mimeType;
if (parsed.buffer.byteLength > MAX_AGENT_ATTACHMENT_BYTES) {
throw new ApiError(413, "ATTACHMENT_TOO_LARGE", `${filename} exceeds ${MAX_AGENT_ATTACHMENT_BYTES} bytes`);
}
await mkdir(uploadDir, { recursive: true });
const storedName = `${Date.now()}-${index + 1}-${filename}`;
const storedPath = join(uploadDir, storedName);
await writeFile(storedPath, parsed.buffer);
const relPath = relative(root, storedPath);
if (mimeType.startsWith("image/")) {
out.push({
id: payload.id || `${Date.now()}-${index}`,
filename,
mimeType,
size: parsed.buffer.byteLength,
storedPath: relPath,
image: {
data: parsed.buffer.toString("base64"),
mimeType,
},
});
continue;
}
if (isTextAttachment(filename, mimeType)) {
const text = parsed.buffer.toString("utf-8");
if (text.length > MAX_AGENT_ATTACHMENT_TEXT_CHARS) {
throw new ApiError(413, "ATTACHMENT_TEXT_TOO_LARGE", `${filename} is too large to inject without semantic compaction`);
}
out.push({
id: payload.id || `${Date.now()}-${index}`,
filename,
mimeType,
size: parsed.buffer.byteLength,
storedPath: relPath,
text,
});
continue;
}
out.push({
id: payload.id || `${Date.now()}-${index}`,
filename,
mimeType,
size: parsed.buffer.byteLength,
storedPath: relPath,
});
}
return out;
}
function projectSkillsDir(root: string): string {
return join(root, ".inkos", "skills");
}
@@ -3674,6 +3796,13 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
return c.json({ ok: true });
});
app.post("/api/v1/sessions/:sessionId/abort", async (c) => {
const sessionId = c.req.param("sessionId");
const aborted = abortAgentSession(root, sessionId);
broadcast("agent:aborted", { sessionId, aborted });
return c.json({ ok: true, aborted });
});
app.post("/api/v1/agent", async (c) => {
const {
instruction,
@@ -3685,6 +3814,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
actionPayload: reqActionPayload,
requestedSkills: reqRequestedSkills,
disabledSkills: reqDisabledSkills,
attachments: reqAttachments,
playMode: reqPlayMode,
model: reqModel,
service: reqService,
@@ -3698,6 +3828,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
actionPayload?: unknown;
requestedSkills?: unknown;
disabledSkills?: unknown;
attachments?: unknown;
playMode?: string;
model?: string;
service?: string;
@@ -3719,9 +3850,10 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
const actionPayload = normalizeStudioActionPayload(reqActionPayload);
const requestedSkills = normalizeStudioSkillIdList(reqRequestedSkills, "requestedSkills");
const disabledSkills = normalizeStudioSkillIdList(reqDisabledSkills, "disabledSkills");
const attachments = await normalizeAgentAttachments(root, sessionId, reqAttachments);
const playMode = normalizeStudioPlayMode(reqPlayMode);
broadcast("agent:start", { instruction, activeBookId, sessionId, actionSource, requestedIntent, requestedSkills });
broadcast("agent:start", { instruction, activeBookId, sessionId, actionSource, requestedIntent, requestedSkills, attachments: attachments.length });
try {
// Load config + create LLM client (pipeline created after model resolution)
@@ -4168,6 +4300,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
actionPayload,
requestedSkills,
disabledSkills,
attachments,
sessionId: bookSession.sessionId,
language: surfaceLanguage,
onContextCompression: (event) => {
+141 -10
View File
@@ -3,7 +3,7 @@ import type { Theme } from "../hooks/use-theme";
import type { TFunction } from "../hooks/use-i18n";
import type { SSEMessage } from "../hooks/use-sse";
import { fetchJson, postApi, useApi } from "../hooks/use-api";
import type { MessagePart } from "../store/chat/types";
import type { ChatAttachmentPayload, MessagePart } from "../store/chat/types";
import { chatSelectors, useChatStore } from "../store/chat";
import type { ChatSessionKind } from "../store/chat";
import { useServiceStore } from "../store/service";
@@ -26,15 +26,16 @@ import { PlayHud } from "../components/chat/PlayHud";
import { PlayChoicePanel } from "../components/chat/PlayChoicePanel";
import { latestPlayChoiceSet } from "../components/chat/play-choices";
import {
Loader2,
BotMessageSquare,
ArrowUp,
ChevronDown,
Check,
Plus,
X,
Paperclip,
Gamepad2,
Palette,
Square,
} from "lucide-react";
import { Shimmer } from "../components/ai-elements/shimmer";
import {
@@ -105,6 +106,51 @@ interface CoverConfigResponse {
readonly providers?: ReadonlyArray<{ readonly service: string; readonly connected?: boolean }>;
}
const MAX_CHAT_ATTACHMENTS = 8;
const MAX_CHAT_ATTACHMENT_BYTES = 4 * 1024 * 1024;
const CHAT_ATTACHMENT_ACCEPT = [
"image/*",
"text/plain",
"text/markdown",
"application/json",
"text/csv",
".txt",
".md",
".markdown",
".json",
".csv",
".tsv",
".yaml",
".yml",
".log",
".pdf",
].join(",");
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
reader.onload = () => resolve(String(reader.result ?? ""));
reader.readAsDataURL(file);
});
}
async function serializeChatAttachments(files: ReadonlyArray<File>): Promise<ChatAttachmentPayload[]> {
return Promise.all(files.map(async (file) => ({
id: `${file.name}-${file.size}-${file.lastModified}`,
filename: file.name,
mediaType: file.type || "application/octet-stream",
size: file.size,
dataUrl: await fileToDataUrl(file),
})));
}
function formatFileSize(size: number): string {
if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
if (size >= 1024) return `${Math.ceil(size / 1024)} KB`;
return `${size} B`;
}
interface SkillsResponse {
readonly skills: ReadonlyArray<StudioSkill>;
readonly diagnostics?: ReadonlyArray<{ readonly path?: string; readonly message?: string }>;
@@ -373,6 +419,7 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
// -- Store actions --
const setInput = useChatStore((s) => s.setInput);
const sendMessage = useChatStore((s) => s.sendMessage);
const abortSession = useChatStore((s) => s.abortSession);
const setSelectedModel = useChatStore((s) => s.setSelectedModel);
const loadSessionList = useChatStore((s) => s.loadSessionList);
const createSession = useChatStore((s) => s.createSession);
@@ -384,6 +431,7 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
const scrollRef = useRef<HTMLDivElement>(null);
const scrollFrameRef = useRef<ScrollFrameId | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const autoScrollPinnedRef = useRef(true);
const isZh = t("nav.connected") === "\u5DF2\u8FDE\u63A5";
@@ -423,6 +471,8 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
const [skillSaving, setSkillSaving] = useState(false);
const [skillCreateError, setSkillCreateError] = useState<string | null>(null);
const [showSkillCreate, setShowSkillCreate] = useState(false);
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const { data: skillsData, loading: skillsLoading, error: skillsError, refetch: refetchSkills } = useApi<SkillsResponse>("/skills");
const worldPanelInsetClass = currentSessionKind === "play" && worldPanelOpen ? "lg:pr-[380px]" : "";
const availableSkills = skillsData?.skills ?? [];
@@ -638,17 +688,45 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
};
}, [activeBookId, activateSession, createSession, loadSessionDetail, loadSessionList, mode]);
const onSend = (text: string) => {
const addAttachedFiles = (files: FileList | File[]) => {
const incoming = Array.from(files);
const accepted: File[] = [];
const rejected: string[] = [];
for (const file of incoming) {
if (file.size > MAX_CHAT_ATTACHMENT_BYTES) {
rejected.push(`${file.name} > ${formatFileSize(MAX_CHAT_ATTACHMENT_BYTES)}`);
continue;
}
accepted.push(file);
}
setAttachedFiles((prev) => [...prev, ...accepted].slice(0, MAX_CHAT_ATTACHMENTS));
setAttachmentError(rejected.length > 0
? (isZh ? `以下文件过大,未添加:${rejected.join("、")}` : `Some files were too large: ${rejected.join(", ")}`)
: null);
};
const onSend = async (text: string) => {
if (!activeSessionId) return;
if (!text.trim()) return;
const hasPendingMessage = Boolean(text.trim()) || attachedFiles.length > 0;
if (!hasPendingMessage) {
if (loading) await abortSession(activeSessionId);
return;
}
const requestedSkills = selectedSkillIdsForSend(selectedSkillIds);
autoScrollPinnedRef.current = true;
void sendMessage(activeSessionId, text, {
const attachments = await serializeChatAttachments(attachedFiles);
if (loading) {
await abortSession(activeSessionId);
}
await sendMessage(activeSessionId, text, {
activeBookId,
sessionKind: currentSessionKind,
actionSource: "free-text",
requestedSkills,
attachments,
});
setAttachedFiles([]);
setAttachmentError(null);
if (requestedSkills?.length) {
setSelectedSkillIds([]);
setSkillPanelOpen(false);
@@ -998,6 +1076,17 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
}}
/>
) : null}
<input
ref={fileInputRef}
type="file"
multiple
accept={CHAT_ATTACHMENT_ACCEPT}
className="hidden"
onChange={(event) => {
if (event.currentTarget.files) addAttachedFiles(event.currentTarget.files);
event.currentTarget.value = "";
}}
/>
{selectedSkills.length > 0 ? (
<div className="flex flex-wrap gap-1.5 border-b border-border/20 px-3 py-2">
{selectedSkills.map((skill) => (
@@ -1018,6 +1107,35 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
))}
</div>
) : null}
{attachedFiles.length > 0 || attachmentError ? (
<div className="border-b border-border/20 px-3 py-2">
{attachedFiles.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{attachedFiles.map((file) => (
<span
key={`${file.name}-${file.size}-${file.lastModified}`}
className="inline-flex max-w-[220px] items-center gap-1.5 rounded-full border border-border/50 bg-secondary/60 px-2.5 py-1 text-xs text-muted-foreground"
title={`${file.name} · ${file.type || "application/octet-stream"} · ${formatFileSize(file.size)}`}
>
<Paperclip size={12} />
<span className="truncate">{file.name}</span>
<button
type="button"
onClick={() => setAttachedFiles((prev) => prev.filter((item) => item !== file))}
className="rounded-full p-0.5 hover:bg-muted"
aria-label={isZh ? `移除 ${file.name}` : `Remove ${file.name}`}
>
<X size={12} />
</button>
</span>
))}
</div>
) : null}
{attachmentError ? (
<div className="mt-1 text-xs leading-5 text-destructive">{attachmentError}</div>
) : null}
</div>
) : null}
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
@@ -1029,23 +1147,36 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
>
<Plus size={16} strokeWidth={2.4} />
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={!activeSessionId}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/50 text-muted-foreground transition-colors hover:border-primary/40 hover:text-primary disabled:opacity-30"
title={isZh ? "上传图片或资料" : "Attach files"}
aria-label={isZh ? "上传图片或资料" : "Attach files"}
>
<Paperclip size={16} strokeWidth={2.3} />
</button>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSend(input); } }}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void onSend(input); } }}
placeholder={isZh ? "输入指令..." : "Enter command..."}
disabled={loading || !activeSessionId}
disabled={!activeSessionId}
rows={1}
className="flex-1 bg-transparent text-base leading-7 placeholder:text-muted-foreground/50 outline-none! border-none! ring-0! shadow-none focus:outline-none! focus:ring-0! focus:border-none! resize-none disabled:opacity-50 max-h-[200px] overflow-y-auto"
/>
<button
type="button"
onClick={() => onSend(input)}
disabled={!input.trim() || loading || !activeSessionId}
onClick={() => void onSend(input)}
disabled={(!input.trim() && attachedFiles.length === 0 && !loading) || !activeSessionId}
className="w-8 h-8 rounded-lg bg-primary text-primary-foreground flex items-center justify-center shrink-0 hover:scale-105 active:scale-95 transition-all disabled:opacity-20 disabled:scale-100 shadow-sm shadow-primary/20"
title={loading && !input.trim() && attachedFiles.length === 0 ? (isZh ? "停止当前回复" : "Stop") : undefined}
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <ArrowUp size={14} strokeWidth={2.5} />}
{loading && !input.trim() && attachedFiles.length === 0
? <Square size={13} fill="currentColor" />
: <ArrowUp size={14} strokeWidth={2.5} />}
</button>
</div>
<div className="flex items-center gap-2 px-3 pb-2 border-t border-border/20 pt-1.5">
@@ -1,6 +1,7 @@
import type { StateCreator } from "zustand";
import type {
AgentResponse,
ChatAttachmentPayload,
ChatSessionKind,
ChatStore,
MessageActions,
@@ -53,6 +54,21 @@ function mergeSkillIds(
return out;
}
function formatAttachmentSize(size: number): string {
if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
if (size >= 1024) return `${Math.ceil(size / 1024)} KB`;
return `${size} B`;
}
function formatUserMessageForDisplay(text: string, attachments: ReadonlyArray<ChatAttachmentPayload>): string {
if (attachments.length === 0) return text;
const lines = text ? [text, "", "附件:"] : ["附件:"];
for (const attachment of attachments) {
lines.push(`- ${attachment.filename} (${attachment.mediaType || "application/octet-stream"}, ${formatAttachmentSize(attachment.size)})`);
}
return lines.join("\n");
}
export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions> = (set, get) => ({
activateSession: (sessionId) =>
set({ activeSessionId: sessionId }),
@@ -292,6 +308,23 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
});
},
abortSession: async (sessionId) => {
const session = get().sessions[sessionId];
session?.stream?.close();
set((state) => ({
sessions: updateSession(state.sessions, sessionId, () => ({
isStreaming: false,
stream: null,
lastError: null,
})),
}));
try {
await fetchJson(`/sessions/${sessionId}/abort`, { method: "POST" });
} catch (error) {
get().addErrorMessage(sessionId, error instanceof Error ? error.message : String(error));
}
},
loadSessionDetail: async (sessionId) => {
// 草稿会话:磁盘上还没有文件,直接跳过远端拉取。
// 本地已有消息:不拉取远端,避免流式中或未持久化的消息被覆盖。
@@ -350,8 +383,10 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
sendMessage: async (sessionId, text, options?: SendMessageOptions) => {
const trimmed = text.trim();
const attachments = options?.attachments ?? [];
const session = get().sessions[sessionId];
if (!trimmed || !session || session.isStreaming) return;
if ((!trimmed && attachments.length === 0) || !session || session.isStreaming) return;
const userInstruction = trimmed || "请阅读我上传的文件。";
const activeBookId = options?.activeBookId ?? session.bookId ?? undefined;
const sessionKind: ChatSessionKind = options?.sessionKind
?? session.sessionKind
@@ -360,7 +395,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
const playMode = options?.playMode ?? session.playMode;
if (!get().selectedModel) {
get().addUserMessage(sessionId, trimmed);
get().addUserMessage(sessionId, formatUserMessageForDisplay(userInstruction, attachments));
get().addErrorMessage(sessionId, "请先选择一个模型");
return;
}
@@ -393,7 +428,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
}
}
const skillDirectives = parseSkillDirectives(trimmed);
const skillDirectives = parseSkillDirectives(userInstruction);
const instruction = skillDirectives.instruction;
const requestedSkills = mergeSkillIds(skillDirectives.requestedSkills, options?.requestedSkills);
const disabledSkills = mergeSkillIds([], options?.disabledSkills);
@@ -408,7 +443,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
})),
}));
get().addUserMessage(sessionId, trimmed);
get().addUserMessage(sessionId, formatUserMessageForDisplay(userInstruction, attachments));
session.stream?.close();
const streamEs = new EventSource("/api/v1/events");
set((state) => ({
@@ -430,6 +465,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
actionPayload: options?.actionPayload,
requestedSkills,
disabledSkills,
attachments,
sessionId,
model: get().selectedModel ?? undefined,
service: get().selectedService ?? undefined,
@@ -242,6 +242,24 @@ export function attachSessionStreamListeners({
streamEs.addEventListener("draft:error", flushTextDeltas);
streamEs.addEventListener("agent:complete", flushTextDeltas);
streamEs.addEventListener("agent:aborted", (event: MessageEvent) => {
try {
const data = event.data ? JSON.parse(event.data) : null;
if (!sessionMatchesEvent(sessionId, data)) return;
flushTextDeltas();
progressThrottle.flush();
streamEs.close();
set((state) => ({
sessions: updateSession(state.sessions, sessionId, () => ({
isStreaming: false,
stream: null,
})),
}));
} catch {
// ignore
}
});
streamEs.addEventListener("thinking:start", (event: MessageEvent) => {
try {
const data = event.data ? JSON.parse(event.data) : null;
+10
View File
@@ -126,9 +126,18 @@ export interface SendMessageOptions {
readonly actionPayload?: ChatActionPayload;
readonly requestedSkills?: ReadonlyArray<string>;
readonly disabledSkills?: ReadonlyArray<string>;
readonly attachments?: ReadonlyArray<ChatAttachmentPayload>;
readonly playMode?: PlayMode;
}
export interface ChatAttachmentPayload {
readonly id: string;
readonly filename: string;
readonly mediaType: string;
readonly size: number;
readonly dataUrl: string;
}
export interface SessionRuntime {
readonly sessionId: string;
readonly bookId: string | null;
@@ -186,6 +195,7 @@ export interface MessageActions {
deleteSession: (sessionId: string) => Promise<void>;
loadSessionDetail: (sessionId: string) => Promise<void>;
sendMessage: (sessionId: string, text: string, options?: SendMessageOptions) => Promise<void>;
abortSession: (sessionId: string) => Promise<void>;
setSelectedModel: (model: string, service: string) => void;
}