feat: 工作区附件与文件读取搜索
This commit is contained in:
parent
4c3b2e48e7
commit
944a09ce2b
@ -41,10 +41,10 @@
|
|||||||
状态: "已完成"
|
状态: "已完成"
|
||||||
- 编号: "F-006"
|
- 编号: "F-006"
|
||||||
名称: "附件、文件列表、读取与搜索"
|
名称: "附件、文件列表、读取与搜索"
|
||||||
状态: "进行中"
|
状态: "已完成"
|
||||||
- 编号: "F-007"
|
- 编号: "F-007"
|
||||||
名称: "文件创建与安全修改"
|
名称: "文件创建与安全修改"
|
||||||
状态: "待开始"
|
状态: "进行中"
|
||||||
- 编号: "F-008"
|
- 编号: "F-008"
|
||||||
名称: "会话管理与个人设置"
|
名称: "会话管理与个人设置"
|
||||||
状态: "待开始"
|
状态: "待开始"
|
||||||
|
|||||||
@ -9,11 +9,13 @@ import type {
|
|||||||
ConversationService,
|
ConversationService,
|
||||||
ProjectService,
|
ProjectService,
|
||||||
InteractionService,
|
InteractionService,
|
||||||
|
WorkspaceService,
|
||||||
} from "@great-agent/agent-core";
|
} from "@great-agent/agent-core";
|
||||||
import type { RunRegistry } from "./run-registry";
|
import type { RunRegistry } from "./run-registry";
|
||||||
import { createRunRoutes } from "../routes/runs";
|
import { createRunRoutes } from "../routes/runs";
|
||||||
import { createProjectRoutes } from "../routes/projects";
|
import { createProjectRoutes } from "../routes/projects";
|
||||||
import { createInteractionRoutes } from "../routes/interactions";
|
import { createInteractionRoutes } from "../routes/interactions";
|
||||||
|
import { createWorkspaceRoutes } from "../routes/workspace";
|
||||||
|
|
||||||
export function createApp(
|
export function createApp(
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
@ -22,17 +24,20 @@ export function createApp(
|
|||||||
runRegistry?: RunRegistry,
|
runRegistry?: RunRegistry,
|
||||||
projects?: ProjectService,
|
projects?: ProjectService,
|
||||||
interactions?: InteractionService,
|
interactions?: InteractionService,
|
||||||
|
workspace?: WorkspaceService,
|
||||||
): Hono {
|
): Hono {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
app.use("*", requestId);
|
app.use("*", requestId);
|
||||||
app.onError(createErrorHandler(logger));
|
app.onError(createErrorHandler(logger));
|
||||||
app.route("/api", createHealthRoutes());
|
app.route("/api", createHealthRoutes());
|
||||||
if (conversations) app.route("/api", createConversationRoutes(conversations));
|
if (conversations)
|
||||||
|
app.route("/api", createConversationRoutes(conversations, workspace));
|
||||||
if (runs && runRegistry)
|
if (runs && runRegistry)
|
||||||
app.route("/api", createRunRoutes(runs, runRegistry));
|
app.route("/api", createRunRoutes(runs, runRegistry));
|
||||||
if (projects && conversations)
|
if (projects && conversations)
|
||||||
app.route("/api", createProjectRoutes(projects, conversations));
|
app.route("/api", createProjectRoutes(projects, conversations));
|
||||||
if (interactions && runs && runRegistry)
|
if (interactions && runs && runRegistry)
|
||||||
app.route("/api", createInteractionRoutes(interactions, runs, runRegistry));
|
app.route("/api", createInteractionRoutes(interactions, runs, runRegistry));
|
||||||
|
if (workspace) app.route("/api", createWorkspaceRoutes(workspace));
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
InteractionService,
|
InteractionService,
|
||||||
CoreError,
|
CoreError,
|
||||||
type ModelPort,
|
type ModelPort,
|
||||||
|
WorkspaceService,
|
||||||
} from "@great-agent/agent-core";
|
} from "@great-agent/agent-core";
|
||||||
import { DeepSeekModelAdapter } from "@great-agent/model-deepseek";
|
import { DeepSeekModelAdapter } from "@great-agent/model-deepseek";
|
||||||
import { createApp } from "./composition/create-app";
|
import { createApp } from "./composition/create-app";
|
||||||
@ -21,7 +22,10 @@ import { createLogger } from "./composition/create-logger";
|
|||||||
import { mountStaticWeb } from "./composition/static-web";
|
import { mountStaticWeb } from "./composition/static-web";
|
||||||
import { loadEnvironment } from "./config/environment";
|
import { loadEnvironment } from "./config/environment";
|
||||||
import { RunRegistry } from "./composition/run-registry";
|
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 environment = loadEnvironment();
|
||||||
const logger = createLogger(environment.logLevel);
|
const logger = createLogger(environment.logLevel);
|
||||||
@ -51,6 +55,13 @@ const interactions = new InteractionService({
|
|||||||
clock,
|
clock,
|
||||||
ids,
|
ids,
|
||||||
});
|
});
|
||||||
|
const workspace = new WorkspaceService(
|
||||||
|
new LocalWorkspaceFiles(),
|
||||||
|
conversations,
|
||||||
|
projects,
|
||||||
|
environment.defaultWorkspaceRoot,
|
||||||
|
clock,
|
||||||
|
);
|
||||||
const model: ModelPort = environment.deepSeekApiKey
|
const model: ModelPort = environment.deepSeekApiKey
|
||||||
? new DeepSeekModelAdapter({
|
? new DeepSeekModelAdapter({
|
||||||
apiKey: environment.deepSeekApiKey,
|
apiKey: environment.deepSeekApiKey,
|
||||||
@ -75,6 +86,7 @@ const runs = new AgentRunService(
|
|||||||
ids,
|
ids,
|
||||||
projects,
|
projects,
|
||||||
interactions,
|
interactions,
|
||||||
|
workspace,
|
||||||
);
|
);
|
||||||
const app = createApp(
|
const app = createApp(
|
||||||
logger,
|
logger,
|
||||||
@ -83,6 +95,7 @@ const app = createApp(
|
|||||||
new RunRegistry(),
|
new RunRegistry(),
|
||||||
projects,
|
projects,
|
||||||
interactions,
|
interactions,
|
||||||
|
workspace,
|
||||||
);
|
);
|
||||||
mountStaticWeb(app, resolve(import.meta.dir, "../../web/dist"));
|
mountStaticWeb(app, resolve(import.meta.dir, "../../web/dist"));
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,24 @@
|
|||||||
import { Hono } from "hono";
|
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();
|
const routes = new Hono();
|
||||||
routes.get("/conversations", async (context) =>
|
routes.get("/conversations", async (context) =>
|
||||||
context.json(await service.listRecent()),
|
context.json(await service.listRecent()),
|
||||||
);
|
);
|
||||||
routes.get("/conversations/:id", async (context) =>
|
routes.get("/conversations/:id", async (context) => {
|
||||||
context.json(await service.getConversation(context.req.param("id"))),
|
const conversation = await service.getConversation(context.req.param("id"));
|
||||||
);
|
return context.json(
|
||||||
|
workspace
|
||||||
|
? await workspace.refreshConversation(conversation)
|
||||||
|
: conversation,
|
||||||
|
);
|
||||||
|
});
|
||||||
return routes;
|
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";
|
import { request } from "./request";
|
||||||
|
|
||||||
export type StartRunInput =
|
export type StartRunInput =
|
||||||
| { kind: "ordinary"; message: string }
|
| { kind: "ordinary"; message: string; attachments: readonly string[] }
|
||||||
| { kind: "project"; projectId: string; message: string }
|
| {
|
||||||
| { kind: "existing"; conversationId: string; message: string };
|
kind: "project";
|
||||||
|
projectId: string;
|
||||||
|
message: string;
|
||||||
|
attachments: readonly string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "existing";
|
||||||
|
conversationId: string;
|
||||||
|
message: string;
|
||||||
|
attachments: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
export async function startRun(
|
export async function startRun(
|
||||||
input: StartRunInput,
|
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>;
|
sending: State<boolean>;
|
||||||
assistantDraft: State<string>;
|
assistantDraft: State<string>;
|
||||||
error: State<string>;
|
error: State<string>;
|
||||||
|
toolActivities: State<ToolActivity[]>;
|
||||||
initialize(): Promise<void>;
|
initialize(): Promise<void>;
|
||||||
startNewTask(): void;
|
startNewTask(): void;
|
||||||
startProjectCreation(): void;
|
startProjectCreation(): void;
|
||||||
@ -41,7 +42,7 @@ export type AppController = Readonly<{
|
|||||||
startProjectDelete(id: string): void;
|
startProjectDelete(id: string): void;
|
||||||
deleteProject(id: string): Promise<void>;
|
deleteProject(id: string): Promise<void>;
|
||||||
openConversation(id: string): Promise<void>;
|
openConversation(id: string): Promise<void>;
|
||||||
send(content: string): Promise<boolean>;
|
send(content: string, attachments?: readonly string[]): Promise<boolean>;
|
||||||
answerInteraction(
|
answerInteraction(
|
||||||
id: string,
|
id: string,
|
||||||
answer: InteractionAnswerRequest,
|
answer: InteractionAnswerRequest,
|
||||||
@ -50,3 +51,10 @@ export type AppController = Readonly<{
|
|||||||
stopGenerating(): Promise<void>;
|
stopGenerating(): Promise<void>;
|
||||||
retryRun(runId: string): 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,
|
answerInteraction as submitInteractionAnswer,
|
||||||
listInteractions,
|
listInteractions,
|
||||||
} from "../api/interactions";
|
} 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 type { AppController, Selection } from "./app-controller-types";
|
||||||
|
|
||||||
export function createAppController(): AppController {
|
export function createAppController(): AppController {
|
||||||
@ -47,10 +52,12 @@ export function createAppController(): AppController {
|
|||||||
const sending = van.state(false);
|
const sending = van.state(false);
|
||||||
const assistantDraft = van.state("");
|
const assistantDraft = van.state("");
|
||||||
const error = van.state("");
|
const error = van.state("");
|
||||||
|
const toolActivities = van.state<ToolActivity[]>([]);
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
loading.val = true;
|
loading.val = true;
|
||||||
error.val = "";
|
error.val = "";
|
||||||
|
toolActivities.val = [];
|
||||||
try {
|
try {
|
||||||
[recent.val, projects.val] = await Promise.all([
|
[recent.val, projects.val] = await Promise.all([
|
||||||
listConversations(),
|
listConversations(),
|
||||||
@ -140,12 +147,14 @@ export function createAppController(): AppController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function send(content: string) {
|
async function send(content: string, attachments: readonly string[] = []) {
|
||||||
if (!content.trim() || sending.val) return false;
|
if ((!content.trim() && attachments.length === 0) || sending.val)
|
||||||
|
return false;
|
||||||
let accepted = false;
|
let accepted = false;
|
||||||
sending.val = true;
|
sending.val = true;
|
||||||
assistantDraft.val = "";
|
assistantDraft.val = "";
|
||||||
error.val = "";
|
error.val = "";
|
||||||
|
toolActivities.val = [];
|
||||||
try {
|
try {
|
||||||
const current = selection.val;
|
const current = selection.val;
|
||||||
const input =
|
const input =
|
||||||
@ -154,14 +163,16 @@ export function createAppController(): AppController {
|
|||||||
kind: "existing" as const,
|
kind: "existing" as const,
|
||||||
conversationId: current.id,
|
conversationId: current.id,
|
||||||
message: content,
|
message: content,
|
||||||
|
attachments,
|
||||||
}
|
}
|
||||||
: current.kind === "project-draft"
|
: current.kind === "project-draft"
|
||||||
? {
|
? {
|
||||||
kind: "project" as const,
|
kind: "project" as const,
|
||||||
projectId: current.projectId,
|
projectId: current.projectId,
|
||||||
message: content,
|
message: content,
|
||||||
|
attachments,
|
||||||
}
|
}
|
||||||
: { kind: "ordinary" as const, message: content };
|
: { kind: "ordinary" as const, message: content, attachments };
|
||||||
const started = await startRun(input);
|
const started = await startRun(input);
|
||||||
accepted = true;
|
accepted = true;
|
||||||
activeRunId.val = started.runId;
|
activeRunId.val = started.runId;
|
||||||
@ -279,24 +290,7 @@ export function createAppController(): AppController {
|
|||||||
type: string;
|
type: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}) {
|
}) {
|
||||||
if (
|
collectRunEvent({ assistantDraft, currentRun, toolActivities }, event);
|
||||||
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" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshActive(conversationId: string) {
|
async function refreshActive(conversationId: string) {
|
||||||
@ -345,6 +339,7 @@ export function createAppController(): AppController {
|
|||||||
sending,
|
sending,
|
||||||
assistantDraft,
|
assistantDraft,
|
||||||
error,
|
error,
|
||||||
|
toolActivities,
|
||||||
initialize,
|
initialize,
|
||||||
startNewTask,
|
startNewTask,
|
||||||
startProjectCreation,
|
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;
|
line-height: 1.55;
|
||||||
white-space: pre-wrap;
|
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 {
|
.composer-wrap {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
@ -4,16 +4,25 @@ import "./conversation-pane.css";
|
|||||||
import { ProjectPanel } from "../projects/project-panel";
|
import { ProjectPanel } from "../projects/project-panel";
|
||||||
import { InteractionCard } from "../interactions/interaction-card";
|
import { InteractionCard } from "../interactions/interaction-card";
|
||||||
import { RunStatus } from "../runs/run-status";
|
import { RunStatus } from "../runs/run-status";
|
||||||
|
import { FilePicker } from "../files/file-picker";
|
||||||
|
|
||||||
const { article, button, div, h1, header, main, p, span, textarea } = van.tags;
|
const { article, button, div, h1, header, main, p, span, textarea } = van.tags;
|
||||||
|
|
||||||
export function ConversationPane(controller: AppController): HTMLElement {
|
export function ConversationPane(controller: AppController): HTMLElement {
|
||||||
const draft = van.state("");
|
const draft = van.state("");
|
||||||
|
const attachments = van.state<string[]>([]);
|
||||||
|
const attachmentContext = van.state("");
|
||||||
|
const pickerOpen = van.state(false);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const content = draft.val;
|
const content = draft.val;
|
||||||
if (!content.trim()) return;
|
const selected =
|
||||||
if (await controller.send(content)) draft.val = "";
|
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(
|
return main(
|
||||||
@ -39,6 +48,26 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
|||||||
message.role === "user" ? "你" : "G",
|
message.role === "user" ? "你" : "G",
|
||||||
),
|
),
|
||||||
message.content ? p(message.content) : div(),
|
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
|
controller.interactions.val
|
||||||
.filter(
|
.filter(
|
||||||
@ -57,6 +86,24 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
|||||||
p(controller.assistantDraft),
|
p(controller.assistantDraft),
|
||||||
)
|
)
|
||||||
: null,
|
: 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),
|
() => RunStatus(controller),
|
||||||
)
|
)
|
||||||
@ -68,7 +115,21 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
|||||||
),
|
),
|
||||||
div(
|
div(
|
||||||
{ class: "composer-slot", hidden: () => !canCompose(controller) },
|
{ 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(
|
function Composer(
|
||||||
controller: AppController,
|
controller: AppController,
|
||||||
draft: State<string>,
|
draft: State<string>,
|
||||||
|
attachments: State<string[]>,
|
||||||
|
attachmentContext: State<string>,
|
||||||
|
pickerOpen: State<boolean>,
|
||||||
submit: () => Promise<void>,
|
submit: () => Promise<void>,
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
return div(
|
return div(
|
||||||
@ -90,6 +154,30 @@ function Composer(
|
|||||||
),
|
),
|
||||||
div(
|
div(
|
||||||
{ class: "composer" },
|
{ 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({
|
textarea({
|
||||||
"aria-label": "输入消息",
|
"aria-label": "输入消息",
|
||||||
placeholder: "我能帮你做些什么?",
|
placeholder: "我能帮你做些什么?",
|
||||||
@ -108,13 +196,34 @@ function Composer(
|
|||||||
div(
|
div(
|
||||||
{ class: "composer-actions" },
|
{ class: "composer-actions" },
|
||||||
button(
|
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(
|
button(
|
||||||
{
|
{
|
||||||
class: "send",
|
class: "send",
|
||||||
disabled: () => !controller.sending.val && !draft.val.trim(),
|
disabled: () =>
|
||||||
|
!controller.sending.val &&
|
||||||
|
!draft.val.trim() &&
|
||||||
|
!(
|
||||||
|
attachmentContext.val === contextKey(controller) &&
|
||||||
|
attachments.val.length
|
||||||
|
),
|
||||||
onclick: () =>
|
onclick: () =>
|
||||||
controller.sending.val
|
controller.sending.val
|
||||||
? void controller.stopGenerating()
|
? 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 {
|
function isProjectConversation(controller: AppController): boolean {
|
||||||
const selection = controller.selection.val;
|
const selection = controller.selection.val;
|
||||||
return (
|
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——附件、文件列表、读取与搜索
|
### F-006——附件、文件列表、读取与搜索
|
||||||
|
|
||||||
- 状态:进行中
|
- 状态:已完成(Agent 于 2026-08-14 验证通过,等待最终统一人工审核)
|
||||||
- 用户可见结果:附加工作区文件并让 Agent 安全读取和搜索。
|
- 用户可见结果:附加工作区文件并让 Agent 安全读取和搜索。
|
||||||
- 主要验收:AC-007 至 AC-009、AC-021、AC-022、AC-028、AC-030。
|
- 主要验收: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——文件创建与安全修改
|
### F-007——文件创建与安全修改
|
||||||
|
|
||||||
- 状态:待开始
|
- 状态:进行中
|
||||||
- 用户可见结果:Agent 在正确工作区内创建和安全修改文本文件。
|
- 用户可见结果:Agent 在正确工作区内创建和安全修改文本文件。
|
||||||
- 主要验收:AC-008、AC-009、AC-023、AC-028。
|
- 主要验收:AC-008、AC-009、AC-023、AC-028。
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,9 @@ export type RunEvent = Readonly<{
|
|||||||
| "interaction.requested"
|
| "interaction.requested"
|
||||||
| "interaction.resolved"
|
| "interaction.resolved"
|
||||||
| "interaction.cancelled"
|
| "interaction.cancelled"
|
||||||
|
| "tool.started"
|
||||||
|
| "tool.completed"
|
||||||
|
| "tool.failed"
|
||||||
| "run.completed"
|
| "run.completed"
|
||||||
| "run.failed"
|
| "run.failed"
|
||||||
| "run.cancelled";
|
| "run.cancelled";
|
||||||
|
|||||||
@ -1,9 +1,21 @@
|
|||||||
export type MessageRole = "user" | "assistant";
|
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<{
|
export type Message = Readonly<{
|
||||||
id: string;
|
id: string;
|
||||||
role: MessageRole;
|
role: MessageRole;
|
||||||
content: string;
|
content: string;
|
||||||
|
attachments: readonly AttachmentRef[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
runId: string;
|
runId: string;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@ -4,6 +4,8 @@ export type {
|
|||||||
ConversationSummary,
|
ConversationSummary,
|
||||||
Message,
|
Message,
|
||||||
MessageRole,
|
MessageRole,
|
||||||
|
AttachmentRef,
|
||||||
|
AttachmentStatus,
|
||||||
} from "./domain/conversation";
|
} from "./domain/conversation";
|
||||||
export type { AgentRun, AgentRunStatus, RunEvent } from "./domain/agent-run";
|
export type { AgentRun, AgentRunStatus, RunEvent } from "./domain/agent-run";
|
||||||
export type { Project, ProjectView } from "./domain/project";
|
export type { Project, ProjectView } from "./domain/project";
|
||||||
@ -29,15 +31,22 @@ export type {
|
|||||||
IdPort,
|
IdPort,
|
||||||
WorkspaceResolverPort,
|
WorkspaceResolverPort,
|
||||||
} from "./ports/system-ports";
|
} from "./ports/system-ports";
|
||||||
|
export type {
|
||||||
|
WorkspaceEntry,
|
||||||
|
WorkspaceFileInfo,
|
||||||
|
WorkspacePort,
|
||||||
|
WorkspaceSearchMatch,
|
||||||
|
} from "./ports/workspace-port";
|
||||||
|
export {
|
||||||
|
WorkspaceService,
|
||||||
|
type WorkspaceContext,
|
||||||
|
} from "./use-cases/workspace-service";
|
||||||
export {
|
export {
|
||||||
ConversationService,
|
ConversationService,
|
||||||
type ConversationServiceDependencies,
|
type ConversationServiceDependencies,
|
||||||
} from "./use-cases/conversation-service";
|
} from "./use-cases/conversation-service";
|
||||||
export {
|
export { AgentRunService } from "./use-cases/agent-run-service";
|
||||||
AgentRunService,
|
export type { StartedRun, StartRunInput } from "./use-cases/run-types";
|
||||||
type StartedRun,
|
|
||||||
type StartRunInput,
|
|
||||||
} from "./use-cases/agent-run-service";
|
|
||||||
export {
|
export {
|
||||||
ProjectService,
|
ProjectService,
|
||||||
type ProjectServiceDependencies,
|
type ProjectServiceDependencies,
|
||||||
|
|||||||
@ -4,12 +4,12 @@ export type ModelRequest = Readonly<{
|
|||||||
model: string;
|
model: string;
|
||||||
messages: readonly Message[];
|
messages: readonly Message[];
|
||||||
tools?: readonly ModelTool[];
|
tools?: readonly ModelTool[];
|
||||||
continuation?: Readonly<{
|
continuations?: readonly Readonly<{
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
toolArguments: string;
|
toolArguments: string;
|
||||||
result: string;
|
result: string;
|
||||||
}>;
|
}>[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type ModelTool = Readonly<{
|
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);
|
).toHaveLength(1);
|
||||||
expect(conversation.messages.at(-1)?.content).toBe("重试成功");
|
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) {
|
function createFixture(model: ModelPort) {
|
||||||
@ -292,5 +336,12 @@ function createFixture(model: ModelPort) {
|
|||||||
projects,
|
projects,
|
||||||
interactions,
|
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 { AgentRun, RunEvent } from "../domain/agent-run";
|
||||||
import type { Conversation } from "../domain/conversation";
|
|
||||||
import type { UserInteraction } from "../domain/user-interaction";
|
import type { UserInteraction } from "../domain/user-interaction";
|
||||||
import { CoreError } from "../errors/core-error";
|
import { CoreError } from "../errors/core-error";
|
||||||
import type { ModelPort, ModelRequest } from "../ports/model-port";
|
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 { ConversationService } from "./conversation-service";
|
||||||
import type { InteractionService } from "./interaction-service";
|
import type { InteractionService } from "./interaction-service";
|
||||||
import type { ProjectService } from "./project-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 { interactionTool } from "./interaction-tool";
|
||||||
import {
|
import {
|
||||||
|
ensureNoActiveRun,
|
||||||
lastSequence,
|
lastSequence,
|
||||||
messagesThroughTrigger,
|
messagesThroughTrigger,
|
||||||
|
persistRunEvent,
|
||||||
|
requireRun,
|
||||||
safeErrorMessage,
|
safeErrorMessage,
|
||||||
} from "./run-service-helpers";
|
} from "./run-service-helpers";
|
||||||
import type { StartedRun, StartRunInput } from "./run-types";
|
import type { StartedRun, StartRunInput } from "./run-types";
|
||||||
export type { StartedRun, StartRunInput } from "./run-types";
|
import { executeWorkspaceTool, prepareRunConversation } from "./run-workspace";
|
||||||
|
|
||||||
export class AgentRunService {
|
export class AgentRunService {
|
||||||
private readonly controllers = new Map<string, AbortController>();
|
private readonly controllers = new Map<string, AbortController>();
|
||||||
@ -29,13 +33,20 @@ export class AgentRunService {
|
|||||||
private readonly ids: IdPort,
|
private readonly ids: IdPort,
|
||||||
private readonly projects: ProjectService,
|
private readonly projects: ProjectService,
|
||||||
private readonly interactions: InteractionService,
|
private readonly interactions: InteractionService,
|
||||||
|
private readonly workspace?: WorkspaceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async start(input: StartRunInput, signal: AbortSignal): Promise<StartedRun> {
|
async start(input: StartRunInput, signal: AbortSignal): Promise<StartedRun> {
|
||||||
return this.withStartLock(async () => {
|
return this.withStartLock(async () => {
|
||||||
await this.ensureNoActiveRun();
|
await ensureNoActiveRun(this.runs);
|
||||||
const runId = this.ids.create();
|
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 timestamp = this.clock.now().toISOString();
|
||||||
const triggerMessage = conversation.messages.at(-1);
|
const triggerMessage = conversation.messages.at(-1);
|
||||||
if (triggerMessage?.role !== "user")
|
if (triggerMessage?.role !== "user")
|
||||||
@ -60,7 +71,7 @@ export class AgentRunService {
|
|||||||
interaction: UserInteraction,
|
interaction: UserInteraction,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<StartedRun> {
|
): Promise<StartedRun> {
|
||||||
const current = await this.requireRun(interaction.runId);
|
const current = await requireRun(this.runs, interaction.runId);
|
||||||
if (current.status !== "waiting_user")
|
if (current.status !== "waiting_user")
|
||||||
throw new CoreError("RUN_NOT_WAITING", "该任务当前没有等待用户回答");
|
throw new CoreError("RUN_NOT_WAITING", "该任务当前没有等待用户回答");
|
||||||
if (!interaction.answer)
|
if (!interaction.answer)
|
||||||
@ -72,17 +83,17 @@ export class AgentRunService {
|
|||||||
};
|
};
|
||||||
await this.runs.update(run);
|
await this.runs.update(run);
|
||||||
const sequence = lastSequence(await this.runs.listEvents(run.id));
|
const sequence = lastSequence(await this.runs.listEvents(run.id));
|
||||||
const continuation: NonNullable<ModelRequest["continuation"]> = {
|
const continuation: NonNullable<ModelRequest["continuations"]>[number] = {
|
||||||
toolCallId: interaction.toolCallId,
|
toolCallId: interaction.toolCallId,
|
||||||
toolName: "request_user_interaction",
|
toolName: "request_user_interaction",
|
||||||
toolArguments: interaction.toolArguments,
|
toolArguments: interaction.toolArguments,
|
||||||
result: JSON.stringify(interaction.answer),
|
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[]> {
|
async cancel(runId: string): Promise<readonly RunEvent[]> {
|
||||||
const run = await this.requireRun(runId);
|
const run = await requireRun(this.runs, runId);
|
||||||
if (
|
if (
|
||||||
run.status === "cancelled" ||
|
run.status === "cancelled" ||
|
||||||
run.status === "completed" ||
|
run.status === "completed" ||
|
||||||
@ -103,7 +114,9 @@ export class AgentRunService {
|
|||||||
if (!interaction)
|
if (!interaction)
|
||||||
throw new CoreError("INTERACTION_NOT_FOUND", "等待中的交互请求不存在");
|
throw new CoreError("INTERACTION_NOT_FOUND", "等待中的交互请求不存在");
|
||||||
let sequence = lastSequence(await this.runs.listEvents(run.id));
|
let sequence = lastSequence(await this.runs.listEvents(run.id));
|
||||||
const cancelled = await this.persistEvent(
|
const cancelled = await persistRunEvent(
|
||||||
|
this.runs,
|
||||||
|
this.clock,
|
||||||
run.id,
|
run.id,
|
||||||
++sequence,
|
++sequence,
|
||||||
"interaction.cancelled",
|
"interaction.cancelled",
|
||||||
@ -114,7 +127,9 @@ export class AgentRunService {
|
|||||||
status: "cancelled",
|
status: "cancelled",
|
||||||
updatedAt: this.clock.now().toISOString(),
|
updatedAt: this.clock.now().toISOString(),
|
||||||
});
|
});
|
||||||
const finished = await this.persistEvent(
|
const finished = await persistRunEvent(
|
||||||
|
this.runs,
|
||||||
|
this.clock,
|
||||||
run.id,
|
run.id,
|
||||||
++sequence,
|
++sequence,
|
||||||
"run.cancelled",
|
"run.cancelled",
|
||||||
@ -125,8 +140,8 @@ export class AgentRunService {
|
|||||||
|
|
||||||
async retry(runId: string, signal: AbortSignal): Promise<StartedRun> {
|
async retry(runId: string, signal: AbortSignal): Promise<StartedRun> {
|
||||||
return this.withStartLock(async () => {
|
return this.withStartLock(async () => {
|
||||||
await this.ensureNoActiveRun();
|
await ensureNoActiveRun(this.runs);
|
||||||
const original = await this.requireRun(runId);
|
const original = await requireRun(this.runs, runId);
|
||||||
if (original.status !== "failed" && original.status !== "cancelled")
|
if (original.status !== "failed" && original.status !== "cancelled")
|
||||||
throw new CoreError(
|
throw new CoreError(
|
||||||
"RUN_NOT_RETRYABLE",
|
"RUN_NOT_RETRYABLE",
|
||||||
@ -156,19 +171,19 @@ export class AgentRunService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async lastSequence(runId: string): Promise<number> {
|
async lastSequence(runId: string): Promise<number> {
|
||||||
await this.requireRun(runId);
|
await requireRun(this.runs, runId);
|
||||||
return lastSequence(await this.runs.listEvents(runId));
|
return lastSequence(await this.runs.listEvents(runId));
|
||||||
}
|
}
|
||||||
|
|
||||||
getRun(runId: string): Promise<AgentRun> {
|
getRun(runId: string): Promise<AgentRun> {
|
||||||
return this.requireRun(runId);
|
return requireRun(this.runs, runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private started(
|
private started(
|
||||||
run: AgentRun,
|
run: AgentRun,
|
||||||
externalSignal: AbortSignal,
|
externalSignal: AbortSignal,
|
||||||
sequence: number,
|
sequence: number,
|
||||||
continuation?: NonNullable<ModelRequest["continuation"]>,
|
continuations?: NonNullable<ModelRequest["continuations"]>,
|
||||||
interaction?: UserInteraction,
|
interaction?: UserInteraction,
|
||||||
): StartedRun {
|
): StartedRun {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@ -176,19 +191,10 @@ export class AgentRunService {
|
|||||||
const signal = AbortSignal.any([externalSignal, controller.signal]);
|
const signal = AbortSignal.any([externalSignal, controller.signal]);
|
||||||
return {
|
return {
|
||||||
run,
|
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> {
|
private async withStartLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
const previous = this.startQueue;
|
const previous = this.startQueue;
|
||||||
let release = () => {};
|
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(
|
private async *execute(
|
||||||
run: AgentRun,
|
run: AgentRun,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
initialSequence: number,
|
initialSequence: number,
|
||||||
continuation?: NonNullable<ModelRequest["continuation"]>,
|
initialContinuations: NonNullable<ModelRequest["continuations"]> = [],
|
||||||
resolvedInteraction?: UserInteraction,
|
resolvedInteraction?: UserInteraction,
|
||||||
): AsyncIterable<RunEvent> {
|
): AsyncIterable<RunEvent> {
|
||||||
let sequence = initialSequence;
|
let sequence = initialSequence;
|
||||||
@ -243,9 +223,10 @@ export class AgentRunService {
|
|||||||
const event = async (
|
const event = async (
|
||||||
type: RunEvent["type"],
|
type: RunEvent["type"],
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
) => this.persistEvent(run.id, ++sequence, type, payload);
|
) =>
|
||||||
|
persistRunEvent(this.runs, this.clock, run.id, ++sequence, type, payload);
|
||||||
try {
|
try {
|
||||||
if (!continuation)
|
if (initialContinuations.length === 0)
|
||||||
yield await event("run.started", {
|
yield await event("run.started", {
|
||||||
conversationId: run.conversationId,
|
conversationId: run.conversationId,
|
||||||
});
|
});
|
||||||
@ -263,53 +244,101 @@ export class AgentRunService {
|
|||||||
? messagesThroughTrigger(conversation, run.triggerMessageId)
|
? messagesThroughTrigger(conversation, run.triggerMessageId)
|
||||||
: conversation.messages;
|
: conversation.messages;
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
for await (const modelEvent of this.model.stream(
|
const continuations = [...initialContinuations];
|
||||||
{
|
let toolCount = 0;
|
||||||
model: "default",
|
modelLoop: while (true) {
|
||||||
messages: modelMessages,
|
let requestedTool = false;
|
||||||
tools: [interactionTool],
|
for await (const modelEvent of this.model.stream(
|
||||||
...(continuation ? { continuation } : {}),
|
{
|
||||||
},
|
model: "default",
|
||||||
signal,
|
messages: modelMessages,
|
||||||
)) {
|
tools: [interactionTool, ...workspaceTools],
|
||||||
signal.throwIfAborted();
|
...(continuations.length ? { continuations } : {}),
|
||||||
if (modelEvent.type === "text.delta") {
|
},
|
||||||
content += modelEvent.delta;
|
signal,
|
||||||
yield await event("message.delta", {
|
)) {
|
||||||
messageId,
|
signal.throwIfAborted();
|
||||||
delta: modelEvent.delta,
|
if (modelEvent.type === "text.delta") {
|
||||||
});
|
content += modelEvent.delta;
|
||||||
continue;
|
yield await event("message.delta", {
|
||||||
}
|
messageId,
|
||||||
if (modelEvent.type === "tool.requested") {
|
delta: modelEvent.delta,
|
||||||
if (modelEvent.name !== interactionTool.name)
|
});
|
||||||
throw new CoreError(
|
continue;
|
||||||
"TOOL_NOT_SUPPORTED",
|
}
|
||||||
"模型请求了尚未支持的工具",
|
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(
|
messagePersisted = true;
|
||||||
run.conversationId,
|
yield await event("message.completed", { messageId, content });
|
||||||
messageId,
|
const interaction = await this.interactions.create({
|
||||||
run.id,
|
runId: run.id,
|
||||||
content,
|
conversationId: run.conversationId,
|
||||||
);
|
messageId,
|
||||||
messagePersisted = true;
|
toolCallId: modelEvent.toolCallId,
|
||||||
yield await event("message.completed", { messageId, content });
|
toolArguments: modelEvent.arguments,
|
||||||
const interaction = await this.interactions.create({
|
});
|
||||||
runId: run.id,
|
await this.runs.update({
|
||||||
conversationId: run.conversationId,
|
...run,
|
||||||
messageId,
|
status: "waiting_user",
|
||||||
toolCallId: modelEvent.toolCallId,
|
updatedAt: this.clock.now().toISOString(),
|
||||||
toolArguments: modelEvent.arguments,
|
});
|
||||||
});
|
yield await event("interaction.requested", { interaction });
|
||||||
await this.runs.update({
|
return;
|
||||||
...run,
|
}
|
||||||
status: "waiting_user",
|
|
||||||
updatedAt: this.clock.now().toISOString(),
|
|
||||||
});
|
|
||||||
yield await event("interaction.requested", { interaction });
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
if (!requestedTool) break;
|
||||||
}
|
}
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
if (!content)
|
if (!content)
|
||||||
@ -367,27 +396,4 @@ export class AgentRunService {
|
|||||||
this.controllers.delete(run.id);
|
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" });
|
).rejects.toMatchObject({ code: "MESSAGE_EMPTY" });
|
||||||
expect(conversations.values.size).toBe(0);
|
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(
|
async createWithFirstMessage(
|
||||||
content: string,
|
content: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
|
attachments: Message["attachments"] = [],
|
||||||
): Promise<Conversation> {
|
): Promise<Conversation> {
|
||||||
const normalized = normalizeContent(content);
|
const normalized = normalizeContent(content, attachments.length);
|
||||||
const timestamp = this.dependencies.clock.now().toISOString();
|
const timestamp = this.dependencies.clock.now().toISOString();
|
||||||
const conversation: Conversation = {
|
const conversation: Conversation = {
|
||||||
id: this.dependencies.ids.create(),
|
id: this.dependencies.ids.create(),
|
||||||
projectId: null,
|
projectId: null,
|
||||||
title: createTitle(normalized),
|
title: createTitle(normalized, attachments),
|
||||||
messages: [this.createUserMessage(normalized, timestamp, runId)],
|
messages: [
|
||||||
|
this.createUserMessage(normalized, timestamp, runId, attachments),
|
||||||
|
],
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
};
|
};
|
||||||
@ -53,14 +56,17 @@ export class ConversationService {
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
content: string,
|
content: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
|
attachments: Message["attachments"] = [],
|
||||||
): Promise<Conversation> {
|
): Promise<Conversation> {
|
||||||
const normalized = normalizeContent(content);
|
const normalized = normalizeContent(content, attachments.length);
|
||||||
const timestamp = this.dependencies.clock.now().toISOString();
|
const timestamp = this.dependencies.clock.now().toISOString();
|
||||||
const conversation: Conversation = {
|
const conversation: Conversation = {
|
||||||
id: this.dependencies.ids.create(),
|
id: this.dependencies.ids.create(),
|
||||||
projectId,
|
projectId,
|
||||||
title: createTitle(normalized),
|
title: createTitle(normalized, attachments),
|
||||||
messages: [this.createUserMessage(normalized, timestamp, runId)],
|
messages: [
|
||||||
|
this.createUserMessage(normalized, timestamp, runId, attachments),
|
||||||
|
],
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
};
|
};
|
||||||
@ -72,8 +78,9 @@ export class ConversationService {
|
|||||||
id: string,
|
id: string,
|
||||||
content: string,
|
content: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
|
attachments: Message["attachments"] = [],
|
||||||
): Promise<Conversation> {
|
): Promise<Conversation> {
|
||||||
const normalized = normalizeContent(content);
|
const normalized = normalizeContent(content, attachments.length);
|
||||||
await this.getConversation(id);
|
await this.getConversation(id);
|
||||||
return this.dependencies.conversations.appendMessage(
|
return this.dependencies.conversations.appendMessage(
|
||||||
id,
|
id,
|
||||||
@ -81,6 +88,7 @@ export class ConversationService {
|
|||||||
normalized,
|
normalized,
|
||||||
this.dependencies.clock.now().toISOString(),
|
this.dependencies.clock.now().toISOString(),
|
||||||
runId,
|
runId,
|
||||||
|
attachments,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -96,6 +104,7 @@ export class ConversationService {
|
|||||||
id: messageId,
|
id: messageId,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content,
|
content,
|
||||||
|
attachments: [],
|
||||||
createdAt: this.dependencies.clock.now().toISOString(),
|
createdAt: this.dependencies.clock.now().toISOString(),
|
||||||
runId,
|
runId,
|
||||||
});
|
});
|
||||||
@ -105,26 +114,33 @@ export class ConversationService {
|
|||||||
content: string,
|
content: string,
|
||||||
createdAt: string,
|
createdAt: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
|
attachments: Message["attachments"],
|
||||||
): Message {
|
): Message {
|
||||||
return {
|
return {
|
||||||
id: this.dependencies.ids.create(),
|
id: this.dependencies.ids.create(),
|
||||||
role: "user",
|
role: "user",
|
||||||
content,
|
content,
|
||||||
|
attachments,
|
||||||
createdAt,
|
createdAt,
|
||||||
runId,
|
runId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeContent(content: string): string {
|
function normalizeContent(content: string, attachmentCount: number): string {
|
||||||
const normalized = content.trim();
|
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)
|
if (normalized.length > 32_000)
|
||||||
throw new CoreError("MESSAGE_TOO_LONG", "消息内容过长");
|
throw new CoreError("MESSAGE_TOO_LONG", "消息内容过长");
|
||||||
return normalized;
|
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;
|
const firstLine = content.split("\n", 1)[0] ?? content;
|
||||||
return firstLine.length > 36 ? `${firstLine.slice(0, 36)}…` : firstLine;
|
return firstLine.length > 36 ? `${firstLine.slice(0, 36)}…` : firstLine;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -173,7 +173,7 @@ function createFixture() {
|
|||||||
});
|
});
|
||||||
const model: ModelPort = {
|
const model: ModelPort = {
|
||||||
async *stream(request) {
|
async *stream(request) {
|
||||||
if (!request.continuation) {
|
if (!request.continuations?.length) {
|
||||||
yield {
|
yield {
|
||||||
type: "tool.requested",
|
type: "tool.requested",
|
||||||
toolCallId: "tool_choice",
|
toolCallId: "tool_choice",
|
||||||
@ -188,8 +188,8 @@ function createFixture() {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
expect(request.continuation.toolCallId).toBe("tool_choice");
|
expect(request.continuations[0]?.toolCallId).toBe("tool_choice");
|
||||||
expect(request.continuation.result).toContain("simple");
|
expect(request.continuations[0]?.result).toContain("simple");
|
||||||
yield { type: "text.delta", delta: "已按你的选择继续完成" };
|
yield { type: "text.delta", delta: "已按你的选择继续完成" };
|
||||||
yield { type: "response.completed" };
|
yield { type: "response.completed" };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,6 +87,10 @@ export class ProjectService {
|
|||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getEntity(id: string): Promise<Project> {
|
||||||
|
return this.require(id);
|
||||||
|
}
|
||||||
|
|
||||||
private async require(id: string): Promise<Project> {
|
private async require(id: string): Promise<Project> {
|
||||||
const project = await this.dependencies.projects.getById(id);
|
const project = await this.dependencies.projects.getById(id);
|
||||||
if (!project) throw new CoreError("PROJECT_NOT_FOUND", "项目不存在");
|
if (!project) throw new CoreError("PROJECT_NOT_FOUND", "项目不存在");
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import type { RunEvent } from "../domain/agent-run";
|
import type { RunEvent } from "../domain/agent-run";
|
||||||
import type { Conversation } from "../domain/conversation";
|
import type { Conversation } from "../domain/conversation";
|
||||||
import { CoreError } from "../errors/core-error";
|
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 {
|
export function lastSequence(events: readonly RunEvent[]): number {
|
||||||
return events.at(-1)?.sequence ?? 0;
|
return events.at(-1)?.sequence ?? 0;
|
||||||
@ -21,3 +23,36 @@ export function messagesThroughTrigger(
|
|||||||
? conversation.messages
|
? conversation.messages
|
||||||
: conversation.messages.slice(0, index + 1);
|
: 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";
|
import type { AgentRun, RunEvent } from "../domain/agent-run";
|
||||||
|
|
||||||
export type StartRunInput =
|
export type StartRunInput =
|
||||||
| Readonly<{ kind: "ordinary"; message: string }>
|
| Readonly<{
|
||||||
| Readonly<{ kind: "project"; projectId: string; message: string }>
|
kind: "ordinary";
|
||||||
| Readonly<{ kind: "existing"; conversationId: string; message: string }>;
|
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<{
|
export type StartedRun = Readonly<{
|
||||||
run: AgentRun;
|
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",
|
id: "message_1",
|
||||||
role: "user",
|
role: "user",
|
||||||
content: "你好",
|
content: "你好",
|
||||||
|
attachments: [],
|
||||||
createdAt: "2026-08-12T01:00:00.000Z",
|
createdAt: "2026-08-12T01:00:00.000Z",
|
||||||
runId: "run_1",
|
runId: "run_1",
|
||||||
});
|
});
|
||||||
|
|||||||
@ -31,9 +31,9 @@ export class FileConversationRepository implements ConversationRepository {
|
|||||||
|
|
||||||
async getById(id: string): Promise<Conversation | null> {
|
async getById(id: string): Promise<Conversation | null> {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(
|
return normalizeConversation(
|
||||||
await readFile(this.pathFor(id), "utf8"),
|
JSON.parse(await readFile(this.pathFor(id), "utf8")) as Conversation,
|
||||||
) as Conversation;
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isFileNotFound(error)) return null;
|
if (isFileNotFound(error)) return null;
|
||||||
throw error;
|
throw error;
|
||||||
@ -82,14 +82,26 @@ export class FileConversationRepository implements ConversationRepository {
|
|||||||
names
|
names
|
||||||
.filter((name) => name.endsWith(".json"))
|
.filter((name) => name.endsWith(".json"))
|
||||||
.map(async (name) => {
|
.map(async (name) => {
|
||||||
return JSON.parse(
|
return normalizeConversation(
|
||||||
await readFile(join(this.layout.conversations, name), "utf8"),
|
JSON.parse(
|
||||||
) as Conversation;
|
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 {
|
function isFileNotFound(error: unknown): boolean {
|
||||||
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
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 { constants } from "node:fs";
|
||||||
import { isAbsolute } from "node:path";
|
import {
|
||||||
import { CoreError, type WorkspaceResolverPort } from "@great-agent/agent-core";
|
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 {
|
export class LocalWorkspaceResolver implements WorkspaceResolverPort {
|
||||||
async resolveForCreation(path: string): Promise<string> {
|
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 { 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 { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { LocalWorkspaceResolver } from ".";
|
import { LocalWorkspaceFiles, LocalWorkspaceResolver } from ".";
|
||||||
|
|
||||||
describe("LocalWorkspaceResolver", () => {
|
describe("LocalWorkspaceResolver", () => {
|
||||||
test("只接受实际存在且可读写的绝对目录", async () => {
|
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,
|
request: ModelRequest,
|
||||||
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
|
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
|
||||||
const messages = request.messages.map(toModelMessage);
|
const messages = request.messages.map(toModelMessage);
|
||||||
if (!request.continuation) return messages;
|
for (const continuation of request.continuations ?? []) {
|
||||||
return [
|
messages.push(
|
||||||
...messages,
|
{
|
||||||
{
|
role: "assistant",
|
||||||
role: "assistant",
|
content: null,
|
||||||
content: null,
|
tool_calls: [
|
||||||
tool_calls: [
|
{
|
||||||
{
|
id: continuation.toolCallId,
|
||||||
id: request.continuation.toolCallId,
|
type: "function",
|
||||||
type: "function",
|
function: {
|
||||||
function: {
|
name: continuation.toolName,
|
||||||
name: request.continuation.toolName,
|
arguments: continuation.toolArguments,
|
||||||
arguments: request.continuation.toolArguments,
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
role: "tool",
|
||||||
role: "tool",
|
tool_call_id: continuation.toolCallId,
|
||||||
tool_call_id: request.continuation.toolCallId,
|
content: continuation.result,
|
||||||
content: request.continuation.result,
|
},
|
||||||
},
|
);
|
||||||
];
|
}
|
||||||
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toModelMessage(
|
function toModelMessage(
|
||||||
message: Message,
|
message: Message,
|
||||||
): OpenAI.Chat.Completions.ChatCompletionMessageParam {
|
): 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.requested",
|
||||||
"interaction.resolved",
|
"interaction.resolved",
|
||||||
"interaction.cancelled",
|
"interaction.cancelled",
|
||||||
|
"tool.started",
|
||||||
|
"tool.completed",
|
||||||
|
"tool.failed",
|
||||||
"run.completed",
|
"run.completed",
|
||||||
"run.failed",
|
"run.failed",
|
||||||
"run.cancelled",
|
"run.cancelled",
|
||||||
|
|||||||
@ -30,3 +30,9 @@ export {
|
|||||||
type InteractionResponse,
|
type InteractionResponse,
|
||||||
type ResolvedInteractionResponse,
|
type ResolvedInteractionResponse,
|
||||||
} from "./responses/interaction";
|
} from "./responses/interaction";
|
||||||
|
export {
|
||||||
|
workspaceEntrySchema,
|
||||||
|
workspaceSearchMatchSchema,
|
||||||
|
type WorkspaceEntryResponse,
|
||||||
|
type WorkspaceSearchMatchResponse,
|
||||||
|
} from "./responses/workspace";
|
||||||
|
|||||||
@ -1,20 +1,28 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const startRunRequestSchema = z.discriminatedUnion("kind", [
|
export const startRunRequestSchema = z
|
||||||
z.object({
|
.discriminatedUnion("kind", [
|
||||||
kind: z.literal("project"),
|
z.object({
|
||||||
projectId: z.string().min(1),
|
kind: z.literal("project"),
|
||||||
message: z.string().trim().min(1).max(32_000),
|
projectId: z.string().min(1),
|
||||||
}),
|
message: z.string().trim().max(32_000),
|
||||||
z.object({
|
attachments: z.array(z.string().min(1)).max(10).default([]),
|
||||||
kind: z.literal("ordinary"),
|
}),
|
||||||
message: z.string().trim().min(1).max(32_000),
|
z.object({
|
||||||
}),
|
kind: z.literal("ordinary"),
|
||||||
z.object({
|
message: z.string().trim().max(32_000),
|
||||||
kind: z.literal("existing"),
|
attachments: z.array(z.string().min(1)).max(10).default([]),
|
||||||
conversationId: z.string().min(1),
|
}),
|
||||||
message: z.string().trim().min(1).max(32_000),
|
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>;
|
export type StartRunRequest = z.infer<typeof startRunRequestSchema>;
|
||||||
|
|||||||
@ -4,6 +4,16 @@ export const messageSchema = z.object({
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
role: z.enum(["user", "assistant"]),
|
role: z.enum(["user", "assistant"]),
|
||||||
content: z.string(),
|
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(),
|
createdAt: z.string(),
|
||||||
runId: 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