feat: 会话管理与个人设置
This commit is contained in:
parent
6f029c49ab
commit
b1571f2052
@ -47,10 +47,10 @@
|
||||
状态: "已完成"
|
||||
- 编号: "F-008"
|
||||
名称: "会话管理与个人设置"
|
||||
状态: "进行中"
|
||||
状态: "已完成"
|
||||
- 编号: "F-009"
|
||||
名称: "Claude Desktop 视觉与交互收口"
|
||||
状态: "待开始"
|
||||
状态: "进行中"
|
||||
- 编号: "F-010"
|
||||
名称: "恢复、边界与发布前加固"
|
||||
状态: "待开始"
|
||||
|
||||
@ -10,12 +10,14 @@ import type {
|
||||
ProjectService,
|
||||
InteractionService,
|
||||
WorkspaceService,
|
||||
SettingsService,
|
||||
} 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";
|
||||
import { createSettingsRoutes } from "../routes/settings";
|
||||
|
||||
export function createApp(
|
||||
logger: Logger,
|
||||
@ -25,6 +27,7 @@ export function createApp(
|
||||
projects?: ProjectService,
|
||||
interactions?: InteractionService,
|
||||
workspace?: WorkspaceService,
|
||||
settings?: SettingsService,
|
||||
): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", requestId);
|
||||
@ -39,5 +42,6 @@ export function createApp(
|
||||
if (interactions && runs && runRegistry)
|
||||
app.route("/api", createInteractionRoutes(interactions, runs, runRegistry));
|
||||
if (workspace) app.route("/api", createWorkspaceRoutes(workspace));
|
||||
if (settings) app.route("/api", createSettingsRoutes(settings));
|
||||
return app;
|
||||
}
|
||||
|
||||
@ -27,7 +27,8 @@ export function createErrorHandler(logger: Logger): ErrorHandler {
|
||||
? 404
|
||||
: error.code === "PROJECT_RUN_ACTIVE" ||
|
||||
error.code === "INTERACTION_ALREADY_RESOLVED" ||
|
||||
error.code === "RUN_ALREADY_ACTIVE"
|
||||
error.code === "RUN_ALREADY_ACTIVE" ||
|
||||
error.code === "CONVERSATION_RUN_ACTIVE"
|
||||
? 409
|
||||
: 400;
|
||||
return context.json(
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
FileRunRepository,
|
||||
FileProjectRepository,
|
||||
FileInteractionRepository,
|
||||
FileSettingsRepository,
|
||||
createDataLayout,
|
||||
ensureDataLayout,
|
||||
} from "@great-agent/local-data";
|
||||
@ -15,6 +16,7 @@ import {
|
||||
CoreError,
|
||||
type ModelPort,
|
||||
WorkspaceService,
|
||||
SettingsService,
|
||||
} from "@great-agent/agent-core";
|
||||
import { DeepSeekModelAdapter } from "@great-agent/model-deepseek";
|
||||
import { createApp } from "./composition/create-app";
|
||||
@ -39,6 +41,7 @@ const runRepository = new FileRunRepository(layout);
|
||||
const interactionRepository = new FileInteractionRepository(layout);
|
||||
const conversations = new ConversationService({
|
||||
conversations: conversationRepository,
|
||||
runs: runRepository,
|
||||
clock,
|
||||
ids,
|
||||
});
|
||||
@ -55,11 +58,21 @@ const interactions = new InteractionService({
|
||||
clock,
|
||||
ids,
|
||||
});
|
||||
const workspaceResolver = new LocalWorkspaceResolver();
|
||||
const settings = new SettingsService({
|
||||
settings: new FileSettingsRepository(layout),
|
||||
workspaces: workspaceResolver,
|
||||
clock,
|
||||
initialWorkspaceRoot: environment.defaultWorkspaceRoot,
|
||||
model: environment.deepSeekModel,
|
||||
modelConfigured: Boolean(environment.deepSeekApiKey),
|
||||
version: "0.1.0",
|
||||
});
|
||||
const workspace = new WorkspaceService(
|
||||
new LocalWorkspaceFiles(),
|
||||
conversations,
|
||||
projects,
|
||||
environment.defaultWorkspaceRoot,
|
||||
() => settings.getDefaultWorkspaceRoot(),
|
||||
clock,
|
||||
);
|
||||
const model: ModelPort = environment.deepSeekApiKey
|
||||
@ -96,6 +109,7 @@ const app = createApp(
|
||||
projects,
|
||||
interactions,
|
||||
workspace,
|
||||
settings,
|
||||
);
|
||||
mountStaticWeb(app, resolve(import.meta.dir, "../../web/dist"));
|
||||
|
||||
|
||||
@ -27,6 +27,12 @@ class MemoryRepository implements ConversationRepository {
|
||||
async create(value: Conversation) {
|
||||
this.values.set(value.id, value);
|
||||
}
|
||||
async update(value: Conversation) {
|
||||
this.values.set(value.id, value);
|
||||
}
|
||||
async delete(id: string) {
|
||||
this.values.delete(id);
|
||||
}
|
||||
async appendMessage(id: string, message: Conversation["messages"][number]) {
|
||||
const current = this.values.get(id);
|
||||
if (!current) throw new Error("not found");
|
||||
@ -50,6 +56,16 @@ function createTestApp() {
|
||||
return createApp(pino({ enabled: false }), service);
|
||||
}
|
||||
|
||||
function createTestFixture() {
|
||||
let id = 0;
|
||||
const service = new ConversationService({
|
||||
conversations: new MemoryRepository(),
|
||||
clock: { now: () => new Date("2026-08-14T08:00:00Z") },
|
||||
ids: { create: () => `manage_${++id}` },
|
||||
});
|
||||
return { service, app: createApp(pino({ enabled: false }), service) };
|
||||
}
|
||||
|
||||
describe("普通会话路由", () => {
|
||||
test("会话路由只提供查询,消息必须通过 Run 创建", async () => {
|
||||
const app = createTestApp();
|
||||
@ -61,4 +77,26 @@ describe("普通会话路由", () => {
|
||||
});
|
||||
expect(directWrite.status).toBe(404);
|
||||
});
|
||||
|
||||
test("通过接口重命名和删除已有会话", async () => {
|
||||
const { app, service } = createTestFixture();
|
||||
const conversation = await service.createWithFirstMessage(
|
||||
"原名称",
|
||||
"run_1",
|
||||
);
|
||||
const renamed = await app.request(`/api/conversations/${conversation.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ title: "新名称" }),
|
||||
});
|
||||
expect(renamed.status).toBe(200);
|
||||
expect((await renamed.json()).title).toBe("新名称");
|
||||
const removed = await app.request(`/api/conversations/${conversation.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(removed.status).toBe(204);
|
||||
expect(service.getConversation(conversation.id)).rejects.toMatchObject({
|
||||
code: "CONVERSATION_NOT_FOUND",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,6 +3,7 @@ import type {
|
||||
ConversationService,
|
||||
WorkspaceService,
|
||||
} from "@great-agent/agent-core";
|
||||
import { renameConversationRequestSchema } from "@great-agent/web-contracts";
|
||||
|
||||
export function createConversationRoutes(
|
||||
service: ConversationService,
|
||||
@ -20,5 +21,17 @@ export function createConversationRoutes(
|
||||
: conversation,
|
||||
);
|
||||
});
|
||||
routes.patch("/conversations/:id", async (context) => {
|
||||
const input = renameConversationRequestSchema.parse(
|
||||
await context.req.json(),
|
||||
);
|
||||
return context.json(
|
||||
await service.rename(context.req.param("id"), input.title),
|
||||
);
|
||||
});
|
||||
routes.delete("/conversations/:id", async (context) => {
|
||||
await service.delete(context.req.param("id"));
|
||||
return context.body(null, 204);
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
|
||||
@ -41,6 +41,12 @@ class Conversations implements ConversationRepository {
|
||||
async create(value: Conversation) {
|
||||
this.value = value;
|
||||
}
|
||||
async update(value: Conversation) {
|
||||
this.value = value;
|
||||
}
|
||||
async delete(id: string) {
|
||||
if (this.value?.id === id) this.value = null;
|
||||
}
|
||||
async appendMessage(_id: string, message: Conversation["messages"][number]) {
|
||||
if (!this.value) throw new Error("not found");
|
||||
this.value = { ...this.value, messages: [...this.value.messages, message] };
|
||||
|
||||
13
apps/web-server/src/routes/settings.ts
Normal file
13
apps/web-server/src/routes/settings.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Hono } from "hono";
|
||||
import type { SettingsService } from "@great-agent/agent-core";
|
||||
import { updateSettingsRequestSchema } from "@great-agent/web-contracts";
|
||||
|
||||
export function createSettingsRoutes(service: SettingsService): Hono {
|
||||
const routes = new Hono();
|
||||
routes.get("/settings", async (context) => context.json(await service.get()));
|
||||
routes.patch("/settings", async (context) => {
|
||||
const input = updateSettingsRequestSchema.parse(await context.req.json());
|
||||
return context.json(await service.update(input));
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
@ -33,3 +33,21 @@ export async function listProjectConversations(
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function renameConversation(
|
||||
id: string,
|
||||
title: string,
|
||||
): Promise<ConversationResponse> {
|
||||
return conversationSchema.parse(
|
||||
await request(`/api/conversations/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ title }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteConversation(id: string): Promise<void> {
|
||||
await request(`/api/conversations/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
21
apps/web/src/api/settings.ts
Normal file
21
apps/web/src/api/settings.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import {
|
||||
settingsSchema,
|
||||
type SettingsResponse,
|
||||
} from "@great-agent/web-contracts";
|
||||
import { request } from "./request";
|
||||
|
||||
export async function getSettings(): Promise<SettingsResponse> {
|
||||
return settingsSchema.parse(await request("/api/settings"));
|
||||
}
|
||||
|
||||
export async function updateSettings(input: {
|
||||
defaultWorkspaceRoot: string;
|
||||
showToolDetails: boolean;
|
||||
}): Promise<SettingsResponse> {
|
||||
return settingsSchema.parse(
|
||||
await request("/api/settings", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
InteractionAnswerRequest,
|
||||
InteractionResponse,
|
||||
ProjectResponse,
|
||||
SettingsResponse,
|
||||
} from "@great-agent/web-contracts";
|
||||
|
||||
export type Selection =
|
||||
@ -15,6 +16,9 @@ export type Selection =
|
||||
| { kind: "project-draft"; projectId: string }
|
||||
| { kind: "project-rename"; projectId: string }
|
||||
| { kind: "project-delete"; projectId: string }
|
||||
| { kind: "conversation-rename"; conversationId: string }
|
||||
| { kind: "conversation-delete"; conversationId: string }
|
||||
| { kind: "settings" }
|
||||
| { kind: "conversation"; id: string };
|
||||
|
||||
export type AppController = Readonly<{
|
||||
@ -31,6 +35,7 @@ export type AppController = Readonly<{
|
||||
assistantDraft: State<string>;
|
||||
error: State<string>;
|
||||
toolActivities: State<ToolActivity[]>;
|
||||
settings: State<SettingsResponse | null>;
|
||||
initialize(): Promise<void>;
|
||||
startNewTask(): void;
|
||||
startProjectCreation(): void;
|
||||
@ -42,6 +47,15 @@ export type AppController = Readonly<{
|
||||
startProjectDelete(id: string): void;
|
||||
deleteProject(id: string): Promise<void>;
|
||||
openConversation(id: string): Promise<void>;
|
||||
startConversationRename(id: string): void;
|
||||
renameConversation(id: string, title: string): Promise<void>;
|
||||
startConversationDelete(id: string): void;
|
||||
deleteConversation(id: string): Promise<void>;
|
||||
openSettings(): Promise<void>;
|
||||
saveSettings(
|
||||
defaultWorkspaceRoot: string,
|
||||
showToolDetails: boolean,
|
||||
): Promise<void>;
|
||||
send(content: string, attachments?: readonly string[]): Promise<boolean>;
|
||||
answerInteraction(
|
||||
id: string,
|
||||
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
InteractionResponse,
|
||||
AgentRunResponse,
|
||||
ProjectResponse,
|
||||
SettingsResponse,
|
||||
} from "@great-agent/web-contracts";
|
||||
import {
|
||||
getConversation,
|
||||
@ -35,6 +36,8 @@ import type {
|
||||
ToolActivity,
|
||||
} from "./app-controller-types";
|
||||
import { collectRunEvent } from "./controller-run-events";
|
||||
import { createManagementActions } from "./controller-management";
|
||||
import { getSettings } from "../api/settings";
|
||||
export type { AppController, Selection } from "./app-controller-types";
|
||||
|
||||
export function createAppController(): AppController {
|
||||
@ -53,15 +56,17 @@ export function createAppController(): AppController {
|
||||
const assistantDraft = van.state("");
|
||||
const error = van.state("");
|
||||
const toolActivities = van.state<ToolActivity[]>([]);
|
||||
const settings = van.state<SettingsResponse | null>(null);
|
||||
|
||||
async function initialize() {
|
||||
loading.val = true;
|
||||
error.val = "";
|
||||
toolActivities.val = [];
|
||||
try {
|
||||
[recent.val, projects.val] = await Promise.all([
|
||||
[recent.val, projects.val, settings.val] = await Promise.all([
|
||||
listConversations(),
|
||||
listProjects(),
|
||||
getSettings(),
|
||||
]);
|
||||
} catch (cause) {
|
||||
error.val = readMessage(cause);
|
||||
@ -326,6 +331,16 @@ export function createAppController(): AppController {
|
||||
}
|
||||
}
|
||||
|
||||
const management = createManagementActions({
|
||||
recent,
|
||||
projectConversations,
|
||||
active,
|
||||
settings,
|
||||
selection,
|
||||
select,
|
||||
perform,
|
||||
});
|
||||
|
||||
return {
|
||||
selection,
|
||||
recent,
|
||||
@ -340,6 +355,7 @@ export function createAppController(): AppController {
|
||||
assistantDraft,
|
||||
error,
|
||||
toolActivities,
|
||||
settings,
|
||||
initialize,
|
||||
startNewTask,
|
||||
startProjectCreation,
|
||||
@ -351,6 +367,7 @@ export function createAppController(): AppController {
|
||||
startProjectDelete,
|
||||
deleteProject: deleteExistingProject,
|
||||
openConversation,
|
||||
...management,
|
||||
send,
|
||||
answerInteraction,
|
||||
cancelInteraction,
|
||||
|
||||
110
apps/web/src/app/controller-management.ts
Normal file
110
apps/web/src/app/controller-management.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import type { State } from "vanjs-core";
|
||||
import type {
|
||||
ConversationResponse,
|
||||
ConversationSummaryResponse,
|
||||
SettingsResponse,
|
||||
} from "@great-agent/web-contracts";
|
||||
import { deleteConversation, renameConversation } from "../api/conversations";
|
||||
import { getSettings, updateSettings } from "../api/settings";
|
||||
import type { Selection } from "./app-controller-types";
|
||||
|
||||
type Dependencies = Readonly<{
|
||||
recent: State<ConversationSummaryResponse[]>;
|
||||
projectConversations: State<Record<string, ConversationSummaryResponse[]>>;
|
||||
active: State<ConversationResponse | null>;
|
||||
settings: State<SettingsResponse | null>;
|
||||
selection: State<Selection>;
|
||||
select(selection: Selection): void;
|
||||
perform(action: () => Promise<void>): Promise<void>;
|
||||
}>;
|
||||
|
||||
export function createManagementActions(dependencies: Dependencies) {
|
||||
function startConversationRename(id: string) {
|
||||
dependencies.selection.val = {
|
||||
kind: "conversation-rename",
|
||||
conversationId: id,
|
||||
};
|
||||
}
|
||||
|
||||
function startConversationDelete(id: string) {
|
||||
dependencies.selection.val = {
|
||||
kind: "conversation-delete",
|
||||
conversationId: id,
|
||||
};
|
||||
}
|
||||
|
||||
async function renameExistingConversation(id: string, title: string) {
|
||||
await dependencies.perform(async () => {
|
||||
const updated = await renameConversation(id, title);
|
||||
const summary = toSummary(updated);
|
||||
dependencies.recent.val = dependencies.recent.val.map((item) =>
|
||||
item.id === id ? summary : item,
|
||||
);
|
||||
dependencies.projectConversations.val = Object.fromEntries(
|
||||
Object.entries(dependencies.projectConversations.val).map(
|
||||
([projectId, conversations]) => [
|
||||
projectId,
|
||||
conversations.map((item) => (item.id === id ? summary : item)),
|
||||
],
|
||||
),
|
||||
);
|
||||
dependencies.active.val = updated;
|
||||
dependencies.select({ kind: "conversation", id });
|
||||
dependencies.active.val = updated;
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteExistingConversation(id: string) {
|
||||
await dependencies.perform(async () => {
|
||||
await deleteConversation(id);
|
||||
dependencies.recent.val = dependencies.recent.val.filter(
|
||||
(item) => item.id !== id,
|
||||
);
|
||||
dependencies.projectConversations.val = Object.fromEntries(
|
||||
Object.entries(dependencies.projectConversations.val).map(
|
||||
([projectId, conversations]) => [
|
||||
projectId,
|
||||
conversations.filter((item) => item.id !== id),
|
||||
],
|
||||
),
|
||||
);
|
||||
dependencies.select({ kind: "none" });
|
||||
});
|
||||
}
|
||||
|
||||
async function openSettings() {
|
||||
await dependencies.perform(async () => {
|
||||
dependencies.settings.val = await getSettings();
|
||||
dependencies.select({ kind: "settings" });
|
||||
});
|
||||
}
|
||||
|
||||
async function saveSettings(
|
||||
defaultWorkspaceRoot: string,
|
||||
showToolDetails: boolean,
|
||||
) {
|
||||
await dependencies.perform(async () => {
|
||||
dependencies.settings.val = await updateSettings({
|
||||
defaultWorkspaceRoot,
|
||||
showToolDetails,
|
||||
});
|
||||
dependencies.select({ kind: "settings" });
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
startConversationRename,
|
||||
renameConversation: renameExistingConversation,
|
||||
startConversationDelete,
|
||||
deleteConversation: deleteExistingConversation,
|
||||
openSettings,
|
||||
saveSettings,
|
||||
};
|
||||
}
|
||||
|
||||
function toSummary(
|
||||
conversation: ConversationResponse,
|
||||
): ConversationSummaryResponse {
|
||||
const { messages: _messages, ...summary } = conversation;
|
||||
return summary;
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
.conversation-management {
|
||||
width: min(520px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
padding-top: 90px;
|
||||
}
|
||||
.management-icon {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
color: #d7a08f;
|
||||
background: #3d302c;
|
||||
}
|
||||
.management-icon.danger {
|
||||
color: #e2a398;
|
||||
background: #4b2d29;
|
||||
}
|
||||
.conversation-management h1 {
|
||||
margin: 18px 0 8px;
|
||||
font: 600 22px var(--font-serif);
|
||||
}
|
||||
.conversation-management p {
|
||||
color: #928e87;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.conversation-management label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-top: 24px;
|
||||
color: #aaa69f;
|
||||
font-size: 10px;
|
||||
}
|
||||
.conversation-management input,
|
||||
.conversation-name {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #494641;
|
||||
border-radius: 8px;
|
||||
color: #e3dfd8;
|
||||
background: #292826;
|
||||
}
|
||||
.conversation-name {
|
||||
margin-top: 20px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.management-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.management-actions button {
|
||||
padding: 8px 14px;
|
||||
border-radius: 7px;
|
||||
color: #bbb6af;
|
||||
background: #363431;
|
||||
}
|
||||
.management-actions .primary {
|
||||
color: #fff;
|
||||
background: #a95e4d;
|
||||
}
|
||||
.management-actions .danger-button {
|
||||
color: #fff;
|
||||
background: #974f44;
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import van from "vanjs-core";
|
||||
import type { AppController } from "../../app/app-controller";
|
||||
import "./conversation-management-panel.css";
|
||||
|
||||
const { button, div, form, h1, input, label, p, span } = van.tags;
|
||||
|
||||
export function ConversationManagementPanel(
|
||||
controller: AppController,
|
||||
): HTMLElement {
|
||||
const conversation = controller.active.val;
|
||||
const selection = controller.selection.val;
|
||||
if (!conversation || !("conversationId" in selection))
|
||||
return div({ class: "conversation-management" }, p("会话不存在"));
|
||||
if (selection.kind === "conversation-delete")
|
||||
return div(
|
||||
{ class: "conversation-management" },
|
||||
span({ class: "management-icon danger" }, "×"),
|
||||
h1("删除会话?"),
|
||||
p("将删除该会话的消息、附件元数据和运行记录。此操作不会修改工作区文件。"),
|
||||
div({ class: "conversation-name" }, conversation.title),
|
||||
div(
|
||||
{ class: "management-actions" },
|
||||
button(
|
||||
{ onclick: () => void controller.openConversation(conversation.id) },
|
||||
"取消",
|
||||
),
|
||||
button(
|
||||
{
|
||||
class: "danger-button",
|
||||
onclick: () => void controller.deleteConversation(conversation.id),
|
||||
},
|
||||
"确认删除",
|
||||
),
|
||||
),
|
||||
);
|
||||
const title = van.state(conversation.title);
|
||||
return form(
|
||||
{
|
||||
class: "conversation-management",
|
||||
onsubmit: (event: Event) => {
|
||||
event.preventDefault();
|
||||
void controller.renameConversation(conversation.id, title.val);
|
||||
},
|
||||
},
|
||||
span({ class: "management-icon" }, "✎"),
|
||||
h1("重命名会话"),
|
||||
p("修改侧栏中显示的会话名称。"),
|
||||
label(
|
||||
"会话名称",
|
||||
input({
|
||||
value: title,
|
||||
maxlength: 80,
|
||||
autofocus: true,
|
||||
oninput: (event: Event) => {
|
||||
title.val = (event.target as HTMLInputElement).value;
|
||||
},
|
||||
}),
|
||||
),
|
||||
div(
|
||||
{ class: "management-actions" },
|
||||
button(
|
||||
{
|
||||
type: "button",
|
||||
onclick: () => void controller.openConversation(conversation.id),
|
||||
},
|
||||
"取消",
|
||||
),
|
||||
button(
|
||||
{ class: "primary", type: "submit", disabled: () => !title.val.trim() },
|
||||
"保存",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,8 @@ import { ProjectPanel } from "../projects/project-panel";
|
||||
import { InteractionCard } from "../interactions/interaction-card";
|
||||
import { RunStatus } from "../runs/run-status";
|
||||
import { FilePicker } from "../files/file-picker";
|
||||
import { ConversationManagementPanel } from "./conversation-management-panel";
|
||||
import { SettingsPanel } from "../settings/settings-panel";
|
||||
|
||||
const { article, button, div, h1, header, main, p, span, textarea } = van.tags;
|
||||
|
||||
@ -31,87 +33,100 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
||||
div({ class: "pane-content" }, () =>
|
||||
controller.selection.val.kind.startsWith("project-")
|
||||
? ProjectPanel(controller)
|
||||
: controller.selection.val.kind === "conversation" &&
|
||||
controller.active.val
|
||||
? div(
|
||||
{ class: "conversation-view" },
|
||||
h1({ class: "conversation-title" }, controller.active.val.title),
|
||||
div(
|
||||
{ class: "message-list" },
|
||||
controller.active.val.messages.map((message) =>
|
||||
: controller.selection.val.kind.startsWith("conversation-")
|
||||
? ConversationManagementPanel(controller)
|
||||
: controller.selection.val.kind === "settings"
|
||||
? SettingsPanel(controller)
|
||||
: controller.selection.val.kind === "conversation" &&
|
||||
controller.active.val
|
||||
? div(
|
||||
{ class: "conversation-view" },
|
||||
h1(
|
||||
{ class: "conversation-title" },
|
||||
controller.active.val.title,
|
||||
),
|
||||
div(
|
||||
{ class: "timeline-entry" },
|
||||
article(
|
||||
{ class: `message ${message.role}` },
|
||||
span(
|
||||
{ class: `message-role ${message.role}-role` },
|
||||
message.role === "user" ? "你" : "G",
|
||||
{ class: "message-list" },
|
||||
controller.active.val.messages.map((message) =>
|
||||
div(
|
||||
{ class: "timeline-entry" },
|
||||
article(
|
||||
{ class: `message ${message.role}` },
|
||||
span(
|
||||
{ class: `message-role ${message.role}-role` },
|
||||
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(
|
||||
(interaction) =>
|
||||
interaction.messageId === message.id,
|
||||
)
|
||||
.map((interaction) =>
|
||||
InteractionCard(controller, interaction),
|
||||
),
|
||||
),
|
||||
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"
|
||||
? " · 不支持读取"
|
||||
: "",
|
||||
),
|
||||
),
|
||||
),
|
||||
() =>
|
||||
controller.assistantDraft.val
|
||||
? article(
|
||||
{ class: "message assistant streaming" },
|
||||
span({ class: "message-role assistant-role" }, "G"),
|
||||
p(controller.assistantDraft),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
controller.interactions.val
|
||||
.filter(
|
||||
(interaction) => interaction.messageId === message.id,
|
||||
)
|
||||
.map((interaction) =>
|
||||
InteractionCard(controller, interaction),
|
||||
),
|
||||
),
|
||||
),
|
||||
() =>
|
||||
controller.assistantDraft.val
|
||||
? article(
|
||||
{ class: "message assistant streaming" },
|
||||
span({ class: "message-role assistant-role" }, "G"),
|
||||
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"
|
||||
? "✓"
|
||||
: "!",
|
||||
{
|
||||
class: "tool-activities",
|
||||
hidden: () =>
|
||||
controller.settings.val?.showToolDetails === false,
|
||||
},
|
||||
...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),
|
||||
),
|
||||
),
|
||||
span(toolName(tool.name)),
|
||||
span(tool.detail),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
() => RunStatus(controller),
|
||||
)
|
||||
: div(
|
||||
{ class: "onboarding" },
|
||||
h1(span({ class: "spark" }, "✳"), " 今天想完成什么?"),
|
||||
p("从一个问题、想法或具体任务开始。"),
|
||||
),
|
||||
() => RunStatus(controller),
|
||||
)
|
||||
: div(
|
||||
{ class: "onboarding" },
|
||||
h1(span({ class: "spark" }, "✳"), " 今天想完成什么?"),
|
||||
p("从一个问题、想法或具体任务开始。"),
|
||||
),
|
||||
),
|
||||
div(
|
||||
{ class: "composer-slot", hidden: () => !canCompose(controller) },
|
||||
|
||||
@ -64,7 +64,7 @@
|
||||
background: #1c1c1b;
|
||||
}
|
||||
.new-task:hover,
|
||||
.recent-list button:hover,
|
||||
.conversation-open:hover,
|
||||
.project-open:hover {
|
||||
background: #323230;
|
||||
}
|
||||
@ -111,7 +111,7 @@
|
||||
color: #96928c;
|
||||
background: transparent;
|
||||
}
|
||||
.project-conversations button {
|
||||
.project-conversations .conversation-open {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
padding: 5px 8px 5px 27px;
|
||||
@ -157,7 +157,14 @@
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.recent-list button {
|
||||
.conversation-item {
|
||||
position: relative;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.conversation-item.selected {
|
||||
background: #323230;
|
||||
}
|
||||
.recent-list .conversation-open {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
padding: 6px 10px 6px 20px;
|
||||
@ -169,7 +176,7 @@
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
.recent-list button::before {
|
||||
.recent-list .conversation-open::before {
|
||||
content: "○";
|
||||
margin-left: -13px;
|
||||
margin-right: 6px;
|
||||
@ -185,6 +192,12 @@
|
||||
border-top: 1px solid #302f2e;
|
||||
color: #c8c5bf;
|
||||
font-size: 11px;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
.sidebar-footer:hover {
|
||||
background: #302f2d;
|
||||
}
|
||||
.sidebar-footer div {
|
||||
display: grid;
|
||||
@ -204,6 +217,20 @@
|
||||
background: #ce7c64;
|
||||
font-weight: 700;
|
||||
}
|
||||
.conversation-actions {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 3px;
|
||||
display: flex;
|
||||
background: #323230;
|
||||
}
|
||||
.conversation-actions button {
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
padding: 0;
|
||||
color: #99958e;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.sidebar {
|
||||
|
||||
@ -44,17 +44,15 @@ export function Sidebar(controller: AppController): HTMLElement {
|
||||
: ul(
|
||||
{ class: "recent-list" },
|
||||
controller.recent.val.map((item) =>
|
||||
li(
|
||||
button(
|
||||
{ onclick: () => controller.openConversation(item.id) },
|
||||
item.title,
|
||||
),
|
||||
),
|
||||
ConversationItem(controller, item.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
div(
|
||||
{ class: "sidebar-footer" },
|
||||
button(
|
||||
{
|
||||
class: "sidebar-footer",
|
||||
onclick: () => void controller.openSettings(),
|
||||
},
|
||||
span({ class: "avatar" }, "G"),
|
||||
div("Great Agent 2", span("本地个人助手")),
|
||||
),
|
||||
@ -116,15 +114,7 @@ function ProjectItem(
|
||||
{ class: "project-conversations" },
|
||||
(controller.projectConversations.val[project.id] ?? []).map(
|
||||
(conversation) =>
|
||||
li(
|
||||
button(
|
||||
{
|
||||
onclick: () =>
|
||||
controller.openConversation(conversation.id),
|
||||
},
|
||||
conversation.title,
|
||||
),
|
||||
),
|
||||
ConversationItem(controller, conversation.id, true),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
@ -132,6 +122,59 @@ function ProjectItem(
|
||||
});
|
||||
}
|
||||
|
||||
function ConversationItem(
|
||||
controller: AppController,
|
||||
conversationId: string,
|
||||
nested = false,
|
||||
): HTMLElement {
|
||||
const conversation = [
|
||||
...controller.recent.val,
|
||||
...Object.values(controller.projectConversations.val).flat(),
|
||||
].find((item) => item.id === conversationId);
|
||||
if (!conversation) return li();
|
||||
const selected = selectedConversationId(controller) === conversation.id;
|
||||
return li(
|
||||
{
|
||||
class: `conversation-item${selected ? " selected" : ""}${nested ? " nested" : ""}`,
|
||||
},
|
||||
button(
|
||||
{
|
||||
class: "conversation-open",
|
||||
onclick: () => controller.openConversation(conversation.id),
|
||||
},
|
||||
conversation.title,
|
||||
),
|
||||
selected
|
||||
? div(
|
||||
{ class: "conversation-actions" },
|
||||
button(
|
||||
{
|
||||
title: "重命名会话",
|
||||
onclick: () =>
|
||||
controller.startConversationRename(conversation.id),
|
||||
},
|
||||
"✎",
|
||||
),
|
||||
button(
|
||||
{
|
||||
title: "删除会话",
|
||||
onclick: () =>
|
||||
controller.startConversationDelete(conversation.id),
|
||||
},
|
||||
"×",
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
function selectedConversationId(controller: AppController): string | null {
|
||||
const selection = controller.selection.val;
|
||||
if (selection.kind === "conversation") return selection.id;
|
||||
if ("conversationId" in selection) return selection.conversationId;
|
||||
return null;
|
||||
}
|
||||
|
||||
function selectedProjectId(controller: AppController): string | null {
|
||||
const selection = controller.selection.val;
|
||||
if ("projectId" in selection) return selection.projectId;
|
||||
|
||||
71
apps/web/src/features/settings/settings-panel.css
Normal file
71
apps/web/src/features/settings/settings-panel.css
Normal file
@ -0,0 +1,71 @@
|
||||
.settings-panel {
|
||||
width: min(560px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
padding-top: 70px;
|
||||
}
|
||||
.settings-icon {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #211f1d;
|
||||
background: #ce7c64;
|
||||
font-weight: 700;
|
||||
}
|
||||
.settings-panel h1 {
|
||||
margin: 18px 0 7px;
|
||||
font: 600 23px var(--font-serif);
|
||||
}
|
||||
.settings-panel > p {
|
||||
color: #8f8b84;
|
||||
font-size: 11px;
|
||||
}
|
||||
.settings-panel > label:not(.checkbox-setting) {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-top: 24px;
|
||||
color: #aaa69f;
|
||||
font-size: 10px;
|
||||
}
|
||||
.settings-panel input[type="text"],
|
||||
.settings-panel input:not([type]) {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #494641;
|
||||
border-radius: 8px;
|
||||
color: #e3dfd8;
|
||||
background: #292826;
|
||||
}
|
||||
.checkbox-setting {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
margin-top: 17px;
|
||||
color: #bbb6af;
|
||||
font-size: 11px;
|
||||
}
|
||||
.settings-summary {
|
||||
margin-top: 25px;
|
||||
padding: 12px;
|
||||
border: 1px solid #403e3a;
|
||||
border-radius: 9px;
|
||||
background: #292826;
|
||||
}
|
||||
.settings-summary p {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 5px 0;
|
||||
color: #8f8b84;
|
||||
font-size: 10px;
|
||||
}
|
||||
.settings-summary span {
|
||||
color: #cbc6bf;
|
||||
}
|
||||
.settings-save {
|
||||
float: right;
|
||||
margin-top: 18px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 7px;
|
||||
color: #fff;
|
||||
background: #a95e4d;
|
||||
}
|
||||
58
apps/web/src/features/settings/settings-panel.ts
Normal file
58
apps/web/src/features/settings/settings-panel.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import van from "vanjs-core";
|
||||
import type { AppController } from "../../app/app-controller";
|
||||
import "./settings-panel.css";
|
||||
|
||||
const { button, div, form, h1, input, label, p, span } = van.tags;
|
||||
|
||||
export function SettingsPanel(controller: AppController): HTMLElement {
|
||||
const current = controller.settings.val;
|
||||
if (!current) return div({ class: "settings-panel" }, p("正在加载设置…"));
|
||||
const root = van.state(current.defaultWorkspaceRoot);
|
||||
const showTools = van.state(current.showToolDetails);
|
||||
return form(
|
||||
{
|
||||
class: "settings-panel",
|
||||
onsubmit: (event: Event) => {
|
||||
event.preventDefault();
|
||||
void controller.saveSettings(root.val, showTools.val);
|
||||
},
|
||||
},
|
||||
span({ class: "settings-icon" }, "G"),
|
||||
h1("个人设置"),
|
||||
p("这些设置只保存在当前实例的本地数据目录中。"),
|
||||
label(
|
||||
"普通对话默认工作区",
|
||||
input({
|
||||
value: root,
|
||||
oninput: (event: Event) => {
|
||||
root.val = (event.target as HTMLInputElement).value;
|
||||
},
|
||||
}),
|
||||
),
|
||||
label(
|
||||
{ class: "checkbox-setting" },
|
||||
input({
|
||||
type: "checkbox",
|
||||
checked: showTools,
|
||||
onchange: (event: Event) => {
|
||||
showTools.val = (event.target as HTMLInputElement).checked;
|
||||
},
|
||||
}),
|
||||
span("显示文件工具执行详情"),
|
||||
),
|
||||
div(
|
||||
{ class: "settings-summary" },
|
||||
p("模型", span(current.model)),
|
||||
p("模型状态", span(current.modelConfigured ? "已配置" : "未配置")),
|
||||
p("应用版本", span(current.version)),
|
||||
),
|
||||
button(
|
||||
{
|
||||
class: "settings-save",
|
||||
type: "submit",
|
||||
disabled: () => !root.val.trim(),
|
||||
},
|
||||
"保存设置",
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -99,13 +99,17 @@
|
||||
|
||||
### F-008——会话管理与个人设置
|
||||
|
||||
- 状态:进行中
|
||||
- 状态:已完成(Agent 于 2026-08-14 验证通过,等待最终统一人工审核)
|
||||
- 用户可见结果:重命名和删除会话,查看并修改非敏感设置。
|
||||
- 主要验收:AC-003、AC-018。
|
||||
- 会话管理:普通会话和项目会话在选中后提供重命名与删除入口;重命名立即更新会话文件、侧栏和当前标题。删除前展示会话名称,并明确说明删除消息、附件元数据和运行记录但不修改工作区文件;有活动任务时拒绝删除,成功后普通或项目会话列表同步移除。
|
||||
- 个人设置:侧栏底部进入设置页,可查看和修改普通对话默认工作区及文件工具详情偏好;新工作区经服务端 `realpath` 和读写目录校验后保存到独立 `settings.json`,后续普通对话即时使用新目录。页面只显示 DeepSeek 模型名、是否已配置和应用版本,不返回或渲染模型密钥。
|
||||
- 自动化验证结果:2026-08-14 通过全仓类型检查、35 项测试和 121 个断言、生产构建、代码检查、格式检查、文件规模检查与架构依赖检查;覆盖会话重命名/删除接口、设置持久化、工作区规范化以及响应中不存在密钥字段。
|
||||
- 人工操作结果:2026-08-14 使用隔离数据目录和本机应用内浏览器完成;验证创建失败终态会话后重命名、刷新恢复名称、删除确认与列表清空;将默认工作区修改为 `/tmp` 并关闭工具详情后刷新,页面恢复规范化路径 `/private/tmp` 和关闭状态;隔离页面控制台无错误。
|
||||
|
||||
### F-009——Claude Desktop 视觉与交互收口
|
||||
|
||||
- 状态:待开始
|
||||
- 状态:进行中
|
||||
- 用户可见结果:全部已支持状态在目标视口下贴近确认后的参考界面。
|
||||
- 主要验收:AC-001、AC-002、AC-012、AC-013、AC-020、AC-024。
|
||||
|
||||
|
||||
12
packages/agent-core/src/domain/settings.ts
Normal file
12
packages/agent-core/src/domain/settings.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export type PersonalSettings = Readonly<{
|
||||
defaultWorkspaceRoot: string;
|
||||
showToolDetails: boolean;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
|
||||
export type SettingsView = PersonalSettings &
|
||||
Readonly<{
|
||||
model: string;
|
||||
modelConfigured: boolean;
|
||||
version: string;
|
||||
}>;
|
||||
@ -9,6 +9,7 @@ export type {
|
||||
} from "./domain/conversation";
|
||||
export type { AgentRun, AgentRunStatus, RunEvent } from "./domain/agent-run";
|
||||
export type { Project, ProjectView } from "./domain/project";
|
||||
export type { PersonalSettings, SettingsView } from "./domain/settings";
|
||||
export type {
|
||||
InteractionAnswer,
|
||||
InteractionKind,
|
||||
@ -26,6 +27,7 @@ export type {
|
||||
export type { RunRepository } from "./ports/run-repository";
|
||||
export type { ProjectRepository } from "./ports/project-repository";
|
||||
export type { InteractionRepository } from "./ports/interaction-repository";
|
||||
export type { SettingsRepository } from "./ports/settings-repository";
|
||||
export type {
|
||||
ClockPort,
|
||||
IdPort,
|
||||
@ -58,3 +60,7 @@ export {
|
||||
InteractionService,
|
||||
type InteractionServiceDependencies,
|
||||
} from "./use-cases/interaction-service";
|
||||
export {
|
||||
SettingsService,
|
||||
type SettingsServiceDependencies,
|
||||
} from "./use-cases/settings-service";
|
||||
|
||||
@ -13,5 +13,7 @@ export interface ConversationRepository {
|
||||
conversationId: string,
|
||||
message: Message,
|
||||
): Promise<Conversation>;
|
||||
update(conversation: Conversation): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
deleteByProject(projectId: string): Promise<void>;
|
||||
}
|
||||
|
||||
6
packages/agent-core/src/ports/settings-repository.ts
Normal file
6
packages/agent-core/src/ports/settings-repository.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import type { PersonalSettings } from "../domain/settings";
|
||||
|
||||
export interface SettingsRepository {
|
||||
get(): Promise<PersonalSettings | null>;
|
||||
save(settings: PersonalSettings): Promise<void>;
|
||||
}
|
||||
@ -41,6 +41,12 @@ class MemoryConversationRepository implements ConversationRepository {
|
||||
async create(value: Conversation) {
|
||||
this.values.set(value.id, value);
|
||||
}
|
||||
async update(value: Conversation) {
|
||||
this.values.set(value.id, value);
|
||||
}
|
||||
async delete(id: string) {
|
||||
this.values.delete(id);
|
||||
}
|
||||
async appendMessage(id: string, message: Conversation["messages"][number]) {
|
||||
const current = this.values.get(id);
|
||||
if (!current) throw new Error("not found");
|
||||
|
||||
@ -23,6 +23,12 @@ class MemoryConversationRepository implements ConversationRepository {
|
||||
async create(conversation: Conversation): Promise<void> {
|
||||
this.values.set(conversation.id, conversation);
|
||||
}
|
||||
async update(conversation: Conversation): Promise<void> {
|
||||
this.values.set(conversation.id, conversation);
|
||||
}
|
||||
async delete(id: string): Promise<void> {
|
||||
this.values.delete(id);
|
||||
}
|
||||
async appendMessage(
|
||||
id: string,
|
||||
message: Conversation["messages"][number],
|
||||
@ -91,4 +97,20 @@ describe("ConversationService", () => {
|
||||
expect(created.messages[0]?.content).toBe("");
|
||||
expect(created.messages[0]?.attachments[0]?.path).toBe("docs/说明.md");
|
||||
});
|
||||
|
||||
test("重命名和删除会话立即更新仓库", async () => {
|
||||
const conversations = new MemoryConversationRepository();
|
||||
let id = 0;
|
||||
const service = new ConversationService({
|
||||
conversations,
|
||||
clock: { now: () => new Date("2026-08-14T08:00:00Z") },
|
||||
ids: { create: () => `manage_${++id}` },
|
||||
});
|
||||
const created = await service.createWithFirstMessage("原名称", "run_1");
|
||||
expect((await service.rename(created.id, " 新名称 ")).title).toBe(
|
||||
"新名称",
|
||||
);
|
||||
await service.delete(created.id);
|
||||
expect(await conversations.getById(created.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,11 +6,13 @@ import type {
|
||||
import { CoreError } from "../errors/core-error";
|
||||
import type { ConversationRepository } from "../ports/conversation-repository";
|
||||
import type { ClockPort, IdPort } from "../ports/system-ports";
|
||||
import type { RunRepository } from "../ports/run-repository";
|
||||
|
||||
export type ConversationServiceDependencies = Readonly<{
|
||||
conversations: ConversationRepository;
|
||||
clock: ClockPort;
|
||||
ids: IdPort;
|
||||
runs?: RunRepository;
|
||||
}>;
|
||||
|
||||
export class ConversationService {
|
||||
@ -31,6 +33,36 @@ export class ConversationService {
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async rename(id: string, title: string): Promise<Conversation> {
|
||||
const conversation = await this.getConversation(id);
|
||||
const normalized = title.trim();
|
||||
if (!normalized)
|
||||
throw new CoreError("CONVERSATION_TITLE_EMPTY", "请输入会话名称");
|
||||
if (normalized.length > 80)
|
||||
throw new CoreError("CONVERSATION_TITLE_TOO_LONG", "会话名称过长");
|
||||
const updated = {
|
||||
...conversation,
|
||||
title: normalized,
|
||||
updatedAt: this.dependencies.clock.now().toISOString(),
|
||||
};
|
||||
await this.dependencies.conversations.update(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.getConversation(id);
|
||||
if (
|
||||
this.dependencies.runs &&
|
||||
(await this.dependencies.runs.hasActiveForConversations([id]))
|
||||
)
|
||||
throw new CoreError(
|
||||
"CONVERSATION_RUN_ACTIVE",
|
||||
"会话中仍有任务正在运行,请先停止任务",
|
||||
);
|
||||
await this.dependencies.runs?.deleteByConversations([id]);
|
||||
await this.dependencies.conversations.delete(id);
|
||||
}
|
||||
|
||||
async createWithFirstMessage(
|
||||
content: string,
|
||||
runId: string,
|
||||
|
||||
@ -28,6 +28,12 @@ class Conversations implements ConversationRepository {
|
||||
async create(value: Conversation) {
|
||||
this.values.set(value.id, value);
|
||||
}
|
||||
async update(value: Conversation) {
|
||||
this.values.set(value.id, value);
|
||||
}
|
||||
async delete(id: string) {
|
||||
this.values.delete(id);
|
||||
}
|
||||
async appendMessage(id: string, message: Conversation["messages"][number]) {
|
||||
const current = this.values.get(id);
|
||||
if (!current) throw new Error("not found");
|
||||
|
||||
@ -44,6 +44,12 @@ class Conversations implements ConversationRepository {
|
||||
async create(value: Conversation) {
|
||||
this.values.set(value.id, value);
|
||||
}
|
||||
async update(value: Conversation) {
|
||||
this.values.set(value.id, value);
|
||||
}
|
||||
async delete(id: string) {
|
||||
this.values.delete(id);
|
||||
}
|
||||
async appendMessage(
|
||||
_id: string,
|
||||
_message: Conversation["messages"][number],
|
||||
|
||||
48
packages/agent-core/src/use-cases/settings-service.test.ts
Normal file
48
packages/agent-core/src/use-cases/settings-service.test.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { PersonalSettings, SettingsRepository } from "..";
|
||||
import { SettingsService } from "..";
|
||||
|
||||
class MemorySettings implements SettingsRepository {
|
||||
value: PersonalSettings | null = null;
|
||||
async get() {
|
||||
return this.value;
|
||||
}
|
||||
async save(value: PersonalSettings) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
describe("SettingsService", () => {
|
||||
test("只返回模型配置摘要并持久化有效默认工作区", async () => {
|
||||
const repository = new MemorySettings();
|
||||
const service = new SettingsService({
|
||||
settings: repository,
|
||||
workspaces: {
|
||||
async resolveForCreation(path) {
|
||||
return `/real${path}`;
|
||||
},
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
clock: { now: () => new Date("2026-08-14T08:00:00Z") },
|
||||
initialWorkspaceRoot: "/default",
|
||||
model: "deepseek-v4-pro",
|
||||
modelConfigured: true,
|
||||
version: "0.1.0",
|
||||
});
|
||||
const initial = await service.get();
|
||||
expect(initial).toMatchObject({
|
||||
defaultWorkspaceRoot: "/default",
|
||||
model: "deepseek-v4-pro",
|
||||
modelConfigured: true,
|
||||
});
|
||||
const updated = await service.update({
|
||||
defaultWorkspaceRoot: "/chosen",
|
||||
showToolDetails: false,
|
||||
});
|
||||
expect(updated.defaultWorkspaceRoot).toBe("/real/chosen");
|
||||
expect(updated.showToolDetails).toBe(false);
|
||||
expect(Object.keys(updated)).not.toContain("apiKey");
|
||||
});
|
||||
});
|
||||
58
packages/agent-core/src/use-cases/settings-service.ts
Normal file
58
packages/agent-core/src/use-cases/settings-service.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import type { PersonalSettings, SettingsView } from "../domain/settings";
|
||||
import type { SettingsRepository } from "../ports/settings-repository";
|
||||
import type { ClockPort, WorkspaceResolverPort } from "../ports/system-ports";
|
||||
|
||||
export type SettingsServiceDependencies = Readonly<{
|
||||
settings: SettingsRepository;
|
||||
workspaces: WorkspaceResolverPort;
|
||||
clock: ClockPort;
|
||||
initialWorkspaceRoot: string;
|
||||
model: string;
|
||||
modelConfigured: boolean;
|
||||
version: string;
|
||||
}>;
|
||||
|
||||
export class SettingsService {
|
||||
constructor(private readonly dependencies: SettingsServiceDependencies) {}
|
||||
|
||||
async get(): Promise<SettingsView> {
|
||||
const settings = await this.getPersonal();
|
||||
return {
|
||||
...settings,
|
||||
model: this.dependencies.model,
|
||||
modelConfigured: this.dependencies.modelConfigured,
|
||||
version: this.dependencies.version,
|
||||
};
|
||||
}
|
||||
|
||||
async getDefaultWorkspaceRoot(): Promise<string> {
|
||||
return (await this.getPersonal()).defaultWorkspaceRoot;
|
||||
}
|
||||
|
||||
async update(input: {
|
||||
defaultWorkspaceRoot: string;
|
||||
showToolDetails: boolean;
|
||||
}): Promise<SettingsView> {
|
||||
const defaultWorkspaceRoot =
|
||||
await this.dependencies.workspaces.resolveForCreation(
|
||||
input.defaultWorkspaceRoot,
|
||||
);
|
||||
const settings: PersonalSettings = {
|
||||
defaultWorkspaceRoot,
|
||||
showToolDetails: input.showToolDetails,
|
||||
updatedAt: this.dependencies.clock.now().toISOString(),
|
||||
};
|
||||
await this.dependencies.settings.save(settings);
|
||||
return this.get();
|
||||
}
|
||||
|
||||
private async getPersonal(): Promise<PersonalSettings> {
|
||||
const stored = await this.dependencies.settings.get();
|
||||
if (stored) return stored;
|
||||
return {
|
||||
defaultWorkspaceRoot: this.dependencies.initialWorkspaceRoot,
|
||||
showToolDetails: true,
|
||||
updatedAt: this.dependencies.clock.now().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,7 @@ export class WorkspaceService {
|
||||
private readonly files: WorkspacePort,
|
||||
private readonly conversations: ConversationService,
|
||||
private readonly projects: ProjectService,
|
||||
private readonly defaultRoot: string,
|
||||
private readonly defaultRoot: string | (() => Promise<string>),
|
||||
private readonly clock: ClockPort,
|
||||
) {}
|
||||
|
||||
@ -149,9 +149,13 @@ export class WorkspaceService {
|
||||
}
|
||||
|
||||
private async requireDefaultRoot(): Promise<string> {
|
||||
const root =
|
||||
typeof this.defaultRoot === "string"
|
||||
? this.defaultRoot
|
||||
: await this.defaultRoot();
|
||||
try {
|
||||
await this.files.list(this.defaultRoot);
|
||||
return this.defaultRoot;
|
||||
await this.files.list(root);
|
||||
return root;
|
||||
} catch {
|
||||
throw new CoreError(
|
||||
"DEFAULT_WORKSPACE_UNAVAILABLE",
|
||||
|
||||
@ -10,3 +10,4 @@ export { FileConversationRepository } from "./repositories/file-conversation-rep
|
||||
export { FileRunRepository } from "./repositories/file-run-repository";
|
||||
export { FileProjectRepository } from "./repositories/file-project-repository";
|
||||
export { FileInteractionRepository } from "./repositories/file-interaction-repository";
|
||||
export { FileSettingsRepository } from "./repositories/file-settings-repository";
|
||||
|
||||
@ -62,6 +62,19 @@ export class FileConversationRepository implements ConversationRepository {
|
||||
return next;
|
||||
}
|
||||
|
||||
async update(conversation: Conversation): Promise<void> {
|
||||
if (!(await this.getById(conversation.id))) throw new Error("会话不存在");
|
||||
await writeJsonAtomically(this.pathFor(conversation.id), conversation);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
try {
|
||||
await unlink(this.pathFor(id));
|
||||
} catch (cause) {
|
||||
if (!isFileNotFound(cause)) throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteByProject(projectId: string): Promise<void> {
|
||||
const conversations = await this.readAll();
|
||||
await Promise.all(
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createDataLayout, ensureDataLayout, FileSettingsRepository } from "..";
|
||||
|
||||
describe("FileSettingsRepository", () => {
|
||||
test("在独立 settings.json 中保存非敏感配置", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "great-agent-settings-"));
|
||||
try {
|
||||
const layout = createDataLayout(root);
|
||||
await ensureDataLayout(layout);
|
||||
const repository = new FileSettingsRepository(layout);
|
||||
expect(await repository.get()).toBeNull();
|
||||
await repository.save({
|
||||
defaultWorkspaceRoot: "/workspace",
|
||||
showToolDetails: true,
|
||||
updatedAt: "2026-08-14T08:00:00Z",
|
||||
});
|
||||
expect(await repository.get()).toMatchObject({
|
||||
defaultWorkspaceRoot: "/workspace",
|
||||
showToolDetails: true,
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,30 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
PersonalSettings,
|
||||
SettingsRepository,
|
||||
} from "@great-agent/agent-core";
|
||||
import { writeJsonAtomically } from "../atomic-writes/write-json-atomically";
|
||||
import type { DataLayout } from "../layout/data-layout";
|
||||
|
||||
export class FileSettingsRepository implements SettingsRepository {
|
||||
constructor(private readonly layout: DataLayout) {}
|
||||
|
||||
async get(): Promise<PersonalSettings | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(this.path, "utf8")) as PersonalSettings;
|
||||
} catch (cause) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
|
||||
return null;
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
save(settings: PersonalSettings): Promise<void> {
|
||||
return writeJsonAtomically(this.path, settings);
|
||||
}
|
||||
|
||||
private get path(): string {
|
||||
return join(this.layout.root, "settings.json");
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,14 @@
|
||||
export { healthResponseSchema, type HealthResponse } from "./responses/health";
|
||||
export { messageInputSchema, type MessageInput } from "./requests/message";
|
||||
export { startRunRequestSchema, type StartRunRequest } from "./requests/run";
|
||||
export {
|
||||
renameConversationRequestSchema,
|
||||
type RenameConversationRequest,
|
||||
} from "./requests/conversation";
|
||||
export {
|
||||
updateSettingsRequestSchema,
|
||||
type UpdateSettingsRequest,
|
||||
} from "./requests/settings";
|
||||
export {
|
||||
createProjectRequestSchema,
|
||||
renameProjectRequestSchema,
|
||||
@ -36,3 +44,4 @@ export {
|
||||
type WorkspaceEntryResponse,
|
||||
type WorkspaceSearchMatchResponse,
|
||||
} from "./responses/workspace";
|
||||
export { settingsSchema, type SettingsResponse } from "./responses/settings";
|
||||
|
||||
9
packages/web-contracts/src/requests/conversation.ts
Normal file
9
packages/web-contracts/src/requests/conversation.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const renameConversationRequestSchema = z.object({
|
||||
title: z.string().trim().min(1).max(80),
|
||||
});
|
||||
|
||||
export type RenameConversationRequest = z.infer<
|
||||
typeof renameConversationRequestSchema
|
||||
>;
|
||||
8
packages/web-contracts/src/requests/settings.ts
Normal file
8
packages/web-contracts/src/requests/settings.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const updateSettingsRequestSchema = z.object({
|
||||
defaultWorkspaceRoot: z.string().trim().min(1),
|
||||
showToolDetails: z.boolean(),
|
||||
});
|
||||
|
||||
export type UpdateSettingsRequest = z.infer<typeof updateSettingsRequestSchema>;
|
||||
12
packages/web-contracts/src/responses/settings.ts
Normal file
12
packages/web-contracts/src/responses/settings.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const settingsSchema = z.object({
|
||||
defaultWorkspaceRoot: z.string(),
|
||||
showToolDetails: z.boolean(),
|
||||
updatedAt: z.string(),
|
||||
model: z.string(),
|
||||
modelConfigured: z.boolean(),
|
||||
version: z.string(),
|
||||
});
|
||||
|
||||
export type SettingsResponse = z.infer<typeof settingsSchema>;
|
||||
Loading…
x
Reference in New Issue
Block a user