feat: 工作区附件与文件读取搜索
This commit is contained in:
parent
4c3b2e48e7
commit
944a09ce2b
@ -41,10 +41,10 @@
|
||||
状态: "已完成"
|
||||
- 编号: "F-006"
|
||||
名称: "附件、文件列表、读取与搜索"
|
||||
状态: "进行中"
|
||||
状态: "已完成"
|
||||
- 编号: "F-007"
|
||||
名称: "文件创建与安全修改"
|
||||
状态: "待开始"
|
||||
状态: "进行中"
|
||||
- 编号: "F-008"
|
||||
名称: "会话管理与个人设置"
|
||||
状态: "待开始"
|
||||
|
||||
@ -9,11 +9,13 @@ import type {
|
||||
ConversationService,
|
||||
ProjectService,
|
||||
InteractionService,
|
||||
WorkspaceService,
|
||||
} from "@great-agent/agent-core";
|
||||
import type { RunRegistry } from "./run-registry";
|
||||
import { createRunRoutes } from "../routes/runs";
|
||||
import { createProjectRoutes } from "../routes/projects";
|
||||
import { createInteractionRoutes } from "../routes/interactions";
|
||||
import { createWorkspaceRoutes } from "../routes/workspace";
|
||||
|
||||
export function createApp(
|
||||
logger: Logger,
|
||||
@ -22,17 +24,20 @@ export function createApp(
|
||||
runRegistry?: RunRegistry,
|
||||
projects?: ProjectService,
|
||||
interactions?: InteractionService,
|
||||
workspace?: WorkspaceService,
|
||||
): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", requestId);
|
||||
app.onError(createErrorHandler(logger));
|
||||
app.route("/api", createHealthRoutes());
|
||||
if (conversations) app.route("/api", createConversationRoutes(conversations));
|
||||
if (conversations)
|
||||
app.route("/api", createConversationRoutes(conversations, workspace));
|
||||
if (runs && runRegistry)
|
||||
app.route("/api", createRunRoutes(runs, runRegistry));
|
||||
if (projects && conversations)
|
||||
app.route("/api", createProjectRoutes(projects, conversations));
|
||||
if (interactions && runs && runRegistry)
|
||||
app.route("/api", createInteractionRoutes(interactions, runs, runRegistry));
|
||||
if (workspace) app.route("/api", createWorkspaceRoutes(workspace));
|
||||
return app;
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
InteractionService,
|
||||
CoreError,
|
||||
type ModelPort,
|
||||
WorkspaceService,
|
||||
} from "@great-agent/agent-core";
|
||||
import { DeepSeekModelAdapter } from "@great-agent/model-deepseek";
|
||||
import { createApp } from "./composition/create-app";
|
||||
@ -21,7 +22,10 @@ import { createLogger } from "./composition/create-logger";
|
||||
import { mountStaticWeb } from "./composition/static-web";
|
||||
import { loadEnvironment } from "./config/environment";
|
||||
import { RunRegistry } from "./composition/run-registry";
|
||||
import { LocalWorkspaceResolver } from "@great-agent/local-files";
|
||||
import {
|
||||
LocalWorkspaceFiles,
|
||||
LocalWorkspaceResolver,
|
||||
} from "@great-agent/local-files";
|
||||
|
||||
const environment = loadEnvironment();
|
||||
const logger = createLogger(environment.logLevel);
|
||||
@ -51,6 +55,13 @@ const interactions = new InteractionService({
|
||||
clock,
|
||||
ids,
|
||||
});
|
||||
const workspace = new WorkspaceService(
|
||||
new LocalWorkspaceFiles(),
|
||||
conversations,
|
||||
projects,
|
||||
environment.defaultWorkspaceRoot,
|
||||
clock,
|
||||
);
|
||||
const model: ModelPort = environment.deepSeekApiKey
|
||||
? new DeepSeekModelAdapter({
|
||||
apiKey: environment.deepSeekApiKey,
|
||||
@ -75,6 +86,7 @@ const runs = new AgentRunService(
|
||||
ids,
|
||||
projects,
|
||||
interactions,
|
||||
workspace,
|
||||
);
|
||||
const app = createApp(
|
||||
logger,
|
||||
@ -83,6 +95,7 @@ const app = createApp(
|
||||
new RunRegistry(),
|
||||
projects,
|
||||
interactions,
|
||||
workspace,
|
||||
);
|
||||
mountStaticWeb(app, resolve(import.meta.dir, "../../web/dist"));
|
||||
|
||||
|
||||
@ -1,13 +1,24 @@
|
||||
import { Hono } from "hono";
|
||||
import type { ConversationService } from "@great-agent/agent-core";
|
||||
import type {
|
||||
ConversationService,
|
||||
WorkspaceService,
|
||||
} from "@great-agent/agent-core";
|
||||
|
||||
export function createConversationRoutes(service: ConversationService): Hono {
|
||||
export function createConversationRoutes(
|
||||
service: ConversationService,
|
||||
workspace?: WorkspaceService,
|
||||
): Hono {
|
||||
const routes = new Hono();
|
||||
routes.get("/conversations", async (context) =>
|
||||
context.json(await service.listRecent()),
|
||||
);
|
||||
routes.get("/conversations/:id", async (context) =>
|
||||
context.json(await service.getConversation(context.req.param("id"))),
|
||||
);
|
||||
routes.get("/conversations/:id", async (context) => {
|
||||
const conversation = await service.getConversation(context.req.param("id"));
|
||||
return context.json(
|
||||
workspace
|
||||
? await workspace.refreshConversation(conversation)
|
||||
: conversation,
|
||||
);
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
|
||||
48
apps/web-server/src/routes/workspace.ts
Normal file
48
apps/web-server/src/routes/workspace.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
CoreError,
|
||||
type WorkspaceContext,
|
||||
type WorkspaceService,
|
||||
} from "@great-agent/agent-core";
|
||||
|
||||
export function createWorkspaceRoutes(service: WorkspaceService): Hono {
|
||||
const routes = new Hono();
|
||||
routes.get("/workspace/tree", async (context) =>
|
||||
context.json(
|
||||
await service.list(
|
||||
readContext(context.req.query()),
|
||||
context.req.query("path") ?? "",
|
||||
),
|
||||
),
|
||||
);
|
||||
routes.get("/workspace/search", async (context) =>
|
||||
context.json(
|
||||
await service.search(
|
||||
readContext(context.req.query()),
|
||||
context.req.query("query") ?? "",
|
||||
),
|
||||
),
|
||||
);
|
||||
routes.get("/workspace/file", async (context) =>
|
||||
context.json({
|
||||
content: await service.read(
|
||||
readContext(context.req.query()),
|
||||
context.req.query("path") ?? "",
|
||||
),
|
||||
}),
|
||||
);
|
||||
return routes;
|
||||
}
|
||||
|
||||
function readContext(query: Record<string, string>): WorkspaceContext {
|
||||
const values = [query.conversationId, query.projectId, query.ordinary].filter(
|
||||
Boolean,
|
||||
);
|
||||
if (values.length !== 1)
|
||||
throw new CoreError("WORKSPACE_CONTEXT_INVALID", "工作区上下文无效");
|
||||
if (query.conversationId)
|
||||
return { kind: "conversation", conversationId: query.conversationId };
|
||||
if (query.projectId) return { kind: "project", projectId: query.projectId };
|
||||
if (query.ordinary === "true") return { kind: "ordinary" };
|
||||
throw new CoreError("WORKSPACE_CONTEXT_INVALID", "工作区上下文无效");
|
||||
}
|
||||
@ -9,9 +9,19 @@ import {
|
||||
import { request } from "./request";
|
||||
|
||||
export type StartRunInput =
|
||||
| { kind: "ordinary"; message: string }
|
||||
| { kind: "project"; projectId: string; message: string }
|
||||
| { kind: "existing"; conversationId: string; message: string };
|
||||
| { kind: "ordinary"; message: string; attachments: readonly string[] }
|
||||
| {
|
||||
kind: "project";
|
||||
projectId: string;
|
||||
message: string;
|
||||
attachments: readonly string[];
|
||||
}
|
||||
| {
|
||||
kind: "existing";
|
||||
conversationId: string;
|
||||
message: string;
|
||||
attachments: readonly string[];
|
||||
};
|
||||
|
||||
export async function startRun(
|
||||
input: StartRunInput,
|
||||
|
||||
38
apps/web/src/api/workspace.ts
Normal file
38
apps/web/src/api/workspace.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import {
|
||||
workspaceEntrySchema,
|
||||
workspaceSearchMatchSchema,
|
||||
type WorkspaceEntryResponse,
|
||||
type WorkspaceSearchMatchResponse,
|
||||
} from "@great-agent/web-contracts";
|
||||
import type { Selection } from "../app/app-controller-types";
|
||||
import { request } from "./request";
|
||||
|
||||
export async function listWorkspace(
|
||||
selection: Selection,
|
||||
path = "",
|
||||
): Promise<readonly WorkspaceEntryResponse[]> {
|
||||
const value = await request(
|
||||
`/api/workspace/tree?${query(selection, { path })}`,
|
||||
);
|
||||
return workspaceEntrySchema.array().parse(value);
|
||||
}
|
||||
|
||||
export async function searchWorkspace(
|
||||
selection: Selection,
|
||||
search: string,
|
||||
): Promise<readonly WorkspaceSearchMatchResponse[]> {
|
||||
const value = await request(
|
||||
`/api/workspace/search?${query(selection, { query: search })}`,
|
||||
);
|
||||
return workspaceSearchMatchSchema.array().parse(value);
|
||||
}
|
||||
|
||||
function query(selection: Selection, extra: Record<string, string>): string {
|
||||
const params = new URLSearchParams(extra);
|
||||
if (selection.kind === "conversation")
|
||||
params.set("conversationId", selection.id);
|
||||
else if (selection.kind === "project-draft")
|
||||
params.set("projectId", selection.projectId);
|
||||
else params.set("ordinary", "true");
|
||||
return params.toString();
|
||||
}
|
||||
@ -30,6 +30,7 @@ export type AppController = Readonly<{
|
||||
sending: State<boolean>;
|
||||
assistantDraft: State<string>;
|
||||
error: State<string>;
|
||||
toolActivities: State<ToolActivity[]>;
|
||||
initialize(): Promise<void>;
|
||||
startNewTask(): void;
|
||||
startProjectCreation(): void;
|
||||
@ -41,7 +42,7 @@ export type AppController = Readonly<{
|
||||
startProjectDelete(id: string): void;
|
||||
deleteProject(id: string): Promise<void>;
|
||||
openConversation(id: string): Promise<void>;
|
||||
send(content: string): Promise<boolean>;
|
||||
send(content: string, attachments?: readonly string[]): Promise<boolean>;
|
||||
answerInteraction(
|
||||
id: string,
|
||||
answer: InteractionAnswerRequest,
|
||||
@ -50,3 +51,10 @@ export type AppController = Readonly<{
|
||||
stopGenerating(): Promise<void>;
|
||||
retryRun(runId: string): Promise<void>;
|
||||
}>;
|
||||
|
||||
export type ToolActivity = Readonly<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
detail: string;
|
||||
}>;
|
||||
|
||||
@ -29,7 +29,12 @@ import {
|
||||
answerInteraction as submitInteractionAnswer,
|
||||
listInteractions,
|
||||
} from "../api/interactions";
|
||||
import type { AppController, Selection } from "./app-controller-types";
|
||||
import type {
|
||||
AppController,
|
||||
Selection,
|
||||
ToolActivity,
|
||||
} from "./app-controller-types";
|
||||
import { collectRunEvent } from "./controller-run-events";
|
||||
export type { AppController, Selection } from "./app-controller-types";
|
||||
|
||||
export function createAppController(): AppController {
|
||||
@ -47,10 +52,12 @@ export function createAppController(): AppController {
|
||||
const sending = van.state(false);
|
||||
const assistantDraft = van.state("");
|
||||
const error = van.state("");
|
||||
const toolActivities = van.state<ToolActivity[]>([]);
|
||||
|
||||
async function initialize() {
|
||||
loading.val = true;
|
||||
error.val = "";
|
||||
toolActivities.val = [];
|
||||
try {
|
||||
[recent.val, projects.val] = await Promise.all([
|
||||
listConversations(),
|
||||
@ -140,12 +147,14 @@ export function createAppController(): AppController {
|
||||
});
|
||||
}
|
||||
|
||||
async function send(content: string) {
|
||||
if (!content.trim() || sending.val) return false;
|
||||
async function send(content: string, attachments: readonly string[] = []) {
|
||||
if ((!content.trim() && attachments.length === 0) || sending.val)
|
||||
return false;
|
||||
let accepted = false;
|
||||
sending.val = true;
|
||||
assistantDraft.val = "";
|
||||
error.val = "";
|
||||
toolActivities.val = [];
|
||||
try {
|
||||
const current = selection.val;
|
||||
const input =
|
||||
@ -154,14 +163,16 @@ export function createAppController(): AppController {
|
||||
kind: "existing" as const,
|
||||
conversationId: current.id,
|
||||
message: content,
|
||||
attachments,
|
||||
}
|
||||
: current.kind === "project-draft"
|
||||
? {
|
||||
kind: "project" as const,
|
||||
projectId: current.projectId,
|
||||
message: content,
|
||||
attachments,
|
||||
}
|
||||
: { kind: "ordinary" as const, message: content };
|
||||
: { kind: "ordinary" as const, message: content, attachments };
|
||||
const started = await startRun(input);
|
||||
accepted = true;
|
||||
activeRunId.val = started.runId;
|
||||
@ -279,24 +290,7 @@ export function createAppController(): AppController {
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
}) {
|
||||
if (
|
||||
event.type === "message.delta" &&
|
||||
typeof event.payload.delta === "string"
|
||||
)
|
||||
assistantDraft.val += event.payload.delta;
|
||||
if (currentRun.val && event.type === "run.failed")
|
||||
currentRun.val = {
|
||||
...currentRun.val,
|
||||
status: "failed",
|
||||
errorCode:
|
||||
typeof event.payload.code === "string"
|
||||
? event.payload.code
|
||||
: "MODEL_UNAVAILABLE",
|
||||
};
|
||||
if (currentRun.val && event.type === "run.cancelled")
|
||||
currentRun.val = { ...currentRun.val, status: "cancelled" };
|
||||
if (currentRun.val && event.type === "run.completed")
|
||||
currentRun.val = { ...currentRun.val, status: "completed" };
|
||||
collectRunEvent({ assistantDraft, currentRun, toolActivities }, event);
|
||||
}
|
||||
|
||||
async function refreshActive(conversationId: string) {
|
||||
@ -345,6 +339,7 @@ export function createAppController(): AppController {
|
||||
sending,
|
||||
assistantDraft,
|
||||
error,
|
||||
toolActivities,
|
||||
initialize,
|
||||
startNewTask,
|
||||
startProjectCreation,
|
||||
|
||||
55
apps/web/src/app/controller-run-events.ts
Normal file
55
apps/web/src/app/controller-run-events.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { State } from "vanjs-core";
|
||||
import type { AgentRunResponse } from "@great-agent/web-contracts";
|
||||
import type { ToolActivity } from "./app-controller-types";
|
||||
|
||||
export function collectRunEvent(
|
||||
states: Readonly<{
|
||||
assistantDraft: State<string>;
|
||||
currentRun: State<AgentRunResponse | null>;
|
||||
toolActivities: State<ToolActivity[]>;
|
||||
}>,
|
||||
event: Readonly<{ type: string; payload: Record<string, unknown> }>,
|
||||
): void {
|
||||
if (event.type.startsWith("tool.")) updateTool(states.toolActivities, event);
|
||||
if (event.type === "message.delta" && typeof event.payload.delta === "string")
|
||||
states.assistantDraft.val += event.payload.delta;
|
||||
if (states.currentRun.val && event.type === "run.failed")
|
||||
states.currentRun.val = {
|
||||
...states.currentRun.val,
|
||||
status: "failed",
|
||||
errorCode:
|
||||
typeof event.payload.code === "string"
|
||||
? event.payload.code
|
||||
: "MODEL_UNAVAILABLE",
|
||||
};
|
||||
if (states.currentRun.val && event.type === "run.cancelled")
|
||||
states.currentRun.val = { ...states.currentRun.val, status: "cancelled" };
|
||||
if (states.currentRun.val && event.type === "run.completed")
|
||||
states.currentRun.val = { ...states.currentRun.val, status: "completed" };
|
||||
}
|
||||
|
||||
function updateTool(
|
||||
activities: State<ToolActivity[]>,
|
||||
event: Readonly<{ type: string; payload: Record<string, unknown> }>,
|
||||
): void {
|
||||
const id = read(event.payload.toolCallId, "tool");
|
||||
const name = read(event.payload.name, "文件工具");
|
||||
const status =
|
||||
event.type === "tool.started"
|
||||
? "running"
|
||||
: event.type === "tool.completed"
|
||||
? "completed"
|
||||
: "failed";
|
||||
const detail = read(
|
||||
event.payload.summary ?? event.payload.message,
|
||||
status === "running" ? "正在执行" : "执行完成",
|
||||
);
|
||||
activities.val = [
|
||||
...activities.val.filter((item) => item.id !== id),
|
||||
{ id, name, status, detail },
|
||||
];
|
||||
}
|
||||
|
||||
function read(value: unknown, fallback: string): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
@ -96,6 +96,61 @@
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.message-attachments,
|
||||
.selected-attachments {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
.attachment-chip,
|
||||
.selected-attachments button {
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #4a4742;
|
||||
border-radius: 7px;
|
||||
color: #bdb8b0;
|
||||
background: #302f2c;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.attachment-chip.unavailable,
|
||||
.attachment-chip.unsupported {
|
||||
color: #d69b91;
|
||||
border-color: #6b4841;
|
||||
}
|
||||
.selected-attachments {
|
||||
grid-column: auto;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.selected-attachments > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.tool-activity {
|
||||
display: grid;
|
||||
grid-template-columns: 18px auto 1fr;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #41403c;
|
||||
border-radius: 8px;
|
||||
color: #aaa59d;
|
||||
background: #2b2a28;
|
||||
font-size: 10px;
|
||||
}
|
||||
.tool-activity span:last-child {
|
||||
text-align: right;
|
||||
color: #7f7b75;
|
||||
}
|
||||
.tool-activity.failed {
|
||||
color: #d69b91;
|
||||
border-color: #6b4841;
|
||||
}
|
||||
.composer-wrap {
|
||||
z-index: 1;
|
||||
flex: 0 0 auto;
|
||||
|
||||
@ -4,16 +4,25 @@ import "./conversation-pane.css";
|
||||
import { ProjectPanel } from "../projects/project-panel";
|
||||
import { InteractionCard } from "../interactions/interaction-card";
|
||||
import { RunStatus } from "../runs/run-status";
|
||||
import { FilePicker } from "../files/file-picker";
|
||||
|
||||
const { article, button, div, h1, header, main, p, span, textarea } = van.tags;
|
||||
|
||||
export function ConversationPane(controller: AppController): HTMLElement {
|
||||
const draft = van.state("");
|
||||
const attachments = van.state<string[]>([]);
|
||||
const attachmentContext = van.state("");
|
||||
const pickerOpen = van.state(false);
|
||||
|
||||
async function submit() {
|
||||
const content = draft.val;
|
||||
if (!content.trim()) return;
|
||||
if (await controller.send(content)) draft.val = "";
|
||||
const selected =
|
||||
attachmentContext.val === contextKey(controller) ? attachments.val : [];
|
||||
if (!content.trim() && selected.length === 0) return;
|
||||
if (await controller.send(content, selected)) {
|
||||
draft.val = "";
|
||||
attachments.val = [];
|
||||
}
|
||||
}
|
||||
|
||||
return main(
|
||||
@ -39,6 +48,26 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
||||
message.role === "user" ? "你" : "G",
|
||||
),
|
||||
message.content ? p(message.content) : div(),
|
||||
message.attachments.length
|
||||
? div(
|
||||
{ class: "message-attachments" },
|
||||
message.attachments.map((attachment) =>
|
||||
span(
|
||||
{
|
||||
class: `attachment-chip ${attachment.status}`,
|
||||
title: attachment.reason ?? attachment.path,
|
||||
},
|
||||
"▤ ",
|
||||
attachment.name,
|
||||
attachment.status === "unavailable"
|
||||
? " · 不可用"
|
||||
: attachment.status === "unsupported"
|
||||
? " · 不支持读取"
|
||||
: "",
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
controller.interactions.val
|
||||
.filter(
|
||||
@ -57,6 +86,24 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
||||
p(controller.assistantDraft),
|
||||
)
|
||||
: null,
|
||||
() =>
|
||||
div(
|
||||
{ class: "tool-activities" },
|
||||
...controller.toolActivities.val.map((tool) =>
|
||||
div(
|
||||
{ class: `tool-activity ${tool.status}` },
|
||||
span(
|
||||
tool.status === "running"
|
||||
? "◌"
|
||||
: tool.status === "completed"
|
||||
? "✓"
|
||||
: "!",
|
||||
),
|
||||
span(toolName(tool.name)),
|
||||
span(tool.detail),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
() => RunStatus(controller),
|
||||
)
|
||||
@ -68,7 +115,21 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
||||
),
|
||||
div(
|
||||
{ class: "composer-slot", hidden: () => !canCompose(controller) },
|
||||
Composer(controller, draft, submit),
|
||||
Composer(
|
||||
controller,
|
||||
draft,
|
||||
attachments,
|
||||
attachmentContext,
|
||||
pickerOpen,
|
||||
submit,
|
||||
),
|
||||
),
|
||||
div({ class: "file-picker-host", hidden: () => !pickerOpen.val }, () =>
|
||||
pickerOpen.val
|
||||
? FilePicker(controller.selection.val, attachments, () => {
|
||||
pickerOpen.val = false;
|
||||
})
|
||||
: div(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -76,6 +137,9 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
||||
function Composer(
|
||||
controller: AppController,
|
||||
draft: State<string>,
|
||||
attachments: State<string[]>,
|
||||
attachmentContext: State<string>,
|
||||
pickerOpen: State<boolean>,
|
||||
submit: () => Promise<void>,
|
||||
): HTMLElement {
|
||||
return div(
|
||||
@ -90,6 +154,30 @@ function Composer(
|
||||
),
|
||||
div(
|
||||
{ class: "composer" },
|
||||
div(
|
||||
{
|
||||
class: "selected-attachments",
|
||||
hidden: () =>
|
||||
attachmentContext.val !== contextKey(controller) ||
|
||||
attachments.val.length === 0,
|
||||
},
|
||||
() =>
|
||||
div(
|
||||
...attachments.val.map((path) =>
|
||||
button(
|
||||
{
|
||||
onclick: () => {
|
||||
attachments.val = attachments.val.filter(
|
||||
(item) => item !== path,
|
||||
);
|
||||
},
|
||||
title: path,
|
||||
},
|
||||
`▤ ${path.split("/").at(-1) ?? path} ×`,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
textarea({
|
||||
"aria-label": "输入消息",
|
||||
placeholder: "我能帮你做些什么?",
|
||||
@ -108,13 +196,34 @@ function Composer(
|
||||
div(
|
||||
{ class: "composer-actions" },
|
||||
button(
|
||||
{ class: "attach", disabled: true, "aria-label": "添加附件" },
|
||||
{
|
||||
class: "attach",
|
||||
disabled: () =>
|
||||
!workspaceAvailable(controller) || controller.sending.val,
|
||||
onclick: () => {
|
||||
const key = contextKey(controller);
|
||||
if (attachmentContext.val !== key) attachments.val = [];
|
||||
attachmentContext.val = key;
|
||||
pickerOpen.val = true;
|
||||
},
|
||||
"aria-label": "添加附件",
|
||||
title: () =>
|
||||
workspaceAvailable(controller)
|
||||
? "添加工作区文件"
|
||||
: "当前工作区不可用",
|
||||
},
|
||||
"+",
|
||||
),
|
||||
button(
|
||||
{
|
||||
class: "send",
|
||||
disabled: () => !controller.sending.val && !draft.val.trim(),
|
||||
disabled: () =>
|
||||
!controller.sending.val &&
|
||||
!draft.val.trim() &&
|
||||
!(
|
||||
attachmentContext.val === contextKey(controller) &&
|
||||
attachments.val.length
|
||||
),
|
||||
onclick: () =>
|
||||
controller.sending.val
|
||||
? void controller.stopGenerating()
|
||||
@ -137,6 +246,41 @@ function Composer(
|
||||
);
|
||||
}
|
||||
|
||||
function contextKey(controller: AppController): string {
|
||||
const selection = controller.selection.val;
|
||||
if (selection.kind === "conversation") return `conversation:${selection.id}`;
|
||||
if (selection.kind === "project-draft")
|
||||
return `project:${selection.projectId}`;
|
||||
return "ordinary";
|
||||
}
|
||||
|
||||
function workspaceAvailable(controller: AppController): boolean {
|
||||
const selection = controller.selection.val;
|
||||
const projectId =
|
||||
selection.kind === "project-draft"
|
||||
? selection.projectId
|
||||
: selection.kind === "conversation"
|
||||
? controller.active.val?.projectId
|
||||
: null;
|
||||
if (!projectId) return true;
|
||||
return (
|
||||
controller.projects.val.find((project) => project.id === projectId)
|
||||
?.workspaceAvailable ?? false
|
||||
);
|
||||
}
|
||||
|
||||
function toolName(name: string): string {
|
||||
return (
|
||||
(
|
||||
{
|
||||
list_directory: "浏览文件",
|
||||
search_files: "搜索文件",
|
||||
read_text_file: "读取文件",
|
||||
} as Record<string, string>
|
||||
)[name] ?? name
|
||||
);
|
||||
}
|
||||
|
||||
function isProjectConversation(controller: AppController): boolean {
|
||||
const selection = controller.selection.val;
|
||||
return (
|
||||
|
||||
94
apps/web/src/features/files/file-picker.css
Normal file
94
apps/web/src/features/files/file-picker.css
Normal file
@ -0,0 +1,94 @@
|
||||
.file-picker-backdrop {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #0009;
|
||||
}
|
||||
.file-picker {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(220px, 50vh) auto;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
overflow: hidden;
|
||||
border: 1px solid #494641;
|
||||
border-radius: 14px;
|
||||
background: #282725;
|
||||
box-shadow: 0 24px 70px #000a;
|
||||
}
|
||||
.file-picker-header,
|
||||
.file-picker-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.file-picker-header h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.file-picker-header p {
|
||||
margin: 4px 0 0;
|
||||
color: #8d8982;
|
||||
font-size: 10px;
|
||||
}
|
||||
.file-picker-close {
|
||||
color: #aaa49c;
|
||||
background: transparent;
|
||||
font-size: 22px;
|
||||
}
|
||||
.file-search {
|
||||
margin: 0 14px 10px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid #45423e;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
color: #e7e2db;
|
||||
background: #1f1e1d;
|
||||
}
|
||||
.file-picker-list {
|
||||
overflow-y: auto;
|
||||
border-block: 1px solid #3b3936;
|
||||
}
|
||||
.file-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr 24px;
|
||||
width: 100%;
|
||||
padding: 9px 15px;
|
||||
color: #d9d5ce;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
.file-entry:hover {
|
||||
background: #34322f;
|
||||
}
|
||||
.file-entry.selected {
|
||||
background: #513a33;
|
||||
}
|
||||
.file-entry-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-entry-check {
|
||||
color: #d08a72;
|
||||
text-align: right;
|
||||
}
|
||||
.file-picker-state {
|
||||
padding: 16px;
|
||||
color: #918c84;
|
||||
font-size: 11px;
|
||||
}
|
||||
.file-picker-state.error {
|
||||
color: #e4aaa0;
|
||||
}
|
||||
.file-picker-footer {
|
||||
color: #9d9890;
|
||||
font-size: 10px;
|
||||
}
|
||||
.file-picker-done {
|
||||
padding: 7px 14px;
|
||||
border-radius: 7px;
|
||||
color: #fff;
|
||||
background: #a95e4d;
|
||||
}
|
||||
156
apps/web/src/features/files/file-picker.ts
Normal file
156
apps/web/src/features/files/file-picker.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import van, { type State } from "vanjs-core";
|
||||
import type { WorkspaceEntryResponse } from "@great-agent/web-contracts";
|
||||
import type { Selection } from "../../app/app-controller-types";
|
||||
import { listWorkspace, searchWorkspace } from "../../api/workspace";
|
||||
import "./file-picker.css";
|
||||
|
||||
const { button, div, h2, input, p, span } = van.tags;
|
||||
|
||||
export function FilePicker(
|
||||
selection: Selection,
|
||||
selected: State<string[]>,
|
||||
close: () => void,
|
||||
): HTMLElement {
|
||||
const path = van.state("");
|
||||
const entries = van.state<readonly WorkspaceEntryResponse[]>([]);
|
||||
const loading = van.state(true);
|
||||
const error = van.state("");
|
||||
|
||||
async function load(nextPath = "") {
|
||||
loading.val = true;
|
||||
error.val = "";
|
||||
try {
|
||||
entries.val = await listWorkspace(selection, nextPath);
|
||||
path.val = nextPath;
|
||||
} catch (cause) {
|
||||
error.val = cause instanceof Error ? cause.message : "文件列表加载失败";
|
||||
} finally {
|
||||
loading.val = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function search(value: string) {
|
||||
if (!value.trim()) return load(path.val);
|
||||
loading.val = true;
|
||||
try {
|
||||
const matches = await searchWorkspace(selection, value);
|
||||
const paths = [...new Set(matches.map((match) => match.path))];
|
||||
entries.val = paths.map((item) => ({
|
||||
path: item,
|
||||
name: item.split("/").at(-1) ?? item,
|
||||
kind: "file" as const,
|
||||
}));
|
||||
} catch (cause) {
|
||||
error.val = cause instanceof Error ? cause.message : "搜索失败";
|
||||
} finally {
|
||||
loading.val = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(item: string) {
|
||||
selected.val = selected.val.includes(item)
|
||||
? selected.val.filter((path) => path !== item)
|
||||
: selected.val.length < 10
|
||||
? [...selected.val, item]
|
||||
: selected.val;
|
||||
}
|
||||
|
||||
void load();
|
||||
return div(
|
||||
{
|
||||
class: "file-picker-backdrop",
|
||||
onclick: (event: Event) =>
|
||||
event.target === event.currentTarget && close(),
|
||||
},
|
||||
div(
|
||||
{
|
||||
class: "file-picker",
|
||||
role: "dialog",
|
||||
"aria-modal": "true",
|
||||
"aria-label": "添加附件",
|
||||
},
|
||||
div(
|
||||
{ class: "file-picker-header" },
|
||||
div(
|
||||
h2("添加工作区文件"),
|
||||
p(() => path.val || "工作区根目录"),
|
||||
),
|
||||
button(
|
||||
{ class: "file-picker-close", onclick: close, "aria-label": "关闭" },
|
||||
"×",
|
||||
),
|
||||
),
|
||||
input({
|
||||
class: "file-search",
|
||||
type: "search",
|
||||
placeholder: "按文件名或内容搜索",
|
||||
oninput: debounce(
|
||||
(event: Event) =>
|
||||
void search((event.target as HTMLInputElement).value),
|
||||
),
|
||||
}),
|
||||
div(
|
||||
{ class: "file-picker-list" },
|
||||
() =>
|
||||
loading.val
|
||||
? p({ class: "file-picker-state" }, "正在读取文件…")
|
||||
: null,
|
||||
() =>
|
||||
error.val ? p({ class: "file-picker-state error" }, error.val) : null,
|
||||
() =>
|
||||
path.val
|
||||
? button(
|
||||
{
|
||||
class: "file-entry directory",
|
||||
onclick: () => void load(parent(path.val)),
|
||||
},
|
||||
span("↩"),
|
||||
span("返回上一级"),
|
||||
)
|
||||
: null,
|
||||
() =>
|
||||
div(
|
||||
...entries.val.map((entry) =>
|
||||
button(
|
||||
{
|
||||
class: () =>
|
||||
`file-entry ${entry.kind}${selected.val.includes(entry.path) ? " selected" : ""}`,
|
||||
onclick: () =>
|
||||
entry.kind === "directory"
|
||||
? void load(entry.path)
|
||||
: toggle(entry.path),
|
||||
},
|
||||
span(
|
||||
{ class: "file-entry-icon" },
|
||||
entry.kind === "directory" ? "▸" : "▤",
|
||||
),
|
||||
span({ class: "file-entry-name" }, entry.name),
|
||||
entry.kind === "file"
|
||||
? span({ class: "file-entry-check" }, () =>
|
||||
selected.val.includes(entry.path) ? "✓" : "",
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
div(
|
||||
{ class: "file-picker-footer" },
|
||||
span(() => `已选择 ${selected.val.length}/10`),
|
||||
button({ class: "file-picker-done", onclick: close }, "完成"),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function parent(path: string): string {
|
||||
return path.split("/").slice(0, -1).join("/");
|
||||
}
|
||||
|
||||
function debounce(action: (event: Event) => void) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
return (event: Event) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => action(event), 250);
|
||||
};
|
||||
}
|
||||
@ -78,13 +78,18 @@
|
||||
|
||||
### F-006——附件、文件列表、读取与搜索
|
||||
|
||||
- 状态:进行中
|
||||
- 状态:已完成(Agent 于 2026-08-14 验证通过,等待最终统一人工审核)
|
||||
- 用户可见结果:附加工作区文件并让 Agent 安全读取和搜索。
|
||||
- 主要验收:AC-007 至 AC-009、AC-021、AC-022、AC-028、AC-030。
|
||||
- 实现结果:消息新增必填附件元数据,支持一次选择最多 10 个工作区文件和仅附件消息;普通草稿、项目草稿与已有会话均由服务端解析可信工作区,浏览器和模型不能传入根目录。文件层支持目录浏览、文件名与 UTF-8 内容搜索、2 MiB 内文本读取、二进制识别,并拒绝绝对路径、`..`、空字节和符号链接逃逸。
|
||||
- Agent 工具:新增 `list_directory`、`search_files` 和 `read_text_file`;普通文件工具使用独立 `tool.started`、`tool.completed`、`tool.failed` 事件,结果交回同一次模型请求链继续生成,与用户交互事件保持区分。
|
||||
- 页面结果:VanJS 输入区提供工作区文件选择器、目录导航、搜索、最多 10 项选择、待发送附件和历史附件状态;只有附件时发送按钮可用。附件在发送后消失或不可访问时,历史会话仍可打开并明确显示“不可用”;项目目录离线时附件入口禁用且不会回退到默认工作区。
|
||||
- 自动化验证结果:2026-08-14 通过全仓类型检查、29 项测试和 99 个断言、生产构建、代码检查、格式检查、文件规模检查与架构依赖检查;覆盖读取/搜索、二进制拒绝、绝对路径、父目录跳转、符号链接逃逸、仅附件消息和文件工具续跑。
|
||||
- 人工操作结果:2026-08-14 使用隔离工作区、隔离数据目录和未配置模型密钥的本机应用内浏览器完成;验证选择 `README.md`、仅附件发送、会话标题与附件历史恢复;删除测试文件后刷新,历史附件显示“不可用”,隔离页面控制台无错误。
|
||||
|
||||
### F-007——文件创建与安全修改
|
||||
|
||||
- 状态:待开始
|
||||
- 状态:进行中
|
||||
- 用户可见结果:Agent 在正确工作区内创建和安全修改文本文件。
|
||||
- 主要验收:AC-008、AC-009、AC-023、AC-028。
|
||||
|
||||
|
||||
@ -28,6 +28,9 @@ export type RunEvent = Readonly<{
|
||||
| "interaction.requested"
|
||||
| "interaction.resolved"
|
||||
| "interaction.cancelled"
|
||||
| "tool.started"
|
||||
| "tool.completed"
|
||||
| "tool.failed"
|
||||
| "run.completed"
|
||||
| "run.failed"
|
||||
| "run.cancelled";
|
||||
|
||||
@ -1,9 +1,21 @@
|
||||
export type MessageRole = "user" | "assistant";
|
||||
|
||||
export type AttachmentStatus = "available" | "unavailable" | "unsupported";
|
||||
|
||||
export type AttachmentRef = Readonly<{
|
||||
name: string;
|
||||
path: string;
|
||||
mediaType: string;
|
||||
status: AttachmentStatus;
|
||||
checkedAt: string;
|
||||
reason?: string;
|
||||
}>;
|
||||
|
||||
export type Message = Readonly<{
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
attachments: readonly AttachmentRef[];
|
||||
createdAt: string;
|
||||
runId: string;
|
||||
}>;
|
||||
|
||||
@ -4,6 +4,8 @@ export type {
|
||||
ConversationSummary,
|
||||
Message,
|
||||
MessageRole,
|
||||
AttachmentRef,
|
||||
AttachmentStatus,
|
||||
} from "./domain/conversation";
|
||||
export type { AgentRun, AgentRunStatus, RunEvent } from "./domain/agent-run";
|
||||
export type { Project, ProjectView } from "./domain/project";
|
||||
@ -29,15 +31,22 @@ export type {
|
||||
IdPort,
|
||||
WorkspaceResolverPort,
|
||||
} from "./ports/system-ports";
|
||||
export type {
|
||||
WorkspaceEntry,
|
||||
WorkspaceFileInfo,
|
||||
WorkspacePort,
|
||||
WorkspaceSearchMatch,
|
||||
} from "./ports/workspace-port";
|
||||
export {
|
||||
WorkspaceService,
|
||||
type WorkspaceContext,
|
||||
} from "./use-cases/workspace-service";
|
||||
export {
|
||||
ConversationService,
|
||||
type ConversationServiceDependencies,
|
||||
} from "./use-cases/conversation-service";
|
||||
export {
|
||||
AgentRunService,
|
||||
type StartedRun,
|
||||
type StartRunInput,
|
||||
} from "./use-cases/agent-run-service";
|
||||
export { AgentRunService } from "./use-cases/agent-run-service";
|
||||
export type { StartedRun, StartRunInput } from "./use-cases/run-types";
|
||||
export {
|
||||
ProjectService,
|
||||
type ProjectServiceDependencies,
|
||||
|
||||
@ -4,12 +4,12 @@ export type ModelRequest = Readonly<{
|
||||
model: string;
|
||||
messages: readonly Message[];
|
||||
tools?: readonly ModelTool[];
|
||||
continuation?: Readonly<{
|
||||
continuations?: readonly Readonly<{
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
toolArguments: string;
|
||||
result: string;
|
||||
}>;
|
||||
}>[];
|
||||
}>;
|
||||
|
||||
export type ModelTool = Readonly<{
|
||||
|
||||
28
packages/agent-core/src/ports/workspace-port.ts
Normal file
28
packages/agent-core/src/ports/workspace-port.ts
Normal file
@ -0,0 +1,28 @@
|
||||
export type WorkspaceEntry = Readonly<{
|
||||
path: string;
|
||||
name: string;
|
||||
kind: "file" | "directory";
|
||||
size?: number;
|
||||
}>;
|
||||
|
||||
export type WorkspaceSearchMatch = Readonly<{
|
||||
path: string;
|
||||
line?: number;
|
||||
preview?: string;
|
||||
matchedBy: "name" | "content";
|
||||
}>;
|
||||
|
||||
export type WorkspaceFileInfo = Readonly<{
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mediaType: string;
|
||||
readableAsText: boolean;
|
||||
}>;
|
||||
|
||||
export interface WorkspacePort {
|
||||
list(root: string, path?: string): Promise<readonly WorkspaceEntry[]>;
|
||||
search(root: string, query: string): Promise<readonly WorkspaceSearchMatch[]>;
|
||||
inspect(root: string, path: string): Promise<WorkspaceFileInfo>;
|
||||
readText(root: string, path: string): Promise<string>;
|
||||
}
|
||||
@ -272,6 +272,50 @@ describe("AgentRunService", () => {
|
||||
).toHaveLength(1);
|
||||
expect(conversation.messages.at(-1)?.content).toBe("重试成功");
|
||||
});
|
||||
|
||||
test("执行文件工具后把结果交回模型并继续同一次 Run", async () => {
|
||||
let calls = 0;
|
||||
const fixture = createFixture({
|
||||
async *stream(request) {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
yield {
|
||||
type: "tool.requested",
|
||||
toolCallId: "tool_read",
|
||||
name: "read_text_file",
|
||||
arguments: JSON.stringify({ path: "README.md" }),
|
||||
};
|
||||
return;
|
||||
}
|
||||
expect(request.continuations?.[0]?.result).toBe("本地文件内容");
|
||||
yield { type: "text.delta", delta: "已经读取文件" };
|
||||
yield { type: "response.completed" };
|
||||
},
|
||||
});
|
||||
const workspace = {
|
||||
createAttachments: async () => [],
|
||||
executeTool: async () => "本地文件内容",
|
||||
} as unknown as import("..").WorkspaceService;
|
||||
fixture.service = new AgentRunService(
|
||||
fixture.conversationService,
|
||||
fixture.model,
|
||||
fixture.runs,
|
||||
{ now: () => new Date("2026-08-14T00:00:00Z") },
|
||||
{ create: () => `tool_${++fixture.nextId}` },
|
||||
projects,
|
||||
interactions,
|
||||
workspace,
|
||||
);
|
||||
const started = await fixture.service.start(
|
||||
{ kind: "ordinary", message: "读取说明" },
|
||||
new AbortController().signal,
|
||||
);
|
||||
const events: RunEvent[] = [];
|
||||
for await (const event of started.events) events.push(event);
|
||||
expect(events.map((event) => event.type)).toContain("tool.started");
|
||||
expect(events.map((event) => event.type)).toContain("tool.completed");
|
||||
expect(events.at(-1)?.type).toBe("run.completed");
|
||||
});
|
||||
});
|
||||
|
||||
function createFixture(model: ModelPort) {
|
||||
@ -292,5 +336,12 @@ function createFixture(model: ModelPort) {
|
||||
projects,
|
||||
interactions,
|
||||
);
|
||||
return { service, runs, conversations, conversationService };
|
||||
return {
|
||||
service,
|
||||
model,
|
||||
runs,
|
||||
conversations,
|
||||
conversationService,
|
||||
nextId: id,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { AgentRun, RunEvent } from "../domain/agent-run";
|
||||
import type { Conversation } from "../domain/conversation";
|
||||
import type { UserInteraction } from "../domain/user-interaction";
|
||||
import { CoreError } from "../errors/core-error";
|
||||
import type { ModelPort, ModelRequest } from "../ports/model-port";
|
||||
@ -8,14 +7,19 @@ import type { ClockPort, IdPort } from "../ports/system-ports";
|
||||
import type { ConversationService } from "./conversation-service";
|
||||
import type { InteractionService } from "./interaction-service";
|
||||
import type { ProjectService } from "./project-service";
|
||||
import type { WorkspaceService } from "./workspace-service";
|
||||
import { isWorkspaceTool, workspaceTools } from "./workspace-tools";
|
||||
import { interactionTool } from "./interaction-tool";
|
||||
import {
|
||||
ensureNoActiveRun,
|
||||
lastSequence,
|
||||
messagesThroughTrigger,
|
||||
persistRunEvent,
|
||||
requireRun,
|
||||
safeErrorMessage,
|
||||
} from "./run-service-helpers";
|
||||
import type { StartedRun, StartRunInput } from "./run-types";
|
||||
export type { StartedRun, StartRunInput } from "./run-types";
|
||||
import { executeWorkspaceTool, prepareRunConversation } from "./run-workspace";
|
||||
|
||||
export class AgentRunService {
|
||||
private readonly controllers = new Map<string, AbortController>();
|
||||
@ -29,13 +33,20 @@ export class AgentRunService {
|
||||
private readonly ids: IdPort,
|
||||
private readonly projects: ProjectService,
|
||||
private readonly interactions: InteractionService,
|
||||
private readonly workspace?: WorkspaceService,
|
||||
) {}
|
||||
|
||||
async start(input: StartRunInput, signal: AbortSignal): Promise<StartedRun> {
|
||||
return this.withStartLock(async () => {
|
||||
await this.ensureNoActiveRun();
|
||||
await ensureNoActiveRun(this.runs);
|
||||
const runId = this.ids.create();
|
||||
const conversation = await this.prepareConversation(input, runId);
|
||||
const conversation = await prepareRunConversation(
|
||||
input,
|
||||
runId,
|
||||
this.conversations,
|
||||
this.projects,
|
||||
this.workspace,
|
||||
);
|
||||
const timestamp = this.clock.now().toISOString();
|
||||
const triggerMessage = conversation.messages.at(-1);
|
||||
if (triggerMessage?.role !== "user")
|
||||
@ -60,7 +71,7 @@ export class AgentRunService {
|
||||
interaction: UserInteraction,
|
||||
signal: AbortSignal,
|
||||
): Promise<StartedRun> {
|
||||
const current = await this.requireRun(interaction.runId);
|
||||
const current = await requireRun(this.runs, interaction.runId);
|
||||
if (current.status !== "waiting_user")
|
||||
throw new CoreError("RUN_NOT_WAITING", "该任务当前没有等待用户回答");
|
||||
if (!interaction.answer)
|
||||
@ -72,17 +83,17 @@ export class AgentRunService {
|
||||
};
|
||||
await this.runs.update(run);
|
||||
const sequence = lastSequence(await this.runs.listEvents(run.id));
|
||||
const continuation: NonNullable<ModelRequest["continuation"]> = {
|
||||
const continuation: NonNullable<ModelRequest["continuations"]>[number] = {
|
||||
toolCallId: interaction.toolCallId,
|
||||
toolName: "request_user_interaction",
|
||||
toolArguments: interaction.toolArguments,
|
||||
result: JSON.stringify(interaction.answer),
|
||||
};
|
||||
return this.started(run, signal, sequence, continuation, interaction);
|
||||
return this.started(run, signal, sequence, [continuation], interaction);
|
||||
}
|
||||
|
||||
async cancel(runId: string): Promise<readonly RunEvent[]> {
|
||||
const run = await this.requireRun(runId);
|
||||
const run = await requireRun(this.runs, runId);
|
||||
if (
|
||||
run.status === "cancelled" ||
|
||||
run.status === "completed" ||
|
||||
@ -103,7 +114,9 @@ export class AgentRunService {
|
||||
if (!interaction)
|
||||
throw new CoreError("INTERACTION_NOT_FOUND", "等待中的交互请求不存在");
|
||||
let sequence = lastSequence(await this.runs.listEvents(run.id));
|
||||
const cancelled = await this.persistEvent(
|
||||
const cancelled = await persistRunEvent(
|
||||
this.runs,
|
||||
this.clock,
|
||||
run.id,
|
||||
++sequence,
|
||||
"interaction.cancelled",
|
||||
@ -114,7 +127,9 @@ export class AgentRunService {
|
||||
status: "cancelled",
|
||||
updatedAt: this.clock.now().toISOString(),
|
||||
});
|
||||
const finished = await this.persistEvent(
|
||||
const finished = await persistRunEvent(
|
||||
this.runs,
|
||||
this.clock,
|
||||
run.id,
|
||||
++sequence,
|
||||
"run.cancelled",
|
||||
@ -125,8 +140,8 @@ export class AgentRunService {
|
||||
|
||||
async retry(runId: string, signal: AbortSignal): Promise<StartedRun> {
|
||||
return this.withStartLock(async () => {
|
||||
await this.ensureNoActiveRun();
|
||||
const original = await this.requireRun(runId);
|
||||
await ensureNoActiveRun(this.runs);
|
||||
const original = await requireRun(this.runs, runId);
|
||||
if (original.status !== "failed" && original.status !== "cancelled")
|
||||
throw new CoreError(
|
||||
"RUN_NOT_RETRYABLE",
|
||||
@ -156,19 +171,19 @@ export class AgentRunService {
|
||||
}
|
||||
|
||||
async lastSequence(runId: string): Promise<number> {
|
||||
await this.requireRun(runId);
|
||||
await requireRun(this.runs, runId);
|
||||
return lastSequence(await this.runs.listEvents(runId));
|
||||
}
|
||||
|
||||
getRun(runId: string): Promise<AgentRun> {
|
||||
return this.requireRun(runId);
|
||||
return requireRun(this.runs, runId);
|
||||
}
|
||||
|
||||
private started(
|
||||
run: AgentRun,
|
||||
externalSignal: AbortSignal,
|
||||
sequence: number,
|
||||
continuation?: NonNullable<ModelRequest["continuation"]>,
|
||||
continuations?: NonNullable<ModelRequest["continuations"]>,
|
||||
interaction?: UserInteraction,
|
||||
): StartedRun {
|
||||
const controller = new AbortController();
|
||||
@ -176,19 +191,10 @@ export class AgentRunService {
|
||||
const signal = AbortSignal.any([externalSignal, controller.signal]);
|
||||
return {
|
||||
run,
|
||||
events: this.execute(run, signal, sequence, continuation, interaction),
|
||||
events: this.execute(run, signal, sequence, continuations, interaction),
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureNoActiveRun(): Promise<void> {
|
||||
const active = await this.runs.findActive();
|
||||
if (active)
|
||||
throw new CoreError(
|
||||
"RUN_ALREADY_ACTIVE",
|
||||
"已有任务正在运行,请先停止或完成当前任务",
|
||||
);
|
||||
}
|
||||
|
||||
private async withStartLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.startQueue;
|
||||
let release = () => {};
|
||||
@ -203,37 +209,11 @@ export class AgentRunService {
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareConversation(
|
||||
input: StartRunInput,
|
||||
runId: string,
|
||||
): Promise<Conversation> {
|
||||
if (input.kind === "ordinary")
|
||||
return this.conversations.createWithFirstMessage(input.message, runId);
|
||||
if (input.kind === "project") {
|
||||
await this.projects.requireAvailable(input.projectId);
|
||||
return this.conversations.createProjectWithFirstMessage(
|
||||
input.projectId,
|
||||
input.message,
|
||||
runId,
|
||||
);
|
||||
}
|
||||
const existing = await this.conversations.getConversation(
|
||||
input.conversationId,
|
||||
);
|
||||
if (existing.projectId)
|
||||
await this.projects.requireAvailable(existing.projectId);
|
||||
return this.conversations.appendUserMessage(
|
||||
input.conversationId,
|
||||
input.message,
|
||||
runId,
|
||||
);
|
||||
}
|
||||
|
||||
private async *execute(
|
||||
run: AgentRun,
|
||||
signal: AbortSignal,
|
||||
initialSequence: number,
|
||||
continuation?: NonNullable<ModelRequest["continuation"]>,
|
||||
initialContinuations: NonNullable<ModelRequest["continuations"]> = [],
|
||||
resolvedInteraction?: UserInteraction,
|
||||
): AsyncIterable<RunEvent> {
|
||||
let sequence = initialSequence;
|
||||
@ -243,9 +223,10 @@ export class AgentRunService {
|
||||
const event = async (
|
||||
type: RunEvent["type"],
|
||||
payload: Record<string, unknown>,
|
||||
) => this.persistEvent(run.id, ++sequence, type, payload);
|
||||
) =>
|
||||
persistRunEvent(this.runs, this.clock, run.id, ++sequence, type, payload);
|
||||
try {
|
||||
if (!continuation)
|
||||
if (initialContinuations.length === 0)
|
||||
yield await event("run.started", {
|
||||
conversationId: run.conversationId,
|
||||
});
|
||||
@ -263,53 +244,101 @@ export class AgentRunService {
|
||||
? messagesThroughTrigger(conversation, run.triggerMessageId)
|
||||
: conversation.messages;
|
||||
signal.throwIfAborted();
|
||||
for await (const modelEvent of this.model.stream(
|
||||
{
|
||||
model: "default",
|
||||
messages: modelMessages,
|
||||
tools: [interactionTool],
|
||||
...(continuation ? { continuation } : {}),
|
||||
},
|
||||
signal,
|
||||
)) {
|
||||
signal.throwIfAborted();
|
||||
if (modelEvent.type === "text.delta") {
|
||||
content += modelEvent.delta;
|
||||
yield await event("message.delta", {
|
||||
messageId,
|
||||
delta: modelEvent.delta,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (modelEvent.type === "tool.requested") {
|
||||
if (modelEvent.name !== interactionTool.name)
|
||||
throw new CoreError(
|
||||
"TOOL_NOT_SUPPORTED",
|
||||
"模型请求了尚未支持的工具",
|
||||
const continuations = [...initialContinuations];
|
||||
let toolCount = 0;
|
||||
modelLoop: while (true) {
|
||||
let requestedTool = false;
|
||||
for await (const modelEvent of this.model.stream(
|
||||
{
|
||||
model: "default",
|
||||
messages: modelMessages,
|
||||
tools: [interactionTool, ...workspaceTools],
|
||||
...(continuations.length ? { continuations } : {}),
|
||||
},
|
||||
signal,
|
||||
)) {
|
||||
signal.throwIfAborted();
|
||||
if (modelEvent.type === "text.delta") {
|
||||
content += modelEvent.delta;
|
||||
yield await event("message.delta", {
|
||||
messageId,
|
||||
delta: modelEvent.delta,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (modelEvent.type === "tool.requested") {
|
||||
requestedTool = true;
|
||||
if (isWorkspaceTool(modelEvent.name)) {
|
||||
if (++toolCount > 12)
|
||||
throw new CoreError(
|
||||
"TOOL_LIMIT_EXCEEDED",
|
||||
"文件工具调用次数过多",
|
||||
);
|
||||
yield await event("tool.started", {
|
||||
messageId,
|
||||
toolCallId: modelEvent.toolCallId,
|
||||
name: modelEvent.name,
|
||||
});
|
||||
const tool = await executeWorkspaceTool(
|
||||
this.workspace,
|
||||
run.conversationId,
|
||||
modelEvent.name,
|
||||
modelEvent.arguments,
|
||||
);
|
||||
if (!tool.failed) {
|
||||
yield await event("tool.completed", {
|
||||
messageId,
|
||||
toolCallId: modelEvent.toolCallId,
|
||||
name: modelEvent.name,
|
||||
summary: tool.summary,
|
||||
});
|
||||
} else {
|
||||
yield await event("tool.failed", {
|
||||
messageId,
|
||||
toolCallId: modelEvent.toolCallId,
|
||||
name: modelEvent.name,
|
||||
code: tool.failed.code,
|
||||
message: tool.failed.message,
|
||||
});
|
||||
}
|
||||
continuations.push({
|
||||
toolCallId: modelEvent.toolCallId,
|
||||
toolName: modelEvent.name,
|
||||
toolArguments: modelEvent.arguments,
|
||||
result: tool.result,
|
||||
});
|
||||
continue modelLoop;
|
||||
}
|
||||
if (modelEvent.name !== interactionTool.name)
|
||||
throw new CoreError(
|
||||
"TOOL_NOT_SUPPORTED",
|
||||
"模型请求了尚未支持的工具",
|
||||
);
|
||||
await this.conversations.appendAssistantMessage(
|
||||
run.conversationId,
|
||||
messageId,
|
||||
run.id,
|
||||
content,
|
||||
);
|
||||
await this.conversations.appendAssistantMessage(
|
||||
run.conversationId,
|
||||
messageId,
|
||||
run.id,
|
||||
content,
|
||||
);
|
||||
messagePersisted = true;
|
||||
yield await event("message.completed", { messageId, content });
|
||||
const interaction = await this.interactions.create({
|
||||
runId: run.id,
|
||||
conversationId: run.conversationId,
|
||||
messageId,
|
||||
toolCallId: modelEvent.toolCallId,
|
||||
toolArguments: modelEvent.arguments,
|
||||
});
|
||||
await this.runs.update({
|
||||
...run,
|
||||
status: "waiting_user",
|
||||
updatedAt: this.clock.now().toISOString(),
|
||||
});
|
||||
yield await event("interaction.requested", { interaction });
|
||||
return;
|
||||
messagePersisted = true;
|
||||
yield await event("message.completed", { messageId, content });
|
||||
const interaction = await this.interactions.create({
|
||||
runId: run.id,
|
||||
conversationId: run.conversationId,
|
||||
messageId,
|
||||
toolCallId: modelEvent.toolCallId,
|
||||
toolArguments: modelEvent.arguments,
|
||||
});
|
||||
await this.runs.update({
|
||||
...run,
|
||||
status: "waiting_user",
|
||||
updatedAt: this.clock.now().toISOString(),
|
||||
});
|
||||
yield await event("interaction.requested", { interaction });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!requestedTool) break;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
if (!content)
|
||||
@ -367,27 +396,4 @@ export class AgentRunService {
|
||||
this.controllers.delete(run.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async requireRun(id: string): Promise<AgentRun> {
|
||||
const run = await this.runs.getById(id);
|
||||
if (!run) throw new CoreError("RUN_NOT_FOUND", "任务不存在");
|
||||
return run;
|
||||
}
|
||||
|
||||
private async persistEvent(
|
||||
runId: string,
|
||||
sequence: number,
|
||||
type: RunEvent["type"],
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
const value: RunEvent = {
|
||||
runId,
|
||||
sequence,
|
||||
timestamp: this.clock.now().toISOString(),
|
||||
type,
|
||||
payload,
|
||||
};
|
||||
await this.runs.appendEvent(value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,4 +70,25 @@ describe("ConversationService", () => {
|
||||
).rejects.toMatchObject({ code: "MESSAGE_EMPTY" });
|
||||
expect(conversations.values.size).toBe(0);
|
||||
});
|
||||
|
||||
test("允许只有附件而没有文本的消息", async () => {
|
||||
const conversations = new MemoryConversationRepository();
|
||||
const service = new ConversationService({
|
||||
conversations,
|
||||
clock: { now: () => new Date("2026-08-14T00:00:00Z") },
|
||||
ids: { create: () => "attachment_message" },
|
||||
});
|
||||
const created = await service.createWithFirstMessage("", "run_1", [
|
||||
{
|
||||
name: "说明.md",
|
||||
path: "docs/说明.md",
|
||||
mediaType: "text/markdown",
|
||||
status: "available",
|
||||
checkedAt: "2026-08-14T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
expect(created.title).toBe("说明.md");
|
||||
expect(created.messages[0]?.content).toBe("");
|
||||
expect(created.messages[0]?.attachments[0]?.path).toBe("docs/说明.md");
|
||||
});
|
||||
});
|
||||
|
||||
@ -34,14 +34,17 @@ export class ConversationService {
|
||||
async createWithFirstMessage(
|
||||
content: string,
|
||||
runId: string,
|
||||
attachments: Message["attachments"] = [],
|
||||
): Promise<Conversation> {
|
||||
const normalized = normalizeContent(content);
|
||||
const normalized = normalizeContent(content, attachments.length);
|
||||
const timestamp = this.dependencies.clock.now().toISOString();
|
||||
const conversation: Conversation = {
|
||||
id: this.dependencies.ids.create(),
|
||||
projectId: null,
|
||||
title: createTitle(normalized),
|
||||
messages: [this.createUserMessage(normalized, timestamp, runId)],
|
||||
title: createTitle(normalized, attachments),
|
||||
messages: [
|
||||
this.createUserMessage(normalized, timestamp, runId, attachments),
|
||||
],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
@ -53,14 +56,17 @@ export class ConversationService {
|
||||
projectId: string,
|
||||
content: string,
|
||||
runId: string,
|
||||
attachments: Message["attachments"] = [],
|
||||
): Promise<Conversation> {
|
||||
const normalized = normalizeContent(content);
|
||||
const normalized = normalizeContent(content, attachments.length);
|
||||
const timestamp = this.dependencies.clock.now().toISOString();
|
||||
const conversation: Conversation = {
|
||||
id: this.dependencies.ids.create(),
|
||||
projectId,
|
||||
title: createTitle(normalized),
|
||||
messages: [this.createUserMessage(normalized, timestamp, runId)],
|
||||
title: createTitle(normalized, attachments),
|
||||
messages: [
|
||||
this.createUserMessage(normalized, timestamp, runId, attachments),
|
||||
],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
@ -72,8 +78,9 @@ export class ConversationService {
|
||||
id: string,
|
||||
content: string,
|
||||
runId: string,
|
||||
attachments: Message["attachments"] = [],
|
||||
): Promise<Conversation> {
|
||||
const normalized = normalizeContent(content);
|
||||
const normalized = normalizeContent(content, attachments.length);
|
||||
await this.getConversation(id);
|
||||
return this.dependencies.conversations.appendMessage(
|
||||
id,
|
||||
@ -81,6 +88,7 @@ export class ConversationService {
|
||||
normalized,
|
||||
this.dependencies.clock.now().toISOString(),
|
||||
runId,
|
||||
attachments,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -96,6 +104,7 @@ export class ConversationService {
|
||||
id: messageId,
|
||||
role: "assistant",
|
||||
content,
|
||||
attachments: [],
|
||||
createdAt: this.dependencies.clock.now().toISOString(),
|
||||
runId,
|
||||
});
|
||||
@ -105,26 +114,33 @@ export class ConversationService {
|
||||
content: string,
|
||||
createdAt: string,
|
||||
runId: string,
|
||||
attachments: Message["attachments"],
|
||||
): Message {
|
||||
return {
|
||||
id: this.dependencies.ids.create(),
|
||||
role: "user",
|
||||
content,
|
||||
attachments,
|
||||
createdAt,
|
||||
runId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContent(content: string): string {
|
||||
function normalizeContent(content: string, attachmentCount: number): string {
|
||||
const normalized = content.trim();
|
||||
if (!normalized) throw new CoreError("MESSAGE_EMPTY", "请输入消息内容");
|
||||
if (!normalized && attachmentCount === 0)
|
||||
throw new CoreError("MESSAGE_EMPTY", "请输入消息内容或添加附件");
|
||||
if (normalized.length > 32_000)
|
||||
throw new CoreError("MESSAGE_TOO_LONG", "消息内容过长");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function createTitle(content: string): string {
|
||||
function createTitle(
|
||||
content: string,
|
||||
attachments: Message["attachments"],
|
||||
): string {
|
||||
if (!content) return attachments[0]?.name ?? "附件对话";
|
||||
const firstLine = content.split("\n", 1)[0] ?? content;
|
||||
return firstLine.length > 36 ? `${firstLine.slice(0, 36)}…` : firstLine;
|
||||
}
|
||||
|
||||
@ -173,7 +173,7 @@ function createFixture() {
|
||||
});
|
||||
const model: ModelPort = {
|
||||
async *stream(request) {
|
||||
if (!request.continuation) {
|
||||
if (!request.continuations?.length) {
|
||||
yield {
|
||||
type: "tool.requested",
|
||||
toolCallId: "tool_choice",
|
||||
@ -188,8 +188,8 @@ function createFixture() {
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
expect(request.continuation.toolCallId).toBe("tool_choice");
|
||||
expect(request.continuation.result).toContain("simple");
|
||||
expect(request.continuations[0]?.toolCallId).toBe("tool_choice");
|
||||
expect(request.continuations[0]?.result).toContain("simple");
|
||||
yield { type: "text.delta", delta: "已按你的选择继续完成" };
|
||||
yield { type: "response.completed" };
|
||||
}
|
||||
|
||||
@ -87,6 +87,10 @@ export class ProjectService {
|
||||
return project;
|
||||
}
|
||||
|
||||
async getEntity(id: string): Promise<Project> {
|
||||
return this.require(id);
|
||||
}
|
||||
|
||||
private async require(id: string): Promise<Project> {
|
||||
const project = await this.dependencies.projects.getById(id);
|
||||
if (!project) throw new CoreError("PROJECT_NOT_FOUND", "项目不存在");
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import type { RunEvent } from "../domain/agent-run";
|
||||
import type { Conversation } from "../domain/conversation";
|
||||
import { CoreError } from "../errors/core-error";
|
||||
import type { RunRepository } from "../ports/run-repository";
|
||||
import type { ClockPort } from "../ports/system-ports";
|
||||
|
||||
export function lastSequence(events: readonly RunEvent[]): number {
|
||||
return events.at(-1)?.sequence ?? 0;
|
||||
@ -21,3 +23,36 @@ export function messagesThroughTrigger(
|
||||
? conversation.messages
|
||||
: conversation.messages.slice(0, index + 1);
|
||||
}
|
||||
|
||||
export async function ensureNoActiveRun(runs: RunRepository): Promise<void> {
|
||||
if (await runs.findActive())
|
||||
throw new CoreError(
|
||||
"RUN_ALREADY_ACTIVE",
|
||||
"已有任务正在运行,请先停止或完成当前任务",
|
||||
);
|
||||
}
|
||||
|
||||
export async function requireRun(runs: RunRepository, id: string) {
|
||||
const run = await runs.getById(id);
|
||||
if (!run) throw new CoreError("RUN_NOT_FOUND", "任务不存在");
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function persistRunEvent(
|
||||
runs: RunRepository,
|
||||
clock: ClockPort,
|
||||
runId: string,
|
||||
sequence: number,
|
||||
type: RunEvent["type"],
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<RunEvent> {
|
||||
const value: RunEvent = {
|
||||
runId,
|
||||
sequence,
|
||||
timestamp: clock.now().toISOString(),
|
||||
type,
|
||||
payload,
|
||||
};
|
||||
await runs.appendEvent(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@ -1,9 +1,23 @@
|
||||
import type { AgentRun, RunEvent } from "../domain/agent-run";
|
||||
|
||||
export type StartRunInput =
|
||||
| Readonly<{ kind: "ordinary"; message: string }>
|
||||
| Readonly<{ kind: "project"; projectId: string; message: string }>
|
||||
| Readonly<{ kind: "existing"; conversationId: string; message: string }>;
|
||||
| Readonly<{
|
||||
kind: "ordinary";
|
||||
message: string;
|
||||
attachments?: readonly string[];
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "project";
|
||||
projectId: string;
|
||||
message: string;
|
||||
attachments?: readonly string[];
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "existing";
|
||||
conversationId: string;
|
||||
message: string;
|
||||
attachments?: readonly string[];
|
||||
}>;
|
||||
|
||||
export type StartedRun = Readonly<{
|
||||
run: AgentRun;
|
||||
|
||||
99
packages/agent-core/src/use-cases/run-workspace.ts
Normal file
99
packages/agent-core/src/use-cases/run-workspace.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import type { Conversation } from "../domain/conversation";
|
||||
import { CoreError } from "../errors/core-error";
|
||||
import type { ConversationService } from "./conversation-service";
|
||||
import type { ProjectService } from "./project-service";
|
||||
import type { StartRunInput } from "./run-types";
|
||||
import type { WorkspaceService } from "./workspace-service";
|
||||
|
||||
export async function prepareRunConversation(
|
||||
input: StartRunInput,
|
||||
runId: string,
|
||||
conversations: ConversationService,
|
||||
projects: ProjectService,
|
||||
workspace?: WorkspaceService,
|
||||
): Promise<Conversation> {
|
||||
const context =
|
||||
input.kind === "ordinary"
|
||||
? ({ kind: "ordinary" } as const)
|
||||
: input.kind === "project"
|
||||
? ({ kind: "project", projectId: input.projectId } as const)
|
||||
: ({
|
||||
kind: "conversation",
|
||||
conversationId: input.conversationId,
|
||||
} as const);
|
||||
const attachments = input.attachments?.length
|
||||
? await requireWorkspace(workspace).createAttachments(
|
||||
context,
|
||||
input.attachments,
|
||||
)
|
||||
: [];
|
||||
if (input.kind === "ordinary")
|
||||
return conversations.createWithFirstMessage(
|
||||
input.message,
|
||||
runId,
|
||||
attachments,
|
||||
);
|
||||
if (input.kind === "project") {
|
||||
await projects.requireAvailable(input.projectId);
|
||||
return conversations.createProjectWithFirstMessage(
|
||||
input.projectId,
|
||||
input.message,
|
||||
runId,
|
||||
attachments,
|
||||
);
|
||||
}
|
||||
const existing = await conversations.getConversation(input.conversationId);
|
||||
if (existing.projectId) await projects.requireAvailable(existing.projectId);
|
||||
return conversations.appendUserMessage(
|
||||
input.conversationId,
|
||||
input.message,
|
||||
runId,
|
||||
attachments,
|
||||
);
|
||||
}
|
||||
|
||||
export type WorkspaceToolResult = Readonly<{
|
||||
result: string;
|
||||
failed?: Readonly<{ code: string; message: string }>;
|
||||
summary?: string;
|
||||
}>;
|
||||
|
||||
export async function executeWorkspaceTool(
|
||||
workspace: WorkspaceService | undefined,
|
||||
conversationId: string,
|
||||
name: string,
|
||||
argumentsValue: string,
|
||||
): Promise<WorkspaceToolResult> {
|
||||
try {
|
||||
const result = await requireWorkspace(workspace).executeTool(
|
||||
conversationId,
|
||||
name,
|
||||
argumentsValue,
|
||||
);
|
||||
return { result, summary: summarize(name, result) };
|
||||
} catch (cause) {
|
||||
const error =
|
||||
cause instanceof CoreError
|
||||
? cause
|
||||
: new CoreError("TOOL_FAILED", "文件工具执行失败");
|
||||
return {
|
||||
result: JSON.stringify({ error: error.code, message: error.message }),
|
||||
failed: { code: error.code, message: error.message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function requireWorkspace(workspace?: WorkspaceService): WorkspaceService {
|
||||
if (!workspace)
|
||||
throw new CoreError("WORKSPACE_UNAVAILABLE", "文件能力尚未配置");
|
||||
return workspace;
|
||||
}
|
||||
|
||||
function summarize(name: string, result: string): string {
|
||||
if (name === "read_text_file") return `已读取 ${result.length} 个字符`;
|
||||
try {
|
||||
const value: unknown = JSON.parse(result);
|
||||
if (Array.isArray(value)) return `找到 ${value.length} 项`;
|
||||
} catch {}
|
||||
return "已完成";
|
||||
}
|
||||
186
packages/agent-core/src/use-cases/workspace-service.ts
Normal file
186
packages/agent-core/src/use-cases/workspace-service.ts
Normal file
@ -0,0 +1,186 @@
|
||||
import type { AttachmentRef, Conversation } from "../domain/conversation";
|
||||
import { CoreError } from "../errors/core-error";
|
||||
import type { ClockPort } from "../ports/system-ports";
|
||||
import type { WorkspacePort } from "../ports/workspace-port";
|
||||
import type { ConversationService } from "./conversation-service";
|
||||
import type { ProjectService } from "./project-service";
|
||||
|
||||
export type WorkspaceContext =
|
||||
| Readonly<{ kind: "ordinary" }>
|
||||
| Readonly<{ kind: "project"; projectId: string }>
|
||||
| Readonly<{ kind: "conversation"; conversationId: string }>;
|
||||
|
||||
export class WorkspaceService {
|
||||
constructor(
|
||||
private readonly files: WorkspacePort,
|
||||
private readonly conversations: ConversationService,
|
||||
private readonly projects: ProjectService,
|
||||
private readonly defaultRoot: string,
|
||||
private readonly clock: ClockPort,
|
||||
) {}
|
||||
|
||||
async list(context: WorkspaceContext, path = "") {
|
||||
return this.files.list(await this.resolveRoot(context), path);
|
||||
}
|
||||
|
||||
async search(context: WorkspaceContext, query: string) {
|
||||
const normalized = query.trim();
|
||||
if (!normalized)
|
||||
throw new CoreError("SEARCH_QUERY_EMPTY", "请输入搜索内容");
|
||||
return this.files.search(await this.resolveRoot(context), normalized);
|
||||
}
|
||||
|
||||
async read(context: WorkspaceContext, path: string) {
|
||||
return this.files.readText(await this.resolveRoot(context), path);
|
||||
}
|
||||
|
||||
async createAttachments(
|
||||
context: WorkspaceContext,
|
||||
paths: readonly string[],
|
||||
): Promise<readonly AttachmentRef[]> {
|
||||
const unique = [...new Set(paths)];
|
||||
if (unique.length > 10)
|
||||
throw new CoreError(
|
||||
"ATTACHMENT_LIMIT_EXCEEDED",
|
||||
"一次最多添加 10 个附件",
|
||||
);
|
||||
const root = await this.resolveRoot(context);
|
||||
return Promise.all(
|
||||
unique.map(async (path) => {
|
||||
const info = await this.files.inspect(root, path);
|
||||
return {
|
||||
name: info.name,
|
||||
path: info.path,
|
||||
mediaType: info.mediaType,
|
||||
status: info.readableAsText ? "available" : "unsupported",
|
||||
checkedAt: this.clock.now().toISOString(),
|
||||
...(info.readableAsText ? {} : { reason: "暂不支持读取该文件类型" }),
|
||||
} as const;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async refreshConversation(conversation: Conversation): Promise<Conversation> {
|
||||
if (!conversation.messages.some((message) => message.attachments.length))
|
||||
return conversation;
|
||||
let root: string;
|
||||
try {
|
||||
root = await this.resolveRoot({
|
||||
kind: "conversation",
|
||||
conversationId: conversation.id,
|
||||
});
|
||||
} catch (cause) {
|
||||
return mapAttachments(conversation, (attachment) => ({
|
||||
...attachment,
|
||||
...unavailable(cause, this.clock),
|
||||
}));
|
||||
}
|
||||
return mapAttachments(conversation, async (attachment) => {
|
||||
try {
|
||||
const info = await this.files.inspect(root, attachment.path);
|
||||
const { reason: _reason, ...current } = attachment;
|
||||
return {
|
||||
...current,
|
||||
mediaType: info.mediaType,
|
||||
status: info.readableAsText ? "available" : "unsupported",
|
||||
checkedAt: this.clock.now().toISOString(),
|
||||
...(info.readableAsText ? {} : { reason: "暂不支持读取该文件类型" }),
|
||||
};
|
||||
} catch (cause) {
|
||||
return { ...attachment, ...unavailable(cause, this.clock) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async executeTool(
|
||||
conversationId: string,
|
||||
name: string,
|
||||
rawArguments: string,
|
||||
): Promise<string> {
|
||||
const input = parseArguments(rawArguments);
|
||||
const context: WorkspaceContext = { kind: "conversation", conversationId };
|
||||
if (name === "list_directory")
|
||||
return JSON.stringify(
|
||||
await this.list(context, readString(input, "path", true)),
|
||||
);
|
||||
if (name === "search_files")
|
||||
return JSON.stringify(
|
||||
await this.search(context, readString(input, "query")),
|
||||
);
|
||||
if (name === "read_text_file")
|
||||
return await this.read(context, readString(input, "path"));
|
||||
throw new CoreError("TOOL_NOT_SUPPORTED", "模型请求了尚未支持的工具");
|
||||
}
|
||||
|
||||
private async resolveRoot(context: WorkspaceContext): Promise<string> {
|
||||
if (context.kind === "ordinary") return this.requireDefaultRoot();
|
||||
if (context.kind === "project")
|
||||
return (await this.projects.requireAvailable(context.projectId))
|
||||
.workspaceRoot;
|
||||
const conversation = await this.conversations.getConversation(
|
||||
context.conversationId,
|
||||
);
|
||||
if (!conversation.projectId) return this.requireDefaultRoot();
|
||||
return (await this.projects.requireAvailable(conversation.projectId))
|
||||
.workspaceRoot;
|
||||
}
|
||||
|
||||
private async requireDefaultRoot(): Promise<string> {
|
||||
try {
|
||||
await this.files.list(this.defaultRoot);
|
||||
return this.defaultRoot;
|
||||
} catch {
|
||||
throw new CoreError(
|
||||
"DEFAULT_WORKSPACE_UNAVAILABLE",
|
||||
"默认工作区当前不可用",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mapAttachments(
|
||||
conversation: Conversation,
|
||||
transform: (
|
||||
attachment: AttachmentRef,
|
||||
) => AttachmentRef | Promise<AttachmentRef>,
|
||||
): Promise<Conversation> {
|
||||
return {
|
||||
...conversation,
|
||||
messages: await Promise.all(
|
||||
conversation.messages.map(async (message) => ({
|
||||
...message,
|
||||
attachments: await Promise.all(message.attachments.map(transform)),
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function unavailable(cause: unknown, clock: ClockPort) {
|
||||
return {
|
||||
status: "unavailable" as const,
|
||||
checkedAt: clock.now().toISOString(),
|
||||
reason:
|
||||
cause instanceof CoreError ? cause.message : "文件已移动、删除或无法访问",
|
||||
};
|
||||
}
|
||||
|
||||
function parseArguments(value: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (parsed && typeof parsed === "object")
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {}
|
||||
throw new CoreError("TOOL_ARGUMENTS_INVALID", "文件工具参数格式无效");
|
||||
}
|
||||
|
||||
function readString(
|
||||
input: Record<string, unknown>,
|
||||
key: string,
|
||||
optional = false,
|
||||
): string {
|
||||
const value = input[key];
|
||||
if (optional && value === undefined) return "";
|
||||
if (typeof value !== "string")
|
||||
throw new CoreError("TOOL_ARGUMENTS_INVALID", `文件工具缺少 ${key} 参数`);
|
||||
return value;
|
||||
}
|
||||
44
packages/agent-core/src/use-cases/workspace-tools.ts
Normal file
44
packages/agent-core/src/use-cases/workspace-tools.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import type { ModelTool } from "../ports/model-port";
|
||||
|
||||
export const workspaceTools: readonly ModelTool[] = [
|
||||
{
|
||||
name: "list_directory",
|
||||
description:
|
||||
"列出当前会话工作区内某个目录的直接子项。路径必须是工作区相对路径。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "目录相对路径,根目录使用空字符串",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "search_files",
|
||||
description: "按文件名和 UTF-8 文本内容搜索当前会话工作区。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
required: ["query"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_text_file",
|
||||
description: "读取当前会话工作区内不超过 2 MiB 的 UTF-8 文本文件。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
required: ["path"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function isWorkspaceTool(name: string): boolean {
|
||||
return workspaceTools.some((tool) => tool.name === name);
|
||||
}
|
||||
@ -31,6 +31,7 @@ describe("FileConversationRepository", () => {
|
||||
id: "message_1",
|
||||
role: "user",
|
||||
content: "你好",
|
||||
attachments: [],
|
||||
createdAt: "2026-08-12T01:00:00.000Z",
|
||||
runId: "run_1",
|
||||
});
|
||||
|
||||
@ -31,9 +31,9 @@ export class FileConversationRepository implements ConversationRepository {
|
||||
|
||||
async getById(id: string): Promise<Conversation | null> {
|
||||
try {
|
||||
return JSON.parse(
|
||||
await readFile(this.pathFor(id), "utf8"),
|
||||
) as Conversation;
|
||||
return normalizeConversation(
|
||||
JSON.parse(await readFile(this.pathFor(id), "utf8")) as Conversation,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isFileNotFound(error)) return null;
|
||||
throw error;
|
||||
@ -82,14 +82,26 @@ export class FileConversationRepository implements ConversationRepository {
|
||||
names
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map(async (name) => {
|
||||
return JSON.parse(
|
||||
await readFile(join(this.layout.conversations, name), "utf8"),
|
||||
) as Conversation;
|
||||
return normalizeConversation(
|
||||
JSON.parse(
|
||||
await readFile(join(this.layout.conversations, name), "utf8"),
|
||||
) as Conversation,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConversation(conversation: Conversation): Conversation {
|
||||
return {
|
||||
...conversation,
|
||||
messages: conversation.messages.map((message) => ({
|
||||
...message,
|
||||
attachments: message.attachments ?? [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function isFileNotFound(error: unknown): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||
}
|
||||
|
||||
@ -1,7 +1,21 @@
|
||||
import { access, realpath, stat } from "node:fs/promises";
|
||||
import { access, readFile, readdir, realpath, stat } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { CoreError, type WorkspaceResolverPort } from "@great-agent/agent-core";
|
||||
import {
|
||||
basename,
|
||||
extname,
|
||||
isAbsolute,
|
||||
relative,
|
||||
resolve,
|
||||
sep,
|
||||
} from "node:path";
|
||||
import {
|
||||
CoreError,
|
||||
type WorkspaceEntry,
|
||||
type WorkspaceFileInfo,
|
||||
type WorkspacePort,
|
||||
type WorkspaceResolverPort,
|
||||
type WorkspaceSearchMatch,
|
||||
} from "@great-agent/agent-core";
|
||||
|
||||
export class LocalWorkspaceResolver implements WorkspaceResolverPort {
|
||||
async resolveForCreation(path: string): Promise<string> {
|
||||
@ -32,3 +46,172 @@ export class LocalWorkspaceResolver implements WorkspaceResolverPort {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_READ_BYTES = 2 * 1024 * 1024;
|
||||
const SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]);
|
||||
|
||||
export class LocalWorkspaceFiles implements WorkspacePort {
|
||||
async list(root: string, path = ""): Promise<readonly WorkspaceEntry[]> {
|
||||
const realRoot = await realpath(root);
|
||||
const target = await safeExistingPath(root, path);
|
||||
if (!(await stat(target)).isDirectory())
|
||||
throw new CoreError("WORKSPACE_NOT_DIRECTORY", "目标不是文件夹");
|
||||
const names = await readdir(target, { withFileTypes: true });
|
||||
return Promise.all(
|
||||
names
|
||||
.filter((entry) => !entry.isSymbolicLink())
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map(async (entry) => {
|
||||
const absolute = resolve(target, entry.name);
|
||||
const info = await stat(absolute);
|
||||
return {
|
||||
path: toRelative(realRoot, absolute),
|
||||
name: entry.name,
|
||||
kind: entry.isDirectory() ? "directory" : "file",
|
||||
...(entry.isFile() ? { size: info.size } : {}),
|
||||
} as const;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async search(
|
||||
root: string,
|
||||
query: string,
|
||||
): Promise<readonly WorkspaceSearchMatch[]> {
|
||||
const normalized = query.toLocaleLowerCase();
|
||||
const paths = await collectFiles(root);
|
||||
const matches: WorkspaceSearchMatch[] = [];
|
||||
for (const path of paths) {
|
||||
if (matches.length >= 200) break;
|
||||
if (basename(path).toLocaleLowerCase().includes(normalized))
|
||||
matches.push({ path, matchedBy: "name" });
|
||||
if (matches.length >= 200) break;
|
||||
try {
|
||||
const content = await this.readText(root, path);
|
||||
const lines = content.split("\n");
|
||||
let count = 0;
|
||||
for (let index = 0; index < lines.length && count < 20; index++) {
|
||||
if (!lines[index]?.toLocaleLowerCase().includes(normalized)) continue;
|
||||
matches.push({
|
||||
path,
|
||||
line: index + 1,
|
||||
preview: (lines[index] ?? "").trim().slice(0, 240),
|
||||
matchedBy: "content",
|
||||
});
|
||||
count++;
|
||||
if (matches.length >= 200) break;
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof CoreError)) throw cause;
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
async inspect(root: string, path: string): Promise<WorkspaceFileInfo> {
|
||||
const realRoot = await realpath(root);
|
||||
const target = await safeExistingPath(root, path);
|
||||
const info = await stat(target);
|
||||
if (!info.isFile())
|
||||
throw new CoreError("FILE_NOT_REGULAR", "目标不是普通文件");
|
||||
const mediaType = mediaTypeFor(path);
|
||||
return {
|
||||
path: toRelative(realRoot, target),
|
||||
name: basename(target),
|
||||
size: info.size,
|
||||
mediaType,
|
||||
readableAsText: info.size <= MAX_READ_BYTES && (await isText(target)),
|
||||
};
|
||||
}
|
||||
|
||||
async readText(root: string, path: string): Promise<string> {
|
||||
const info = await this.inspect(root, path);
|
||||
if (info.size > MAX_READ_BYTES)
|
||||
throw new CoreError("FILE_TOO_LARGE", "文件超过 2 MiB,暂不支持读取");
|
||||
if (!info.readableAsText)
|
||||
throw new CoreError(
|
||||
"FILE_TYPE_UNSUPPORTED",
|
||||
"该文件不是支持的 UTF-8 文本",
|
||||
);
|
||||
return readFile(await safeExistingPath(root, path), "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeExistingPath(root: string, input: string): Promise<string> {
|
||||
validateRelativePath(input);
|
||||
try {
|
||||
const realRoot = await realpath(root);
|
||||
const target = await realpath(resolve(realRoot, input || "."));
|
||||
if (!within(realRoot, target))
|
||||
throw new CoreError(
|
||||
"WORKSPACE_PATH_FORBIDDEN",
|
||||
"不能访问工作区之外的路径",
|
||||
);
|
||||
return target;
|
||||
} catch (cause) {
|
||||
if (cause instanceof CoreError) throw cause;
|
||||
throw new CoreError("FILE_UNAVAILABLE", "文件不存在或无法访问");
|
||||
}
|
||||
}
|
||||
|
||||
function validateRelativePath(path: string): void {
|
||||
if (path.includes("\0") || isAbsolute(path))
|
||||
throw new CoreError(
|
||||
"WORKSPACE_PATH_FORBIDDEN",
|
||||
"只能使用工作区内的相对路径",
|
||||
);
|
||||
if (path.split(/[\\/]/u).includes(".."))
|
||||
throw new CoreError("WORKSPACE_PATH_FORBIDDEN", "路径不能包含父目录跳转");
|
||||
}
|
||||
|
||||
function within(root: string, target: string): boolean {
|
||||
return target === root || target.startsWith(`${root}${sep}`);
|
||||
}
|
||||
|
||||
function toRelative(root: string, target: string): string {
|
||||
return relative(root, target).split(sep).join("/");
|
||||
}
|
||||
|
||||
async function collectFiles(root: string): Promise<string[]> {
|
||||
const realRoot = await safeExistingPath(root, "");
|
||||
const result: string[] = [];
|
||||
const visit = async (directory: string) => {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
const absolute = resolve(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIPPED_DIRECTORIES.has(entry.name)) await visit(absolute);
|
||||
} else if (entry.isFile()) result.push(toRelative(realRoot, absolute));
|
||||
if (result.length >= 5_000) return;
|
||||
}
|
||||
};
|
||||
await visit(realRoot);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function isText(path: string): Promise<boolean> {
|
||||
const buffer = await readFile(path);
|
||||
if (buffer.includes(0)) return false;
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function mediaTypeFor(path: string): string {
|
||||
const types: Record<string, string> = {
|
||||
".css": "text/css",
|
||||
".csv": "text/csv",
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".json": "application/json",
|
||||
".md": "text/markdown",
|
||||
".ts": "text/typescript",
|
||||
".txt": "text/plain",
|
||||
".yaml": "application/yaml",
|
||||
".yml": "application/yaml",
|
||||
};
|
||||
return types[extname(path).toLocaleLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { LocalWorkspaceResolver } from ".";
|
||||
import { LocalWorkspaceFiles, LocalWorkspaceResolver } from ".";
|
||||
|
||||
describe("LocalWorkspaceResolver", () => {
|
||||
test("只接受实际存在且可读写的绝对目录", async () => {
|
||||
@ -21,3 +21,70 @@ describe("LocalWorkspaceResolver", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalWorkspaceFiles", () => {
|
||||
test("列出、搜索并读取工作区文本", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "great-agent-files-"));
|
||||
try {
|
||||
await mkdir(join(root, "docs"));
|
||||
await writeFile(
|
||||
join(root, "docs", "说明.md"),
|
||||
"第一行\n需要搜索的内容\n",
|
||||
);
|
||||
const files = new LocalWorkspaceFiles();
|
||||
expect(await files.list(root)).toEqual([
|
||||
{ path: "docs", name: "docs", kind: "directory" },
|
||||
]);
|
||||
expect((await files.search(root, "搜索"))[0]).toMatchObject({
|
||||
path: "docs/说明.md",
|
||||
line: 2,
|
||||
matchedBy: "content",
|
||||
});
|
||||
expect(await files.readText(root, "docs/说明.md")).toContain("第一行");
|
||||
} finally {
|
||||
await rm(root, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("拒绝绝对路径、父目录跳转和符号链接逃逸", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "great-agent-safe-"));
|
||||
const outside = await mkdtemp(join(tmpdir(), "great-agent-outside-"));
|
||||
try {
|
||||
await writeFile(join(outside, "secret.txt"), "secret");
|
||||
await symlink(join(outside, "secret.txt"), join(root, "shortcut.txt"));
|
||||
const files = new LocalWorkspaceFiles();
|
||||
await expect(files.readText(root, "../secret.txt")).rejects.toMatchObject(
|
||||
{
|
||||
code: "WORKSPACE_PATH_FORBIDDEN",
|
||||
},
|
||||
);
|
||||
await expect(
|
||||
files.readText(root, join(outside, "secret.txt")),
|
||||
).rejects.toMatchObject({
|
||||
code: "WORKSPACE_PATH_FORBIDDEN",
|
||||
});
|
||||
await expect(files.readText(root, "shortcut.txt")).rejects.toMatchObject({
|
||||
code: "WORKSPACE_PATH_FORBIDDEN",
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true });
|
||||
await rm(outside, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("二进制文件返回明确的不支持状态", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "great-agent-binary-"));
|
||||
try {
|
||||
await writeFile(join(root, "image.bin"), new Uint8Array([1, 0, 2]));
|
||||
const files = new LocalWorkspaceFiles();
|
||||
expect((await files.inspect(root, "image.bin")).readableAsText).toBe(
|
||||
false,
|
||||
);
|
||||
await expect(files.readText(root, "image.bin")).rejects.toMatchObject({
|
||||
code: "FILE_TYPE_UNSUPPORTED",
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -94,33 +94,40 @@ function toModelMessages(
|
||||
request: ModelRequest,
|
||||
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
|
||||
const messages = request.messages.map(toModelMessage);
|
||||
if (!request.continuation) return messages;
|
||||
return [
|
||||
...messages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: request.continuation.toolCallId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: request.continuation.toolName,
|
||||
arguments: request.continuation.toolArguments,
|
||||
for (const continuation of request.continuations ?? []) {
|
||||
messages.push(
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: continuation.toolCallId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: continuation.toolName,
|
||||
arguments: continuation.toolArguments,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
tool_call_id: request.continuation.toolCallId,
|
||||
content: request.continuation.result,
|
||||
},
|
||||
];
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
tool_call_id: continuation.toolCallId,
|
||||
content: continuation.result,
|
||||
},
|
||||
);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
function toModelMessage(
|
||||
message: Message,
|
||||
): OpenAI.Chat.Completions.ChatCompletionMessageParam {
|
||||
return { role: message.role, content: message.content };
|
||||
const attachments = message.attachments
|
||||
.map((attachment) => `- ${attachment.path}(${attachment.status})`)
|
||||
.join("\n");
|
||||
const content = attachments
|
||||
? `${message.content}${message.content ? "\n\n" : ""}[附件]\n${attachments}`
|
||||
: message.content;
|
||||
return { role: message.role, content };
|
||||
}
|
||||
|
||||
@ -12,6 +12,9 @@ export const runEventSchema = z.object({
|
||||
"interaction.requested",
|
||||
"interaction.resolved",
|
||||
"interaction.cancelled",
|
||||
"tool.started",
|
||||
"tool.completed",
|
||||
"tool.failed",
|
||||
"run.completed",
|
||||
"run.failed",
|
||||
"run.cancelled",
|
||||
|
||||
@ -30,3 +30,9 @@ export {
|
||||
type InteractionResponse,
|
||||
type ResolvedInteractionResponse,
|
||||
} from "./responses/interaction";
|
||||
export {
|
||||
workspaceEntrySchema,
|
||||
workspaceSearchMatchSchema,
|
||||
type WorkspaceEntryResponse,
|
||||
type WorkspaceSearchMatchResponse,
|
||||
} from "./responses/workspace";
|
||||
|
||||
@ -1,20 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const startRunRequestSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("project"),
|
||||
projectId: z.string().min(1),
|
||||
message: z.string().trim().min(1).max(32_000),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("ordinary"),
|
||||
message: z.string().trim().min(1).max(32_000),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("existing"),
|
||||
conversationId: z.string().min(1),
|
||||
message: z.string().trim().min(1).max(32_000),
|
||||
}),
|
||||
]);
|
||||
export const startRunRequestSchema = z
|
||||
.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("project"),
|
||||
projectId: z.string().min(1),
|
||||
message: z.string().trim().max(32_000),
|
||||
attachments: z.array(z.string().min(1)).max(10).default([]),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("ordinary"),
|
||||
message: z.string().trim().max(32_000),
|
||||
attachments: z.array(z.string().min(1)).max(10).default([]),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("existing"),
|
||||
conversationId: z.string().min(1),
|
||||
message: z.string().trim().max(32_000),
|
||||
attachments: z.array(z.string().min(1)).max(10).default([]),
|
||||
}),
|
||||
])
|
||||
.superRefine((value, context) => {
|
||||
if (!value.message && value.attachments.length === 0)
|
||||
context.addIssue({ code: "custom", message: "请输入消息内容或添加附件" });
|
||||
});
|
||||
|
||||
export type StartRunRequest = z.infer<typeof startRunRequestSchema>;
|
||||
|
||||
@ -4,6 +4,16 @@ export const messageSchema = z.object({
|
||||
id: z.string(),
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.string(),
|
||||
attachments: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
mediaType: z.string(),
|
||||
status: z.enum(["available", "unavailable", "unsupported"]),
|
||||
checkedAt: z.string(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
createdAt: z.string(),
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
20
packages/web-contracts/src/responses/workspace.ts
Normal file
20
packages/web-contracts/src/responses/workspace.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const workspaceEntrySchema = z.object({
|
||||
path: z.string(),
|
||||
name: z.string(),
|
||||
kind: z.enum(["file", "directory"]),
|
||||
size: z.number().optional(),
|
||||
});
|
||||
|
||||
export const workspaceSearchMatchSchema = z.object({
|
||||
path: z.string(),
|
||||
line: z.number().optional(),
|
||||
preview: z.string().optional(),
|
||||
matchedBy: z.enum(["name", "content"]),
|
||||
});
|
||||
|
||||
export type WorkspaceEntryResponse = z.infer<typeof workspaceEntrySchema>;
|
||||
export type WorkspaceSearchMatchResponse = z.infer<
|
||||
typeof workspaceSearchMatchSchema
|
||||
>;
|
||||
Loading…
x
Reference in New Issue
Block a user