mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-28 23:02:03 +08:00
feat(agent): support chat aborts and attachments
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user