feat: 工作区附件与文件读取搜索

This commit is contained in:
李岩岩 2026-08-14 15:41:59 +08:00
parent 4c3b2e48e7
commit 944a09ce2b
41 changed files with 1720 additions and 238 deletions

View File

@ -41,10 +41,10 @@
状态: "已完成"
- 编号: "F-006"
名称: "附件、文件列表、读取与搜索"
状态: "进行中"
状态: "已完成"
- 编号: "F-007"
名称: "文件创建与安全修改"
状态: "待开始"
状态: "进行中"
- 编号: "F-008"
名称: "会话管理与个人设置"
状态: "待开始"

View File

@ -9,11 +9,13 @@ import type {
ConversationService,
ProjectService,
InteractionService,
WorkspaceService,
} from "@great-agent/agent-core";
import type { RunRegistry } from "./run-registry";
import { createRunRoutes } from "../routes/runs";
import { createProjectRoutes } from "../routes/projects";
import { createInteractionRoutes } from "../routes/interactions";
import { createWorkspaceRoutes } from "../routes/workspace";
export function createApp(
logger: Logger,
@ -22,17 +24,20 @@ export function createApp(
runRegistry?: RunRegistry,
projects?: ProjectService,
interactions?: InteractionService,
workspace?: WorkspaceService,
): Hono {
const app = new Hono();
app.use("*", requestId);
app.onError(createErrorHandler(logger));
app.route("/api", createHealthRoutes());
if (conversations) app.route("/api", createConversationRoutes(conversations));
if (conversations)
app.route("/api", createConversationRoutes(conversations, workspace));
if (runs && runRegistry)
app.route("/api", createRunRoutes(runs, runRegistry));
if (projects && conversations)
app.route("/api", createProjectRoutes(projects, conversations));
if (interactions && runs && runRegistry)
app.route("/api", createInteractionRoutes(interactions, runs, runRegistry));
if (workspace) app.route("/api", createWorkspaceRoutes(workspace));
return app;
}

View File

@ -14,6 +14,7 @@ import {
InteractionService,
CoreError,
type ModelPort,
WorkspaceService,
} from "@great-agent/agent-core";
import { DeepSeekModelAdapter } from "@great-agent/model-deepseek";
import { createApp } from "./composition/create-app";
@ -21,7 +22,10 @@ import { createLogger } from "./composition/create-logger";
import { mountStaticWeb } from "./composition/static-web";
import { loadEnvironment } from "./config/environment";
import { RunRegistry } from "./composition/run-registry";
import { LocalWorkspaceResolver } from "@great-agent/local-files";
import {
LocalWorkspaceFiles,
LocalWorkspaceResolver,
} from "@great-agent/local-files";
const environment = loadEnvironment();
const logger = createLogger(environment.logLevel);
@ -51,6 +55,13 @@ const interactions = new InteractionService({
clock,
ids,
});
const workspace = new WorkspaceService(
new LocalWorkspaceFiles(),
conversations,
projects,
environment.defaultWorkspaceRoot,
clock,
);
const model: ModelPort = environment.deepSeekApiKey
? new DeepSeekModelAdapter({
apiKey: environment.deepSeekApiKey,
@ -75,6 +86,7 @@ const runs = new AgentRunService(
ids,
projects,
interactions,
workspace,
);
const app = createApp(
logger,
@ -83,6 +95,7 @@ const app = createApp(
new RunRegistry(),
projects,
interactions,
workspace,
);
mountStaticWeb(app, resolve(import.meta.dir, "../../web/dist"));

View File

@ -1,13 +1,24 @@
import { Hono } from "hono";
import type { ConversationService } from "@great-agent/agent-core";
import type {
ConversationService,
WorkspaceService,
} from "@great-agent/agent-core";
export function createConversationRoutes(service: ConversationService): Hono {
export function createConversationRoutes(
service: ConversationService,
workspace?: WorkspaceService,
): Hono {
const routes = new Hono();
routes.get("/conversations", async (context) =>
context.json(await service.listRecent()),
);
routes.get("/conversations/:id", async (context) =>
context.json(await service.getConversation(context.req.param("id"))),
);
routes.get("/conversations/:id", async (context) => {
const conversation = await service.getConversation(context.req.param("id"));
return context.json(
workspace
? await workspace.refreshConversation(conversation)
: conversation,
);
});
return routes;
}

View 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", "工作区上下文无效");
}

View File

@ -9,9 +9,19 @@ import {
import { request } from "./request";
export type StartRunInput =
| { kind: "ordinary"; message: string }
| { kind: "project"; projectId: string; message: string }
| { kind: "existing"; conversationId: string; message: string };
| { kind: "ordinary"; message: string; attachments: readonly string[] }
| {
kind: "project";
projectId: string;
message: string;
attachments: readonly string[];
}
| {
kind: "existing";
conversationId: string;
message: string;
attachments: readonly string[];
};
export async function startRun(
input: StartRunInput,

View 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();
}

View File

@ -30,6 +30,7 @@ export type AppController = Readonly<{
sending: State<boolean>;
assistantDraft: State<string>;
error: State<string>;
toolActivities: State<ToolActivity[]>;
initialize(): Promise<void>;
startNewTask(): void;
startProjectCreation(): void;
@ -41,7 +42,7 @@ export type AppController = Readonly<{
startProjectDelete(id: string): void;
deleteProject(id: string): Promise<void>;
openConversation(id: string): Promise<void>;
send(content: string): Promise<boolean>;
send(content: string, attachments?: readonly string[]): Promise<boolean>;
answerInteraction(
id: string,
answer: InteractionAnswerRequest,
@ -50,3 +51,10 @@ export type AppController = Readonly<{
stopGenerating(): Promise<void>;
retryRun(runId: string): Promise<void>;
}>;
export type ToolActivity = Readonly<{
id: string;
name: string;
status: "running" | "completed" | "failed";
detail: string;
}>;

View File

@ -29,7 +29,12 @@ import {
answerInteraction as submitInteractionAnswer,
listInteractions,
} from "../api/interactions";
import type { AppController, Selection } from "./app-controller-types";
import type {
AppController,
Selection,
ToolActivity,
} from "./app-controller-types";
import { collectRunEvent } from "./controller-run-events";
export type { AppController, Selection } from "./app-controller-types";
export function createAppController(): AppController {
@ -47,10 +52,12 @@ export function createAppController(): AppController {
const sending = van.state(false);
const assistantDraft = van.state("");
const error = van.state("");
const toolActivities = van.state<ToolActivity[]>([]);
async function initialize() {
loading.val = true;
error.val = "";
toolActivities.val = [];
try {
[recent.val, projects.val] = await Promise.all([
listConversations(),
@ -140,12 +147,14 @@ export function createAppController(): AppController {
});
}
async function send(content: string) {
if (!content.trim() || sending.val) return false;
async function send(content: string, attachments: readonly string[] = []) {
if ((!content.trim() && attachments.length === 0) || sending.val)
return false;
let accepted = false;
sending.val = true;
assistantDraft.val = "";
error.val = "";
toolActivities.val = [];
try {
const current = selection.val;
const input =
@ -154,14 +163,16 @@ export function createAppController(): AppController {
kind: "existing" as const,
conversationId: current.id,
message: content,
attachments,
}
: current.kind === "project-draft"
? {
kind: "project" as const,
projectId: current.projectId,
message: content,
attachments,
}
: { kind: "ordinary" as const, message: content };
: { kind: "ordinary" as const, message: content, attachments };
const started = await startRun(input);
accepted = true;
activeRunId.val = started.runId;
@ -279,24 +290,7 @@ export function createAppController(): AppController {
type: string;
payload: Record<string, unknown>;
}) {
if (
event.type === "message.delta" &&
typeof event.payload.delta === "string"
)
assistantDraft.val += event.payload.delta;
if (currentRun.val && event.type === "run.failed")
currentRun.val = {
...currentRun.val,
status: "failed",
errorCode:
typeof event.payload.code === "string"
? event.payload.code
: "MODEL_UNAVAILABLE",
};
if (currentRun.val && event.type === "run.cancelled")
currentRun.val = { ...currentRun.val, status: "cancelled" };
if (currentRun.val && event.type === "run.completed")
currentRun.val = { ...currentRun.val, status: "completed" };
collectRunEvent({ assistantDraft, currentRun, toolActivities }, event);
}
async function refreshActive(conversationId: string) {
@ -345,6 +339,7 @@ export function createAppController(): AppController {
sending,
assistantDraft,
error,
toolActivities,
initialize,
startNewTask,
startProjectCreation,

View 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;
}

View File

@ -96,6 +96,61 @@
line-height: 1.55;
white-space: pre-wrap;
}
.message-attachments,
.selected-attachments {
grid-column: 2;
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 7px;
}
.attachment-chip,
.selected-attachments button {
max-width: 260px;
overflow: hidden;
padding: 5px 8px;
border: 1px solid #4a4742;
border-radius: 7px;
color: #bdb8b0;
background: #302f2c;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-chip.unavailable,
.attachment-chip.unsupported {
color: #d69b91;
border-color: #6b4841;
}
.selected-attachments {
grid-column: auto;
margin: 0 0 8px;
}
.selected-attachments > div {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tool-activity {
display: grid;
grid-template-columns: 18px auto 1fr;
gap: 7px;
align-items: center;
padding: 8px 10px;
border: 1px solid #41403c;
border-radius: 8px;
color: #aaa59d;
background: #2b2a28;
font-size: 10px;
}
.tool-activity span:last-child {
text-align: right;
color: #7f7b75;
}
.tool-activity.failed {
color: #d69b91;
border-color: #6b4841;
}
.composer-wrap {
z-index: 1;
flex: 0 0 auto;

View File

@ -4,16 +4,25 @@ import "./conversation-pane.css";
import { ProjectPanel } from "../projects/project-panel";
import { InteractionCard } from "../interactions/interaction-card";
import { RunStatus } from "../runs/run-status";
import { FilePicker } from "../files/file-picker";
const { article, button, div, h1, header, main, p, span, textarea } = van.tags;
export function ConversationPane(controller: AppController): HTMLElement {
const draft = van.state("");
const attachments = van.state<string[]>([]);
const attachmentContext = van.state("");
const pickerOpen = van.state(false);
async function submit() {
const content = draft.val;
if (!content.trim()) return;
if (await controller.send(content)) draft.val = "";
const selected =
attachmentContext.val === contextKey(controller) ? attachments.val : [];
if (!content.trim() && selected.length === 0) return;
if (await controller.send(content, selected)) {
draft.val = "";
attachments.val = [];
}
}
return main(
@ -39,6 +48,26 @@ export function ConversationPane(controller: AppController): HTMLElement {
message.role === "user" ? "你" : "G",
),
message.content ? p(message.content) : div(),
message.attachments.length
? div(
{ class: "message-attachments" },
message.attachments.map((attachment) =>
span(
{
class: `attachment-chip ${attachment.status}`,
title: attachment.reason ?? attachment.path,
},
"▤ ",
attachment.name,
attachment.status === "unavailable"
? " · 不可用"
: attachment.status === "unsupported"
? " · 不支持读取"
: "",
),
),
)
: null,
),
controller.interactions.val
.filter(
@ -57,6 +86,24 @@ export function ConversationPane(controller: AppController): HTMLElement {
p(controller.assistantDraft),
)
: null,
() =>
div(
{ class: "tool-activities" },
...controller.toolActivities.val.map((tool) =>
div(
{ class: `tool-activity ${tool.status}` },
span(
tool.status === "running"
? "◌"
: tool.status === "completed"
? "✓"
: "!",
),
span(toolName(tool.name)),
span(tool.detail),
),
),
),
),
() => RunStatus(controller),
)
@ -68,7 +115,21 @@ export function ConversationPane(controller: AppController): HTMLElement {
),
div(
{ class: "composer-slot", hidden: () => !canCompose(controller) },
Composer(controller, draft, submit),
Composer(
controller,
draft,
attachments,
attachmentContext,
pickerOpen,
submit,
),
),
div({ class: "file-picker-host", hidden: () => !pickerOpen.val }, () =>
pickerOpen.val
? FilePicker(controller.selection.val, attachments, () => {
pickerOpen.val = false;
})
: div(),
),
);
}
@ -76,6 +137,9 @@ export function ConversationPane(controller: AppController): HTMLElement {
function Composer(
controller: AppController,
draft: State<string>,
attachments: State<string[]>,
attachmentContext: State<string>,
pickerOpen: State<boolean>,
submit: () => Promise<void>,
): HTMLElement {
return div(
@ -90,6 +154,30 @@ function Composer(
),
div(
{ class: "composer" },
div(
{
class: "selected-attachments",
hidden: () =>
attachmentContext.val !== contextKey(controller) ||
attachments.val.length === 0,
},
() =>
div(
...attachments.val.map((path) =>
button(
{
onclick: () => {
attachments.val = attachments.val.filter(
(item) => item !== path,
);
},
title: path,
},
`${path.split("/").at(-1) ?? path} ×`,
),
),
),
),
textarea({
"aria-label": "输入消息",
placeholder: "我能帮你做些什么?",
@ -108,13 +196,34 @@ function Composer(
div(
{ class: "composer-actions" },
button(
{ class: "attach", disabled: true, "aria-label": "添加附件" },
{
class: "attach",
disabled: () =>
!workspaceAvailable(controller) || controller.sending.val,
onclick: () => {
const key = contextKey(controller);
if (attachmentContext.val !== key) attachments.val = [];
attachmentContext.val = key;
pickerOpen.val = true;
},
"aria-label": "添加附件",
title: () =>
workspaceAvailable(controller)
? "添加工作区文件"
: "当前工作区不可用",
},
"+",
),
button(
{
class: "send",
disabled: () => !controller.sending.val && !draft.val.trim(),
disabled: () =>
!controller.sending.val &&
!draft.val.trim() &&
!(
attachmentContext.val === contextKey(controller) &&
attachments.val.length
),
onclick: () =>
controller.sending.val
? void controller.stopGenerating()
@ -137,6 +246,41 @@ function Composer(
);
}
function contextKey(controller: AppController): string {
const selection = controller.selection.val;
if (selection.kind === "conversation") return `conversation:${selection.id}`;
if (selection.kind === "project-draft")
return `project:${selection.projectId}`;
return "ordinary";
}
function workspaceAvailable(controller: AppController): boolean {
const selection = controller.selection.val;
const projectId =
selection.kind === "project-draft"
? selection.projectId
: selection.kind === "conversation"
? controller.active.val?.projectId
: null;
if (!projectId) return true;
return (
controller.projects.val.find((project) => project.id === projectId)
?.workspaceAvailable ?? false
);
}
function toolName(name: string): string {
return (
(
{
list_directory: "浏览文件",
search_files: "搜索文件",
read_text_file: "读取文件",
} as Record<string, string>
)[name] ?? name
);
}
function isProjectConversation(controller: AppController): boolean {
const selection = controller.selection.val;
return (

View 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;
}

View 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);
};
}

View File

@ -78,13 +78,18 @@
### F-006——附件、文件列表、读取与搜索
- 状态:进行中
- 状态:已完成Agent 于 2026-08-14 验证通过,等待最终统一人工审核)
- 用户可见结果:附加工作区文件并让 Agent 安全读取和搜索。
- 主要验收AC-007 至 AC-009、AC-021、AC-022、AC-028、AC-030。
- 实现结果:消息新增必填附件元数据,支持一次选择最多 10 个工作区文件和仅附件消息;普通草稿、项目草稿与已有会话均由服务端解析可信工作区,浏览器和模型不能传入根目录。文件层支持目录浏览、文件名与 UTF-8 内容搜索、2 MiB 内文本读取、二进制识别,并拒绝绝对路径、`..`、空字节和符号链接逃逸。
- Agent 工具:新增 `list_directory``search_files``read_text_file`;普通文件工具使用独立 `tool.started``tool.completed``tool.failed` 事件,结果交回同一次模型请求链继续生成,与用户交互事件保持区分。
- 页面结果VanJS 输入区提供工作区文件选择器、目录导航、搜索、最多 10 项选择、待发送附件和历史附件状态;只有附件时发送按钮可用。附件在发送后消失或不可访问时,历史会话仍可打开并明确显示“不可用”;项目目录离线时附件入口禁用且不会回退到默认工作区。
- 自动化验证结果2026-08-14 通过全仓类型检查、29 项测试和 99 个断言、生产构建、代码检查、格式检查、文件规模检查与架构依赖检查;覆盖读取/搜索、二进制拒绝、绝对路径、父目录跳转、符号链接逃逸、仅附件消息和文件工具续跑。
- 人工操作结果2026-08-14 使用隔离工作区、隔离数据目录和未配置模型密钥的本机应用内浏览器完成;验证选择 `README.md`、仅附件发送、会话标题与附件历史恢复;删除测试文件后刷新,历史附件显示“不可用”,隔离页面控制台无错误。
### F-007——文件创建与安全修改
- 状态:待开始
- 状态:进行中
- 用户可见结果Agent 在正确工作区内创建和安全修改文本文件。
- 主要验收AC-008、AC-009、AC-023、AC-028。

View File

@ -28,6 +28,9 @@ export type RunEvent = Readonly<{
| "interaction.requested"
| "interaction.resolved"
| "interaction.cancelled"
| "tool.started"
| "tool.completed"
| "tool.failed"
| "run.completed"
| "run.failed"
| "run.cancelled";

View File

@ -1,9 +1,21 @@
export type MessageRole = "user" | "assistant";
export type AttachmentStatus = "available" | "unavailable" | "unsupported";
export type AttachmentRef = Readonly<{
name: string;
path: string;
mediaType: string;
status: AttachmentStatus;
checkedAt: string;
reason?: string;
}>;
export type Message = Readonly<{
id: string;
role: MessageRole;
content: string;
attachments: readonly AttachmentRef[];
createdAt: string;
runId: string;
}>;

View File

@ -4,6 +4,8 @@ export type {
ConversationSummary,
Message,
MessageRole,
AttachmentRef,
AttachmentStatus,
} from "./domain/conversation";
export type { AgentRun, AgentRunStatus, RunEvent } from "./domain/agent-run";
export type { Project, ProjectView } from "./domain/project";
@ -29,15 +31,22 @@ export type {
IdPort,
WorkspaceResolverPort,
} from "./ports/system-ports";
export type {
WorkspaceEntry,
WorkspaceFileInfo,
WorkspacePort,
WorkspaceSearchMatch,
} from "./ports/workspace-port";
export {
WorkspaceService,
type WorkspaceContext,
} from "./use-cases/workspace-service";
export {
ConversationService,
type ConversationServiceDependencies,
} from "./use-cases/conversation-service";
export {
AgentRunService,
type StartedRun,
type StartRunInput,
} from "./use-cases/agent-run-service";
export { AgentRunService } from "./use-cases/agent-run-service";
export type { StartedRun, StartRunInput } from "./use-cases/run-types";
export {
ProjectService,
type ProjectServiceDependencies,

View File

@ -4,12 +4,12 @@ export type ModelRequest = Readonly<{
model: string;
messages: readonly Message[];
tools?: readonly ModelTool[];
continuation?: Readonly<{
continuations?: readonly Readonly<{
toolCallId: string;
toolName: string;
toolArguments: string;
result: string;
}>;
}>[];
}>;
export type ModelTool = Readonly<{

View 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>;
}

View File

@ -272,6 +272,50 @@ describe("AgentRunService", () => {
).toHaveLength(1);
expect(conversation.messages.at(-1)?.content).toBe("重试成功");
});
test("执行文件工具后把结果交回模型并继续同一次 Run", async () => {
let calls = 0;
const fixture = createFixture({
async *stream(request) {
calls++;
if (calls === 1) {
yield {
type: "tool.requested",
toolCallId: "tool_read",
name: "read_text_file",
arguments: JSON.stringify({ path: "README.md" }),
};
return;
}
expect(request.continuations?.[0]?.result).toBe("本地文件内容");
yield { type: "text.delta", delta: "已经读取文件" };
yield { type: "response.completed" };
},
});
const workspace = {
createAttachments: async () => [],
executeTool: async () => "本地文件内容",
} as unknown as import("..").WorkspaceService;
fixture.service = new AgentRunService(
fixture.conversationService,
fixture.model,
fixture.runs,
{ now: () => new Date("2026-08-14T00:00:00Z") },
{ create: () => `tool_${++fixture.nextId}` },
projects,
interactions,
workspace,
);
const started = await fixture.service.start(
{ kind: "ordinary", message: "读取说明" },
new AbortController().signal,
);
const events: RunEvent[] = [];
for await (const event of started.events) events.push(event);
expect(events.map((event) => event.type)).toContain("tool.started");
expect(events.map((event) => event.type)).toContain("tool.completed");
expect(events.at(-1)?.type).toBe("run.completed");
});
});
function createFixture(model: ModelPort) {
@ -292,5 +336,12 @@ function createFixture(model: ModelPort) {
projects,
interactions,
);
return { service, runs, conversations, conversationService };
return {
service,
model,
runs,
conversations,
conversationService,
nextId: id,
};
}

View File

@ -1,5 +1,4 @@
import type { AgentRun, RunEvent } from "../domain/agent-run";
import type { Conversation } from "../domain/conversation";
import type { UserInteraction } from "../domain/user-interaction";
import { CoreError } from "../errors/core-error";
import type { ModelPort, ModelRequest } from "../ports/model-port";
@ -8,14 +7,19 @@ import type { ClockPort, IdPort } from "../ports/system-ports";
import type { ConversationService } from "./conversation-service";
import type { InteractionService } from "./interaction-service";
import type { ProjectService } from "./project-service";
import type { WorkspaceService } from "./workspace-service";
import { isWorkspaceTool, workspaceTools } from "./workspace-tools";
import { interactionTool } from "./interaction-tool";
import {
ensureNoActiveRun,
lastSequence,
messagesThroughTrigger,
persistRunEvent,
requireRun,
safeErrorMessage,
} from "./run-service-helpers";
import type { StartedRun, StartRunInput } from "./run-types";
export type { StartedRun, StartRunInput } from "./run-types";
import { executeWorkspaceTool, prepareRunConversation } from "./run-workspace";
export class AgentRunService {
private readonly controllers = new Map<string, AbortController>();
@ -29,13 +33,20 @@ export class AgentRunService {
private readonly ids: IdPort,
private readonly projects: ProjectService,
private readonly interactions: InteractionService,
private readonly workspace?: WorkspaceService,
) {}
async start(input: StartRunInput, signal: AbortSignal): Promise<StartedRun> {
return this.withStartLock(async () => {
await this.ensureNoActiveRun();
await ensureNoActiveRun(this.runs);
const runId = this.ids.create();
const conversation = await this.prepareConversation(input, runId);
const conversation = await prepareRunConversation(
input,
runId,
this.conversations,
this.projects,
this.workspace,
);
const timestamp = this.clock.now().toISOString();
const triggerMessage = conversation.messages.at(-1);
if (triggerMessage?.role !== "user")
@ -60,7 +71,7 @@ export class AgentRunService {
interaction: UserInteraction,
signal: AbortSignal,
): Promise<StartedRun> {
const current = await this.requireRun(interaction.runId);
const current = await requireRun(this.runs, interaction.runId);
if (current.status !== "waiting_user")
throw new CoreError("RUN_NOT_WAITING", "该任务当前没有等待用户回答");
if (!interaction.answer)
@ -72,17 +83,17 @@ export class AgentRunService {
};
await this.runs.update(run);
const sequence = lastSequence(await this.runs.listEvents(run.id));
const continuation: NonNullable<ModelRequest["continuation"]> = {
const continuation: NonNullable<ModelRequest["continuations"]>[number] = {
toolCallId: interaction.toolCallId,
toolName: "request_user_interaction",
toolArguments: interaction.toolArguments,
result: JSON.stringify(interaction.answer),
};
return this.started(run, signal, sequence, continuation, interaction);
return this.started(run, signal, sequence, [continuation], interaction);
}
async cancel(runId: string): Promise<readonly RunEvent[]> {
const run = await this.requireRun(runId);
const run = await requireRun(this.runs, runId);
if (
run.status === "cancelled" ||
run.status === "completed" ||
@ -103,7 +114,9 @@ export class AgentRunService {
if (!interaction)
throw new CoreError("INTERACTION_NOT_FOUND", "等待中的交互请求不存在");
let sequence = lastSequence(await this.runs.listEvents(run.id));
const cancelled = await this.persistEvent(
const cancelled = await persistRunEvent(
this.runs,
this.clock,
run.id,
++sequence,
"interaction.cancelled",
@ -114,7 +127,9 @@ export class AgentRunService {
status: "cancelled",
updatedAt: this.clock.now().toISOString(),
});
const finished = await this.persistEvent(
const finished = await persistRunEvent(
this.runs,
this.clock,
run.id,
++sequence,
"run.cancelled",
@ -125,8 +140,8 @@ export class AgentRunService {
async retry(runId: string, signal: AbortSignal): Promise<StartedRun> {
return this.withStartLock(async () => {
await this.ensureNoActiveRun();
const original = await this.requireRun(runId);
await ensureNoActiveRun(this.runs);
const original = await requireRun(this.runs, runId);
if (original.status !== "failed" && original.status !== "cancelled")
throw new CoreError(
"RUN_NOT_RETRYABLE",
@ -156,19 +171,19 @@ export class AgentRunService {
}
async lastSequence(runId: string): Promise<number> {
await this.requireRun(runId);
await requireRun(this.runs, runId);
return lastSequence(await this.runs.listEvents(runId));
}
getRun(runId: string): Promise<AgentRun> {
return this.requireRun(runId);
return requireRun(this.runs, runId);
}
private started(
run: AgentRun,
externalSignal: AbortSignal,
sequence: number,
continuation?: NonNullable<ModelRequest["continuation"]>,
continuations?: NonNullable<ModelRequest["continuations"]>,
interaction?: UserInteraction,
): StartedRun {
const controller = new AbortController();
@ -176,19 +191,10 @@ export class AgentRunService {
const signal = AbortSignal.any([externalSignal, controller.signal]);
return {
run,
events: this.execute(run, signal, sequence, continuation, interaction),
events: this.execute(run, signal, sequence, continuations, interaction),
};
}
private async ensureNoActiveRun(): Promise<void> {
const active = await this.runs.findActive();
if (active)
throw new CoreError(
"RUN_ALREADY_ACTIVE",
"已有任务正在运行,请先停止或完成当前任务",
);
}
private async withStartLock<T>(operation: () => Promise<T>): Promise<T> {
const previous = this.startQueue;
let release = () => {};
@ -203,37 +209,11 @@ export class AgentRunService {
}
}
private async prepareConversation(
input: StartRunInput,
runId: string,
): Promise<Conversation> {
if (input.kind === "ordinary")
return this.conversations.createWithFirstMessage(input.message, runId);
if (input.kind === "project") {
await this.projects.requireAvailable(input.projectId);
return this.conversations.createProjectWithFirstMessage(
input.projectId,
input.message,
runId,
);
}
const existing = await this.conversations.getConversation(
input.conversationId,
);
if (existing.projectId)
await this.projects.requireAvailable(existing.projectId);
return this.conversations.appendUserMessage(
input.conversationId,
input.message,
runId,
);
}
private async *execute(
run: AgentRun,
signal: AbortSignal,
initialSequence: number,
continuation?: NonNullable<ModelRequest["continuation"]>,
initialContinuations: NonNullable<ModelRequest["continuations"]> = [],
resolvedInteraction?: UserInteraction,
): AsyncIterable<RunEvent> {
let sequence = initialSequence;
@ -243,9 +223,10 @@ export class AgentRunService {
const event = async (
type: RunEvent["type"],
payload: Record<string, unknown>,
) => this.persistEvent(run.id, ++sequence, type, payload);
) =>
persistRunEvent(this.runs, this.clock, run.id, ++sequence, type, payload);
try {
if (!continuation)
if (initialContinuations.length === 0)
yield await event("run.started", {
conversationId: run.conversationId,
});
@ -263,53 +244,101 @@ export class AgentRunService {
? messagesThroughTrigger(conversation, run.triggerMessageId)
: conversation.messages;
signal.throwIfAborted();
for await (const modelEvent of this.model.stream(
{
model: "default",
messages: modelMessages,
tools: [interactionTool],
...(continuation ? { continuation } : {}),
},
signal,
)) {
signal.throwIfAborted();
if (modelEvent.type === "text.delta") {
content += modelEvent.delta;
yield await event("message.delta", {
messageId,
delta: modelEvent.delta,
});
continue;
}
if (modelEvent.type === "tool.requested") {
if (modelEvent.name !== interactionTool.name)
throw new CoreError(
"TOOL_NOT_SUPPORTED",
"模型请求了尚未支持的工具",
const continuations = [...initialContinuations];
let toolCount = 0;
modelLoop: while (true) {
let requestedTool = false;
for await (const modelEvent of this.model.stream(
{
model: "default",
messages: modelMessages,
tools: [interactionTool, ...workspaceTools],
...(continuations.length ? { continuations } : {}),
},
signal,
)) {
signal.throwIfAborted();
if (modelEvent.type === "text.delta") {
content += modelEvent.delta;
yield await event("message.delta", {
messageId,
delta: modelEvent.delta,
});
continue;
}
if (modelEvent.type === "tool.requested") {
requestedTool = true;
if (isWorkspaceTool(modelEvent.name)) {
if (++toolCount > 12)
throw new CoreError(
"TOOL_LIMIT_EXCEEDED",
"文件工具调用次数过多",
);
yield await event("tool.started", {
messageId,
toolCallId: modelEvent.toolCallId,
name: modelEvent.name,
});
const tool = await executeWorkspaceTool(
this.workspace,
run.conversationId,
modelEvent.name,
modelEvent.arguments,
);
if (!tool.failed) {
yield await event("tool.completed", {
messageId,
toolCallId: modelEvent.toolCallId,
name: modelEvent.name,
summary: tool.summary,
});
} else {
yield await event("tool.failed", {
messageId,
toolCallId: modelEvent.toolCallId,
name: modelEvent.name,
code: tool.failed.code,
message: tool.failed.message,
});
}
continuations.push({
toolCallId: modelEvent.toolCallId,
toolName: modelEvent.name,
toolArguments: modelEvent.arguments,
result: tool.result,
});
continue modelLoop;
}
if (modelEvent.name !== interactionTool.name)
throw new CoreError(
"TOOL_NOT_SUPPORTED",
"模型请求了尚未支持的工具",
);
await this.conversations.appendAssistantMessage(
run.conversationId,
messageId,
run.id,
content,
);
await this.conversations.appendAssistantMessage(
run.conversationId,
messageId,
run.id,
content,
);
messagePersisted = true;
yield await event("message.completed", { messageId, content });
const interaction = await this.interactions.create({
runId: run.id,
conversationId: run.conversationId,
messageId,
toolCallId: modelEvent.toolCallId,
toolArguments: modelEvent.arguments,
});
await this.runs.update({
...run,
status: "waiting_user",
updatedAt: this.clock.now().toISOString(),
});
yield await event("interaction.requested", { interaction });
return;
messagePersisted = true;
yield await event("message.completed", { messageId, content });
const interaction = await this.interactions.create({
runId: run.id,
conversationId: run.conversationId,
messageId,
toolCallId: modelEvent.toolCallId,
toolArguments: modelEvent.arguments,
});
await this.runs.update({
...run,
status: "waiting_user",
updatedAt: this.clock.now().toISOString(),
});
yield await event("interaction.requested", { interaction });
return;
}
}
if (!requestedTool) break;
}
signal.throwIfAborted();
if (!content)
@ -367,27 +396,4 @@ export class AgentRunService {
this.controllers.delete(run.id);
}
}
private async requireRun(id: string): Promise<AgentRun> {
const run = await this.runs.getById(id);
if (!run) throw new CoreError("RUN_NOT_FOUND", "任务不存在");
return run;
}
private async persistEvent(
runId: string,
sequence: number,
type: RunEvent["type"],
payload: Record<string, unknown>,
) {
const value: RunEvent = {
runId,
sequence,
timestamp: this.clock.now().toISOString(),
type,
payload,
};
await this.runs.appendEvent(value);
return value;
}
}

View File

@ -70,4 +70,25 @@ describe("ConversationService", () => {
).rejects.toMatchObject({ code: "MESSAGE_EMPTY" });
expect(conversations.values.size).toBe(0);
});
test("允许只有附件而没有文本的消息", async () => {
const conversations = new MemoryConversationRepository();
const service = new ConversationService({
conversations,
clock: { now: () => new Date("2026-08-14T00:00:00Z") },
ids: { create: () => "attachment_message" },
});
const created = await service.createWithFirstMessage("", "run_1", [
{
name: "说明.md",
path: "docs/说明.md",
mediaType: "text/markdown",
status: "available",
checkedAt: "2026-08-14T00:00:00Z",
},
]);
expect(created.title).toBe("说明.md");
expect(created.messages[0]?.content).toBe("");
expect(created.messages[0]?.attachments[0]?.path).toBe("docs/说明.md");
});
});

View File

@ -34,14 +34,17 @@ export class ConversationService {
async createWithFirstMessage(
content: string,
runId: string,
attachments: Message["attachments"] = [],
): Promise<Conversation> {
const normalized = normalizeContent(content);
const normalized = normalizeContent(content, attachments.length);
const timestamp = this.dependencies.clock.now().toISOString();
const conversation: Conversation = {
id: this.dependencies.ids.create(),
projectId: null,
title: createTitle(normalized),
messages: [this.createUserMessage(normalized, timestamp, runId)],
title: createTitle(normalized, attachments),
messages: [
this.createUserMessage(normalized, timestamp, runId, attachments),
],
createdAt: timestamp,
updatedAt: timestamp,
};
@ -53,14 +56,17 @@ export class ConversationService {
projectId: string,
content: string,
runId: string,
attachments: Message["attachments"] = [],
): Promise<Conversation> {
const normalized = normalizeContent(content);
const normalized = normalizeContent(content, attachments.length);
const timestamp = this.dependencies.clock.now().toISOString();
const conversation: Conversation = {
id: this.dependencies.ids.create(),
projectId,
title: createTitle(normalized),
messages: [this.createUserMessage(normalized, timestamp, runId)],
title: createTitle(normalized, attachments),
messages: [
this.createUserMessage(normalized, timestamp, runId, attachments),
],
createdAt: timestamp,
updatedAt: timestamp,
};
@ -72,8 +78,9 @@ export class ConversationService {
id: string,
content: string,
runId: string,
attachments: Message["attachments"] = [],
): Promise<Conversation> {
const normalized = normalizeContent(content);
const normalized = normalizeContent(content, attachments.length);
await this.getConversation(id);
return this.dependencies.conversations.appendMessage(
id,
@ -81,6 +88,7 @@ export class ConversationService {
normalized,
this.dependencies.clock.now().toISOString(),
runId,
attachments,
),
);
}
@ -96,6 +104,7 @@ export class ConversationService {
id: messageId,
role: "assistant",
content,
attachments: [],
createdAt: this.dependencies.clock.now().toISOString(),
runId,
});
@ -105,26 +114,33 @@ export class ConversationService {
content: string,
createdAt: string,
runId: string,
attachments: Message["attachments"],
): Message {
return {
id: this.dependencies.ids.create(),
role: "user",
content,
attachments,
createdAt,
runId,
};
}
}
function normalizeContent(content: string): string {
function normalizeContent(content: string, attachmentCount: number): string {
const normalized = content.trim();
if (!normalized) throw new CoreError("MESSAGE_EMPTY", "请输入消息内容");
if (!normalized && attachmentCount === 0)
throw new CoreError("MESSAGE_EMPTY", "请输入消息内容或添加附件");
if (normalized.length > 32_000)
throw new CoreError("MESSAGE_TOO_LONG", "消息内容过长");
return normalized;
}
function createTitle(content: string): string {
function createTitle(
content: string,
attachments: Message["attachments"],
): string {
if (!content) return attachments[0]?.name ?? "附件对话";
const firstLine = content.split("\n", 1)[0] ?? content;
return firstLine.length > 36 ? `${firstLine.slice(0, 36)}` : firstLine;
}

View File

@ -173,7 +173,7 @@ function createFixture() {
});
const model: ModelPort = {
async *stream(request) {
if (!request.continuation) {
if (!request.continuations?.length) {
yield {
type: "tool.requested",
toolCallId: "tool_choice",
@ -188,8 +188,8 @@ function createFixture() {
}),
};
} else {
expect(request.continuation.toolCallId).toBe("tool_choice");
expect(request.continuation.result).toContain("simple");
expect(request.continuations[0]?.toolCallId).toBe("tool_choice");
expect(request.continuations[0]?.result).toContain("simple");
yield { type: "text.delta", delta: "已按你的选择继续完成" };
yield { type: "response.completed" };
}

View File

@ -87,6 +87,10 @@ export class ProjectService {
return project;
}
async getEntity(id: string): Promise<Project> {
return this.require(id);
}
private async require(id: string): Promise<Project> {
const project = await this.dependencies.projects.getById(id);
if (!project) throw new CoreError("PROJECT_NOT_FOUND", "项目不存在");

View File

@ -1,6 +1,8 @@
import type { RunEvent } from "../domain/agent-run";
import type { Conversation } from "../domain/conversation";
import { CoreError } from "../errors/core-error";
import type { RunRepository } from "../ports/run-repository";
import type { ClockPort } from "../ports/system-ports";
export function lastSequence(events: readonly RunEvent[]): number {
return events.at(-1)?.sequence ?? 0;
@ -21,3 +23,36 @@ export function messagesThroughTrigger(
? conversation.messages
: conversation.messages.slice(0, index + 1);
}
export async function ensureNoActiveRun(runs: RunRepository): Promise<void> {
if (await runs.findActive())
throw new CoreError(
"RUN_ALREADY_ACTIVE",
"已有任务正在运行,请先停止或完成当前任务",
);
}
export async function requireRun(runs: RunRepository, id: string) {
const run = await runs.getById(id);
if (!run) throw new CoreError("RUN_NOT_FOUND", "任务不存在");
return run;
}
export async function persistRunEvent(
runs: RunRepository,
clock: ClockPort,
runId: string,
sequence: number,
type: RunEvent["type"],
payload: Record<string, unknown>,
): Promise<RunEvent> {
const value: RunEvent = {
runId,
sequence,
timestamp: clock.now().toISOString(),
type,
payload,
};
await runs.appendEvent(value);
return value;
}

View File

@ -1,9 +1,23 @@
import type { AgentRun, RunEvent } from "../domain/agent-run";
export type StartRunInput =
| Readonly<{ kind: "ordinary"; message: string }>
| Readonly<{ kind: "project"; projectId: string; message: string }>
| Readonly<{ kind: "existing"; conversationId: string; message: string }>;
| Readonly<{
kind: "ordinary";
message: string;
attachments?: readonly string[];
}>
| Readonly<{
kind: "project";
projectId: string;
message: string;
attachments?: readonly string[];
}>
| Readonly<{
kind: "existing";
conversationId: string;
message: string;
attachments?: readonly string[];
}>;
export type StartedRun = Readonly<{
run: AgentRun;

View 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 "已完成";
}

View 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;
}

View 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);
}

View File

@ -31,6 +31,7 @@ describe("FileConversationRepository", () => {
id: "message_1",
role: "user",
content: "你好",
attachments: [],
createdAt: "2026-08-12T01:00:00.000Z",
runId: "run_1",
});

View File

@ -31,9 +31,9 @@ export class FileConversationRepository implements ConversationRepository {
async getById(id: string): Promise<Conversation | null> {
try {
return JSON.parse(
await readFile(this.pathFor(id), "utf8"),
) as Conversation;
return normalizeConversation(
JSON.parse(await readFile(this.pathFor(id), "utf8")) as Conversation,
);
} catch (error) {
if (isFileNotFound(error)) return null;
throw error;
@ -82,14 +82,26 @@ export class FileConversationRepository implements ConversationRepository {
names
.filter((name) => name.endsWith(".json"))
.map(async (name) => {
return JSON.parse(
await readFile(join(this.layout.conversations, name), "utf8"),
) as Conversation;
return normalizeConversation(
JSON.parse(
await readFile(join(this.layout.conversations, name), "utf8"),
) as Conversation,
);
}),
);
}
}
function normalizeConversation(conversation: Conversation): Conversation {
return {
...conversation,
messages: conversation.messages.map((message) => ({
...message,
attachments: message.attachments ?? [],
})),
};
}
function isFileNotFound(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}

View File

@ -1,7 +1,21 @@
import { access, realpath, stat } from "node:fs/promises";
import { access, readFile, readdir, realpath, stat } from "node:fs/promises";
import { constants } from "node:fs";
import { isAbsolute } from "node:path";
import { CoreError, type WorkspaceResolverPort } from "@great-agent/agent-core";
import {
basename,
extname,
isAbsolute,
relative,
resolve,
sep,
} from "node:path";
import {
CoreError,
type WorkspaceEntry,
type WorkspaceFileInfo,
type WorkspacePort,
type WorkspaceResolverPort,
type WorkspaceSearchMatch,
} from "@great-agent/agent-core";
export class LocalWorkspaceResolver implements WorkspaceResolverPort {
async resolveForCreation(path: string): Promise<string> {
@ -32,3 +46,172 @@ export class LocalWorkspaceResolver implements WorkspaceResolverPort {
}
}
}
const MAX_READ_BYTES = 2 * 1024 * 1024;
const SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]);
export class LocalWorkspaceFiles implements WorkspacePort {
async list(root: string, path = ""): Promise<readonly WorkspaceEntry[]> {
const realRoot = await realpath(root);
const target = await safeExistingPath(root, path);
if (!(await stat(target)).isDirectory())
throw new CoreError("WORKSPACE_NOT_DIRECTORY", "目标不是文件夹");
const names = await readdir(target, { withFileTypes: true });
return Promise.all(
names
.filter((entry) => !entry.isSymbolicLink())
.sort((left, right) => left.name.localeCompare(right.name))
.map(async (entry) => {
const absolute = resolve(target, entry.name);
const info = await stat(absolute);
return {
path: toRelative(realRoot, absolute),
name: entry.name,
kind: entry.isDirectory() ? "directory" : "file",
...(entry.isFile() ? { size: info.size } : {}),
} as const;
}),
);
}
async search(
root: string,
query: string,
): Promise<readonly WorkspaceSearchMatch[]> {
const normalized = query.toLocaleLowerCase();
const paths = await collectFiles(root);
const matches: WorkspaceSearchMatch[] = [];
for (const path of paths) {
if (matches.length >= 200) break;
if (basename(path).toLocaleLowerCase().includes(normalized))
matches.push({ path, matchedBy: "name" });
if (matches.length >= 200) break;
try {
const content = await this.readText(root, path);
const lines = content.split("\n");
let count = 0;
for (let index = 0; index < lines.length && count < 20; index++) {
if (!lines[index]?.toLocaleLowerCase().includes(normalized)) continue;
matches.push({
path,
line: index + 1,
preview: (lines[index] ?? "").trim().slice(0, 240),
matchedBy: "content",
});
count++;
if (matches.length >= 200) break;
}
} catch (cause) {
if (!(cause instanceof CoreError)) throw cause;
}
}
return matches;
}
async inspect(root: string, path: string): Promise<WorkspaceFileInfo> {
const realRoot = await realpath(root);
const target = await safeExistingPath(root, path);
const info = await stat(target);
if (!info.isFile())
throw new CoreError("FILE_NOT_REGULAR", "目标不是普通文件");
const mediaType = mediaTypeFor(path);
return {
path: toRelative(realRoot, target),
name: basename(target),
size: info.size,
mediaType,
readableAsText: info.size <= MAX_READ_BYTES && (await isText(target)),
};
}
async readText(root: string, path: string): Promise<string> {
const info = await this.inspect(root, path);
if (info.size > MAX_READ_BYTES)
throw new CoreError("FILE_TOO_LARGE", "文件超过 2 MiB暂不支持读取");
if (!info.readableAsText)
throw new CoreError(
"FILE_TYPE_UNSUPPORTED",
"该文件不是支持的 UTF-8 文本",
);
return readFile(await safeExistingPath(root, path), "utf8");
}
}
async function safeExistingPath(root: string, input: string): Promise<string> {
validateRelativePath(input);
try {
const realRoot = await realpath(root);
const target = await realpath(resolve(realRoot, input || "."));
if (!within(realRoot, target))
throw new CoreError(
"WORKSPACE_PATH_FORBIDDEN",
"不能访问工作区之外的路径",
);
return target;
} catch (cause) {
if (cause instanceof CoreError) throw cause;
throw new CoreError("FILE_UNAVAILABLE", "文件不存在或无法访问");
}
}
function validateRelativePath(path: string): void {
if (path.includes("\0") || isAbsolute(path))
throw new CoreError(
"WORKSPACE_PATH_FORBIDDEN",
"只能使用工作区内的相对路径",
);
if (path.split(/[\\/]/u).includes(".."))
throw new CoreError("WORKSPACE_PATH_FORBIDDEN", "路径不能包含父目录跳转");
}
function within(root: string, target: string): boolean {
return target === root || target.startsWith(`${root}${sep}`);
}
function toRelative(root: string, target: string): string {
return relative(root, target).split(sep).join("/");
}
async function collectFiles(root: string): Promise<string[]> {
const realRoot = await safeExistingPath(root, "");
const result: string[] = [];
const visit = async (directory: string) => {
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (entry.isSymbolicLink()) continue;
const absolute = resolve(directory, entry.name);
if (entry.isDirectory()) {
if (!SKIPPED_DIRECTORIES.has(entry.name)) await visit(absolute);
} else if (entry.isFile()) result.push(toRelative(realRoot, absolute));
if (result.length >= 5_000) return;
}
};
await visit(realRoot);
return result;
}
async function isText(path: string): Promise<boolean> {
const buffer = await readFile(path);
if (buffer.includes(0)) return false;
try {
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
return true;
} catch {
return false;
}
}
function mediaTypeFor(path: string): string {
const types: Record<string, string> = {
".css": "text/css",
".csv": "text/csv",
".html": "text/html",
".js": "text/javascript",
".json": "application/json",
".md": "text/markdown",
".ts": "text/typescript",
".txt": "text/plain",
".yaml": "application/yaml",
".yml": "application/yaml",
};
return types[extname(path).toLocaleLowerCase()] ?? "application/octet-stream";
}

View File

@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { LocalWorkspaceResolver } from ".";
import { LocalWorkspaceFiles, LocalWorkspaceResolver } from ".";
describe("LocalWorkspaceResolver", () => {
test("只接受实际存在且可读写的绝对目录", async () => {
@ -21,3 +21,70 @@ describe("LocalWorkspaceResolver", () => {
}
});
});
describe("LocalWorkspaceFiles", () => {
test("列出、搜索并读取工作区文本", async () => {
const root = await mkdtemp(join(tmpdir(), "great-agent-files-"));
try {
await mkdir(join(root, "docs"));
await writeFile(
join(root, "docs", "说明.md"),
"第一行\n需要搜索的内容\n",
);
const files = new LocalWorkspaceFiles();
expect(await files.list(root)).toEqual([
{ path: "docs", name: "docs", kind: "directory" },
]);
expect((await files.search(root, "搜索"))[0]).toMatchObject({
path: "docs/说明.md",
line: 2,
matchedBy: "content",
});
expect(await files.readText(root, "docs/说明.md")).toContain("第一行");
} finally {
await rm(root, { recursive: true });
}
});
test("拒绝绝对路径、父目录跳转和符号链接逃逸", async () => {
const root = await mkdtemp(join(tmpdir(), "great-agent-safe-"));
const outside = await mkdtemp(join(tmpdir(), "great-agent-outside-"));
try {
await writeFile(join(outside, "secret.txt"), "secret");
await symlink(join(outside, "secret.txt"), join(root, "shortcut.txt"));
const files = new LocalWorkspaceFiles();
await expect(files.readText(root, "../secret.txt")).rejects.toMatchObject(
{
code: "WORKSPACE_PATH_FORBIDDEN",
},
);
await expect(
files.readText(root, join(outside, "secret.txt")),
).rejects.toMatchObject({
code: "WORKSPACE_PATH_FORBIDDEN",
});
await expect(files.readText(root, "shortcut.txt")).rejects.toMatchObject({
code: "WORKSPACE_PATH_FORBIDDEN",
});
} finally {
await rm(root, { recursive: true });
await rm(outside, { recursive: true });
}
});
test("二进制文件返回明确的不支持状态", async () => {
const root = await mkdtemp(join(tmpdir(), "great-agent-binary-"));
try {
await writeFile(join(root, "image.bin"), new Uint8Array([1, 0, 2]));
const files = new LocalWorkspaceFiles();
expect((await files.inspect(root, "image.bin")).readableAsText).toBe(
false,
);
await expect(files.readText(root, "image.bin")).rejects.toMatchObject({
code: "FILE_TYPE_UNSUPPORTED",
});
} finally {
await rm(root, { recursive: true });
}
});
});

View File

@ -94,33 +94,40 @@ function toModelMessages(
request: ModelRequest,
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
const messages = request.messages.map(toModelMessage);
if (!request.continuation) return messages;
return [
...messages,
{
role: "assistant",
content: null,
tool_calls: [
{
id: request.continuation.toolCallId,
type: "function",
function: {
name: request.continuation.toolName,
arguments: request.continuation.toolArguments,
for (const continuation of request.continuations ?? []) {
messages.push(
{
role: "assistant",
content: null,
tool_calls: [
{
id: continuation.toolCallId,
type: "function",
function: {
name: continuation.toolName,
arguments: continuation.toolArguments,
},
},
},
],
},
{
role: "tool",
tool_call_id: request.continuation.toolCallId,
content: request.continuation.result,
},
];
],
},
{
role: "tool",
tool_call_id: continuation.toolCallId,
content: continuation.result,
},
);
}
return messages;
}
function toModelMessage(
message: Message,
): OpenAI.Chat.Completions.ChatCompletionMessageParam {
return { role: message.role, content: message.content };
const attachments = message.attachments
.map((attachment) => `- ${attachment.path}${attachment.status}`)
.join("\n");
const content = attachments
? `${message.content}${message.content ? "\n\n" : ""}[附件]\n${attachments}`
: message.content;
return { role: message.role, content };
}

View File

@ -12,6 +12,9 @@ export const runEventSchema = z.object({
"interaction.requested",
"interaction.resolved",
"interaction.cancelled",
"tool.started",
"tool.completed",
"tool.failed",
"run.completed",
"run.failed",
"run.cancelled",

View File

@ -30,3 +30,9 @@ export {
type InteractionResponse,
type ResolvedInteractionResponse,
} from "./responses/interaction";
export {
workspaceEntrySchema,
workspaceSearchMatchSchema,
type WorkspaceEntryResponse,
type WorkspaceSearchMatchResponse,
} from "./responses/workspace";

View File

@ -1,20 +1,28 @@
import { z } from "zod";
export const startRunRequestSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("project"),
projectId: z.string().min(1),
message: z.string().trim().min(1).max(32_000),
}),
z.object({
kind: z.literal("ordinary"),
message: z.string().trim().min(1).max(32_000),
}),
z.object({
kind: z.literal("existing"),
conversationId: z.string().min(1),
message: z.string().trim().min(1).max(32_000),
}),
]);
export const startRunRequestSchema = z
.discriminatedUnion("kind", [
z.object({
kind: z.literal("project"),
projectId: z.string().min(1),
message: z.string().trim().max(32_000),
attachments: z.array(z.string().min(1)).max(10).default([]),
}),
z.object({
kind: z.literal("ordinary"),
message: z.string().trim().max(32_000),
attachments: z.array(z.string().min(1)).max(10).default([]),
}),
z.object({
kind: z.literal("existing"),
conversationId: z.string().min(1),
message: z.string().trim().max(32_000),
attachments: z.array(z.string().min(1)).max(10).default([]),
}),
])
.superRefine((value, context) => {
if (!value.message && value.attachments.length === 0)
context.addIssue({ code: "custom", message: "请输入消息内容或添加附件" });
});
export type StartRunRequest = z.infer<typeof startRunRequestSchema>;

View File

@ -4,6 +4,16 @@ export const messageSchema = z.object({
id: z.string(),
role: z.enum(["user", "assistant"]),
content: z.string(),
attachments: z.array(
z.object({
name: z.string(),
path: z.string(),
mediaType: z.string(),
status: z.enum(["available", "unavailable", "unsupported"]),
checkedAt: z.string(),
reason: z.string().optional(),
}),
),
createdAt: z.string(),
runId: z.string(),
});

View 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
>;