feat: 会话管理与个人设置

This commit is contained in:
李岩岩 2026-08-14 16:02:27 +08:00
parent 6f029c49ab
commit b1571f2052
41 changed files with 1049 additions and 104 deletions

View File

@ -47,10 +47,10 @@
状态: "已完成"
- 编号: "F-008"
名称: "会话管理与个人设置"
状态: "进行中"
状态: "已完成"
- 编号: "F-009"
名称: "Claude Desktop 视觉与交互收口"
状态: "待开始"
状态: "进行中"
- 编号: "F-010"
名称: "恢复、边界与发布前加固"
状态: "待开始"

View File

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

View File

@ -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(

View File

@ -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"));

View File

@ -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",
});
});
});

View File

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

View File

@ -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] };

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

View File

@ -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",
});
}

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

View File

@ -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,

View File

@ -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,

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

View File

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

View File

@ -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() },
"保存",
),
),
);
}

View File

@ -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) },

View File

@ -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 {

View File

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

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

View 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(),
},
"保存设置",
),
);
}

View File

@ -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。

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

View File

@ -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";

View File

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

View File

@ -0,0 +1,6 @@
import type { PersonalSettings } from "../domain/settings";
export interface SettingsRepository {
get(): Promise<PersonalSettings | null>;
save(settings: PersonalSettings): Promise<void>;
}

View File

@ -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");

View File

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

View File

@ -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,

View File

@ -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");

View File

@ -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],

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

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

View File

@ -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",

View File

@ -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";

View File

@ -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(

View File

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

View File

@ -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");
}
}

View File

@ -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";

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

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

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