diff --git a/.ai-project/state.yaml b/.ai-project/state.yaml index 2dc7ec9..6fca727 100644 --- a/.ai-project/state.yaml +++ b/.ai-project/state.yaml @@ -47,10 +47,10 @@ 状态: "已完成" - 编号: "F-008" 名称: "会话管理与个人设置" - 状态: "进行中" + 状态: "已完成" - 编号: "F-009" 名称: "Claude Desktop 视觉与交互收口" - 状态: "待开始" + 状态: "进行中" - 编号: "F-010" 名称: "恢复、边界与发布前加固" 状态: "待开始" diff --git a/apps/web-server/src/composition/create-app.ts b/apps/web-server/src/composition/create-app.ts index 66d0226..1b2657d 100644 --- a/apps/web-server/src/composition/create-app.ts +++ b/apps/web-server/src/composition/create-app.ts @@ -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; } diff --git a/apps/web-server/src/http/error-handler.ts b/apps/web-server/src/http/error-handler.ts index a7b35e2..a9675af 100644 --- a/apps/web-server/src/http/error-handler.ts +++ b/apps/web-server/src/http/error-handler.ts @@ -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( diff --git a/apps/web-server/src/index.ts b/apps/web-server/src/index.ts index 0727e0e..65e9812 100644 --- a/apps/web-server/src/index.ts +++ b/apps/web-server/src/index.ts @@ -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")); diff --git a/apps/web-server/src/routes/conversations.test.ts b/apps/web-server/src/routes/conversations.test.ts index 432bdb4..ba8d515 100644 --- a/apps/web-server/src/routes/conversations.test.ts +++ b/apps/web-server/src/routes/conversations.test.ts @@ -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", + }); + }); }); diff --git a/apps/web-server/src/routes/conversations.ts b/apps/web-server/src/routes/conversations.ts index 4a55bb7..3801ce8 100644 --- a/apps/web-server/src/routes/conversations.ts +++ b/apps/web-server/src/routes/conversations.ts @@ -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; } diff --git a/apps/web-server/src/routes/runs.test.ts b/apps/web-server/src/routes/runs.test.ts index a6cb10e..ca58574 100644 --- a/apps/web-server/src/routes/runs.test.ts +++ b/apps/web-server/src/routes/runs.test.ts @@ -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] }; diff --git a/apps/web-server/src/routes/settings.ts b/apps/web-server/src/routes/settings.ts new file mode 100644 index 0000000..659cf93 --- /dev/null +++ b/apps/web-server/src/routes/settings.ts @@ -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; +} diff --git a/apps/web/src/api/conversations.ts b/apps/web/src/api/conversations.ts index b6f57ea..b2e1943 100644 --- a/apps/web/src/api/conversations.ts +++ b/apps/web/src/api/conversations.ts @@ -33,3 +33,21 @@ export async function listProjectConversations( ), ); } + +export async function renameConversation( + id: string, + title: string, +): Promise { + return conversationSchema.parse( + await request(`/api/conversations/${encodeURIComponent(id)}`, { + method: "PATCH", + body: JSON.stringify({ title }), + }), + ); +} + +export async function deleteConversation(id: string): Promise { + await request(`/api/conversations/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} diff --git a/apps/web/src/api/settings.ts b/apps/web/src/api/settings.ts new file mode 100644 index 0000000..2c9c6c9 --- /dev/null +++ b/apps/web/src/api/settings.ts @@ -0,0 +1,21 @@ +import { + settingsSchema, + type SettingsResponse, +} from "@great-agent/web-contracts"; +import { request } from "./request"; + +export async function getSettings(): Promise { + return settingsSchema.parse(await request("/api/settings")); +} + +export async function updateSettings(input: { + defaultWorkspaceRoot: string; + showToolDetails: boolean; +}): Promise { + return settingsSchema.parse( + await request("/api/settings", { + method: "PATCH", + body: JSON.stringify(input), + }), + ); +} diff --git a/apps/web/src/app/app-controller-types.ts b/apps/web/src/app/app-controller-types.ts index 784c31d..d53176f 100644 --- a/apps/web/src/app/app-controller-types.ts +++ b/apps/web/src/app/app-controller-types.ts @@ -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; error: State; toolActivities: State; + settings: State; initialize(): Promise; startNewTask(): void; startProjectCreation(): void; @@ -42,6 +47,15 @@ export type AppController = Readonly<{ startProjectDelete(id: string): void; deleteProject(id: string): Promise; openConversation(id: string): Promise; + startConversationRename(id: string): void; + renameConversation(id: string, title: string): Promise; + startConversationDelete(id: string): void; + deleteConversation(id: string): Promise; + openSettings(): Promise; + saveSettings( + defaultWorkspaceRoot: string, + showToolDetails: boolean, + ): Promise; send(content: string, attachments?: readonly string[]): Promise; answerInteraction( id: string, diff --git a/apps/web/src/app/app-controller.ts b/apps/web/src/app/app-controller.ts index 82f8b0c..30f91b5 100644 --- a/apps/web/src/app/app-controller.ts +++ b/apps/web/src/app/app-controller.ts @@ -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([]); + const settings = van.state(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, diff --git a/apps/web/src/app/controller-management.ts b/apps/web/src/app/controller-management.ts new file mode 100644 index 0000000..764d883 --- /dev/null +++ b/apps/web/src/app/controller-management.ts @@ -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; + projectConversations: State>; + active: State; + settings: State; + selection: State; + select(selection: Selection): void; + perform(action: () => Promise): Promise; +}>; + +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; +} diff --git a/apps/web/src/features/conversations/conversation-management-panel.css b/apps/web/src/features/conversations/conversation-management-panel.css new file mode 100644 index 0000000..ce6b805 --- /dev/null +++ b/apps/web/src/features/conversations/conversation-management-panel.css @@ -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; +} diff --git a/apps/web/src/features/conversations/conversation-management-panel.ts b/apps/web/src/features/conversations/conversation-management-panel.ts new file mode 100644 index 0000000..9128cbd --- /dev/null +++ b/apps/web/src/features/conversations/conversation-management-panel.ts @@ -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() }, + "保存", + ), + ), + ); +} diff --git a/apps/web/src/features/conversations/conversation-pane.ts b/apps/web/src/features/conversations/conversation-pane.ts index a259dbe..d467668 100644 --- a/apps/web/src/features/conversations/conversation-pane.ts +++ b/apps/web/src/features/conversations/conversation-pane.ts @@ -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) }, diff --git a/apps/web/src/features/conversations/sidebar.css b/apps/web/src/features/conversations/sidebar.css index 3b31676..f56f4f1 100644 --- a/apps/web/src/features/conversations/sidebar.css +++ b/apps/web/src/features/conversations/sidebar.css @@ -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 { diff --git a/apps/web/src/features/conversations/sidebar.ts b/apps/web/src/features/conversations/sidebar.ts index 203a4ec..817e484 100644 --- a/apps/web/src/features/conversations/sidebar.ts +++ b/apps/web/src/features/conversations/sidebar.ts @@ -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; diff --git a/apps/web/src/features/settings/settings-panel.css b/apps/web/src/features/settings/settings-panel.css new file mode 100644 index 0000000..4c4121a --- /dev/null +++ b/apps/web/src/features/settings/settings-panel.css @@ -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; +} diff --git a/apps/web/src/features/settings/settings-panel.ts b/apps/web/src/features/settings/settings-panel.ts new file mode 100644 index 0000000..5052e48 --- /dev/null +++ b/apps/web/src/features/settings/settings-panel.ts @@ -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(), + }, + "保存设置", + ), + ); +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 9156b25..bea669e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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。 diff --git a/packages/agent-core/src/domain/settings.ts b/packages/agent-core/src/domain/settings.ts new file mode 100644 index 0000000..0624196 --- /dev/null +++ b/packages/agent-core/src/domain/settings.ts @@ -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; + }>; diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index b8b0851..2972fbc 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -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"; diff --git a/packages/agent-core/src/ports/conversation-repository.ts b/packages/agent-core/src/ports/conversation-repository.ts index f497e43..add5451 100644 --- a/packages/agent-core/src/ports/conversation-repository.ts +++ b/packages/agent-core/src/ports/conversation-repository.ts @@ -13,5 +13,7 @@ export interface ConversationRepository { conversationId: string, message: Message, ): Promise; + update(conversation: Conversation): Promise; + delete(id: string): Promise; deleteByProject(projectId: string): Promise; } diff --git a/packages/agent-core/src/ports/settings-repository.ts b/packages/agent-core/src/ports/settings-repository.ts new file mode 100644 index 0000000..e2c3616 --- /dev/null +++ b/packages/agent-core/src/ports/settings-repository.ts @@ -0,0 +1,6 @@ +import type { PersonalSettings } from "../domain/settings"; + +export interface SettingsRepository { + get(): Promise; + save(settings: PersonalSettings): Promise; +} diff --git a/packages/agent-core/src/use-cases/agent-run-service.test.ts b/packages/agent-core/src/use-cases/agent-run-service.test.ts index ed96463..f6ad0be 100644 --- a/packages/agent-core/src/use-cases/agent-run-service.test.ts +++ b/packages/agent-core/src/use-cases/agent-run-service.test.ts @@ -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"); diff --git a/packages/agent-core/src/use-cases/conversation-service.test.ts b/packages/agent-core/src/use-cases/conversation-service.test.ts index 01cf412..1cac476 100644 --- a/packages/agent-core/src/use-cases/conversation-service.test.ts +++ b/packages/agent-core/src/use-cases/conversation-service.test.ts @@ -23,6 +23,12 @@ class MemoryConversationRepository implements ConversationRepository { async create(conversation: Conversation): Promise { this.values.set(conversation.id, conversation); } + async update(conversation: Conversation): Promise { + this.values.set(conversation.id, conversation); + } + async delete(id: string): Promise { + 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(); + }); }); diff --git a/packages/agent-core/src/use-cases/conversation-service.ts b/packages/agent-core/src/use-cases/conversation-service.ts index abcb47f..c880542 100644 --- a/packages/agent-core/src/use-cases/conversation-service.ts +++ b/packages/agent-core/src/use-cases/conversation-service.ts @@ -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 { + 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 { + 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, diff --git a/packages/agent-core/src/use-cases/interaction-run.test.ts b/packages/agent-core/src/use-cases/interaction-run.test.ts index 9b190a2..c887d88 100644 --- a/packages/agent-core/src/use-cases/interaction-run.test.ts +++ b/packages/agent-core/src/use-cases/interaction-run.test.ts @@ -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"); diff --git a/packages/agent-core/src/use-cases/project-service.test.ts b/packages/agent-core/src/use-cases/project-service.test.ts index 8d5d104..6a0b315 100644 --- a/packages/agent-core/src/use-cases/project-service.test.ts +++ b/packages/agent-core/src/use-cases/project-service.test.ts @@ -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], diff --git a/packages/agent-core/src/use-cases/settings-service.test.ts b/packages/agent-core/src/use-cases/settings-service.test.ts new file mode 100644 index 0000000..89a30f0 --- /dev/null +++ b/packages/agent-core/src/use-cases/settings-service.test.ts @@ -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"); + }); +}); diff --git a/packages/agent-core/src/use-cases/settings-service.ts b/packages/agent-core/src/use-cases/settings-service.ts new file mode 100644 index 0000000..563e803 --- /dev/null +++ b/packages/agent-core/src/use-cases/settings-service.ts @@ -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 { + const settings = await this.getPersonal(); + return { + ...settings, + model: this.dependencies.model, + modelConfigured: this.dependencies.modelConfigured, + version: this.dependencies.version, + }; + } + + async getDefaultWorkspaceRoot(): Promise { + return (await this.getPersonal()).defaultWorkspaceRoot; + } + + async update(input: { + defaultWorkspaceRoot: string; + showToolDetails: boolean; + }): Promise { + 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 { + const stored = await this.dependencies.settings.get(); + if (stored) return stored; + return { + defaultWorkspaceRoot: this.dependencies.initialWorkspaceRoot, + showToolDetails: true, + updatedAt: this.dependencies.clock.now().toISOString(), + }; + } +} diff --git a/packages/agent-core/src/use-cases/workspace-service.ts b/packages/agent-core/src/use-cases/workspace-service.ts index 0b78d51..18b1cb4 100644 --- a/packages/agent-core/src/use-cases/workspace-service.ts +++ b/packages/agent-core/src/use-cases/workspace-service.ts @@ -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), private readonly clock: ClockPort, ) {} @@ -149,9 +149,13 @@ export class WorkspaceService { } private async requireDefaultRoot(): Promise { + 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", diff --git a/packages/local-data/src/index.ts b/packages/local-data/src/index.ts index 3e1cb78..6ebd0fb 100644 --- a/packages/local-data/src/index.ts +++ b/packages/local-data/src/index.ts @@ -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"; diff --git a/packages/local-data/src/repositories/file-conversation-repository.ts b/packages/local-data/src/repositories/file-conversation-repository.ts index 14f7f24..41e4050 100644 --- a/packages/local-data/src/repositories/file-conversation-repository.ts +++ b/packages/local-data/src/repositories/file-conversation-repository.ts @@ -62,6 +62,19 @@ export class FileConversationRepository implements ConversationRepository { return next; } + async update(conversation: Conversation): Promise { + if (!(await this.getById(conversation.id))) throw new Error("会话不存在"); + await writeJsonAtomically(this.pathFor(conversation.id), conversation); + } + + async delete(id: string): Promise { + try { + await unlink(this.pathFor(id)); + } catch (cause) { + if (!isFileNotFound(cause)) throw cause; + } + } + async deleteByProject(projectId: string): Promise { const conversations = await this.readAll(); await Promise.all( diff --git a/packages/local-data/src/repositories/file-settings-repository.test.ts b/packages/local-data/src/repositories/file-settings-repository.test.ts new file mode 100644 index 0000000..0cdad49 --- /dev/null +++ b/packages/local-data/src/repositories/file-settings-repository.test.ts @@ -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 }); + } + }); +}); diff --git a/packages/local-data/src/repositories/file-settings-repository.ts b/packages/local-data/src/repositories/file-settings-repository.ts new file mode 100644 index 0000000..92e7136 --- /dev/null +++ b/packages/local-data/src/repositories/file-settings-repository.ts @@ -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 { + 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 { + return writeJsonAtomically(this.path, settings); + } + + private get path(): string { + return join(this.layout.root, "settings.json"); + } +} diff --git a/packages/web-contracts/src/index.ts b/packages/web-contracts/src/index.ts index 7752317..66f48b0 100644 --- a/packages/web-contracts/src/index.ts +++ b/packages/web-contracts/src/index.ts @@ -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"; diff --git a/packages/web-contracts/src/requests/conversation.ts b/packages/web-contracts/src/requests/conversation.ts new file mode 100644 index 0000000..a5e4106 --- /dev/null +++ b/packages/web-contracts/src/requests/conversation.ts @@ -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 +>; diff --git a/packages/web-contracts/src/requests/settings.ts b/packages/web-contracts/src/requests/settings.ts new file mode 100644 index 0000000..ce24611 --- /dev/null +++ b/packages/web-contracts/src/requests/settings.ts @@ -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; diff --git a/packages/web-contracts/src/responses/settings.ts b/packages/web-contracts/src/responses/settings.ts new file mode 100644 index 0000000..6359d6c --- /dev/null +++ b/packages/web-contracts/src/responses/settings.ts @@ -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;