From d5aa2a4509aaa0f7647633ea9af4fa015ec130bc Mon Sep 17 00:00:00 2001 From: Ma Date: Fri, 3 Jul 2026 18:19:02 +0800 Subject: [PATCH 1/3] feat(agent): support chat aborts and attachments --- .../core/src/__tests__/agent-session.test.ts | 27 +++- packages/core/src/agent/agent-session.ts | 80 +++++++++- packages/core/src/agent/index.ts | 9 +- packages/studio/src/api/server.test.ts | 69 ++++++++ packages/studio/src/api/server.ts | 135 +++++++++++++++- packages/studio/src/pages/ChatPage.tsx | 151 ++++++++++++++++-- .../src/store/chat/slices/message/action.ts | 44 ++++- .../chat/slices/message/stream-events.ts | 18 +++ packages/studio/src/store/chat/types.ts | 10 ++ 9 files changed, 524 insertions(+), 19 deletions(-) diff --git a/packages/core/src/__tests__/agent-session.test.ts b/packages/core/src/__tests__/agent-session.test.ts index 3b4e5dfe..3244b94b 100644 --- a/packages/core/src/__tests__/agent-session.test.ts +++ b/packages/core/src/__tests__/agent-session.test.ts @@ -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; diff --git a/packages/core/src/agent/agent-session.ts b/packages/core/src/agent/agent-session.ts index ad5104fa..82b94206 100644 --- a/packages/core/src/agent/agent-session.ts +++ b/packages/core/src/agent/agent-session.ts @@ -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; } 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 | 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 | undefined): ImageContent[] { + return (attachments ?? []) + .filter((attachment) => attachment.image) + .map((attachment) => ({ + type: "image", + data: attachment.image!.data, + mimeType: attachment.image!.mimeType, + })); +} + function guardedStreamSimple( model: Model, 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; +} diff --git a/packages/core/src/agent/index.ts b/packages/core/src/agent/index.ts index 8abd78d7..8b80b023 100644 --- a/packages/core/src/agent/index.ts +++ b/packages/core/src/agent/index.ts @@ -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, diff --git a/packages/studio/src/api/server.test.ts b/packages/studio/src/api/server.test.ts index f62dd0c9..91c54b9a 100644 --- a/packages/studio/src/api/server.test.ts +++ b/packages/studio/src/api/server.test.ts @@ -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> }; + 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", diff --git a/packages/studio/src/api/server.ts b/packages/studio/src/api/server.ts index c948b1f9..49fdcbde 100644 --- a/packages/studio/src/api/server.ts +++ b/packages/studio/src/api/server.ts @@ -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 { + 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) => { diff --git a/packages/studio/src/pages/ChatPage.tsx b/packages/studio/src/pages/ChatPage.tsx index e2d3d08e..75947670 100644 --- a/packages/studio/src/pages/ChatPage.tsx +++ b/packages/studio/src/pages/ChatPage.tsx @@ -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 { + 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): Promise { + 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; 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(null); const scrollFrameRef = useRef(null); const textareaRef = useRef(null); + const fileInputRef = useRef(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(null); const [showSkillCreate, setShowSkillCreate] = useState(false); + const [attachedFiles, setAttachedFiles] = useState([]); + const [attachmentError, setAttachmentError] = useState(null); const { data: skillsData, loading: skillsLoading, error: skillsError, refetch: refetchSkills } = useApi("/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} + { + if (event.currentTarget.files) addAttachedFiles(event.currentTarget.files); + event.currentTarget.value = ""; + }} + /> {selectedSkills.length > 0 ? (
{selectedSkills.map((skill) => ( @@ -1018,6 +1107,35 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr ))}
) : null} + {attachedFiles.length > 0 || attachmentError ? ( +
+ {attachedFiles.length > 0 ? ( +
+ {attachedFiles.map((file) => ( + + + {file.name} + + + ))} +
+ ) : null} + {attachmentError ? ( +
{attachmentError}
+ ) : null} +
+ ) : null}
+