mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(studio): address composition UI review and host archive uploads
Problem: repeated New session reset a blank pane's mode, copied built-in descriptions ignored the window language, and identifier fields missed theme styles. The picker was always visible and native archives were parsed in the renderer. Unrelated transitions also repositioned every open popup. Root cause: pane reuse resolved an unrequested default; editor styles depended on a container the identifier row did not share; interface opt-in was missing; archive transport carried parsed objects; placement had no anchor scope. Fix: change a reused pane only for an explicit selection and preserve focus on refusal, translate built-in copies, share field tokens, add a default-off local picker switch that preserves bindings and the original hidden-state layout, upload opaque files to the existing host decoder with typed localized errors, keep the prior JSON envelope, and scope transitions to each popup's anchor or container. Show catalog unavailability and wrap the editor components. Verification: failing-first regressions; format/vet/lint/make check; all 228 root Go packages, required builtin/boot, HTTP/storage races and exact-binding archive transport tests; Desktop/SDK; 691 frontend files and 5608 tests, typecheck/build/census; 12 browser and eight Electron renderer scenes across languages/themes/widths; Electron units (176 pass, one existing skip). No authority, schema, safety policy or verification threshold is widened.
This commit is contained in:
1 parent
38609899ae
commit
2e2b316d86
32 files changed
+1147
-159
No files matched your search
@@ -50,6 +50,8 @@ export interface UIAction {
|
||||
export const ACTIONS: UIAction[] = [
|
||||
{id:"agent-composition.select",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
|
||||
{id:"agent-composition.manage",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-composition.unavailable",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.show-picker",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-composition.new",kind:"kernel-mutation",target:"none",proof:"authority-effect"},
|
||||
{id:"agent-preset.reload",kind:"view",target:"none",proof:"interaction"},
|
||||
{id:"agent-preset.view",kind:"view",target:"none",proof:"interaction"},
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export const EN_COMPOSITION: Record<string,string> = {
|
||||
"会话文件无效,请选择完整的原生会话导出文件。": "Invalid session file. Choose a complete native session export.",
|
||||
"不支持此会话文件版本,请使用兼容版本导出。": "Unsupported session file version. Export it with a compatible version.",
|
||||
"会话文件超过安全导入限制(大小或记录数)。": "The session file exceeds safe import limits (size or record count).",
|
||||
"Agent 预设暂不可用": "Agent presets unavailable",
|
||||
"显示模式选择器": "Show mode picker",
|
||||
"默认关闭。开启后在输入框顶部选择模式;关闭只隐藏入口,不改变已有会话的模式。": "Off by default. Enable to choose a mode above the input. Turning it off hides the picker without changing existing conversations.",
|
||||
"当前会话已绑定预设,标准模式的人设修改不会更新此绑定。": "This conversation is bound to a preset. Editing the Standard-mode persona will not update that binding.",
|
||||
"默认使用标准模式。其他模式仅在明确选择后启用;首选标记不会自动切换会话。": "Standard mode is used by default. Other modes require an explicit choice; a preference never switches a conversation automatically.",
|
||||
"标准模式": "Standard mode",
|
||||
|
||||
@@ -52,6 +52,9 @@ const SAID: Record<string, string> = {
|
||||
"shell.command_line_too_long": "命令超过主机长度限制;请拆分命令或从文件读取长文本",
|
||||
"tool.arguments_invalid": "参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试",
|
||||
"workspace.write_conflict": "另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试",
|
||||
"session.archive_invalid": "会话文件无效,请选择完整的原生会话导出文件。",
|
||||
"session.archive_unsupported": "不支持此会话文件版本,请使用兼容版本导出。",
|
||||
"session.archive_too_large": "会话文件超过安全导入限制(大小或记录数)。",
|
||||
"session.title_empty": "请输入聊天标题",
|
||||
"session.title_no_message": "该会话还没有可用于命名的用户消息",
|
||||
"session.title_unavailable": "未配置可用于自动命名的模型,请先配置模型",
|
||||
|
||||
@@ -3,6 +3,18 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { SseHub } from "./hub";
|
||||
import { HttpError } from "./port";
|
||||
|
||||
it("uploads native archives as opaque bodies to the owning host route", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ path: "imported" }) });
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
try {
|
||||
const file = new File(["{ not parsed here"], "session.json");
|
||||
await new SseHub().importNativeSession(file, "/folder with space");
|
||||
expect(fetch).toHaveBeenCalledWith("/tree/sessions/import-native?workspace=%2Ffolder+with+space",
|
||||
expect.objectContaining({ method: "POST", body: file,
|
||||
headers: { "content-type": "application/json" }, credentials: "same-origin" }));
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
function answer(status: number, body: string) {
|
||||
vi.stubGlobal("fetch", async () => ({
|
||||
ok: false,
|
||||
|
||||
@@ -194,7 +194,7 @@ export interface HubPort extends SharePort {
|
||||
autoNameSession(path: string): Promise<{ title: string }>;
|
||||
exportSession(path: string): Promise<{ name: string; content: string }>;
|
||||
importLegacySessions(path: string, workspace: string): Promise<LegacyImport>;
|
||||
importNativeSession(content: unknown, workspace: string): Promise<{ path: string }>;
|
||||
importNativeSession(file: Blob, workspace: string): Promise<{ path: string }>;
|
||||
// The host book with each link's state, or null where this kernel refuses
|
||||
// remote panes outright — a page served to a browser, rather than the window.
|
||||
// Null is what lets the sidebar leave the whole section out instead of
|
||||
@@ -418,8 +418,14 @@ export class SseHub implements HubPort {
|
||||
return this.post<LegacyImport>("/tree/sessions/import-legacy", { path, workspace });
|
||||
}
|
||||
|
||||
importNativeSession(content: unknown, workspace: string) {
|
||||
return this.post<{ path: string }>("/tree/sessions/import-native", { content, workspace });
|
||||
async importNativeSession(file: Blob, workspace: string): Promise<{ path: string }> {
|
||||
const path = `/tree/sessions/import-native?${new URLSearchParams({ workspace })}`;
|
||||
const response = await fetch(path, {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" }, body: file,
|
||||
});
|
||||
if (!response.ok) await SseHub.fail(path, response);
|
||||
return await response.json() as { path: string };
|
||||
}
|
||||
|
||||
async remoteHosts() {
|
||||
|
||||
@@ -305,7 +305,7 @@ export class MockHub implements HubPort {
|
||||
return Promise.resolve({ summary: "mock migration complete", imported: 0, warnings: 0, recognised: true, skipped: [] });
|
||||
}
|
||||
|
||||
importNativeSession(_content: unknown, _workspace: string) {
|
||||
importNativeSession(_file: Blob, _workspace: string) {
|
||||
return Promise.resolve({ path: `/sessions/imported-${++this.seq}.jsonl` });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const KEY = "rx-show-agent-composition";
|
||||
const listeners = new Set<() => void>();
|
||||
let fallback = false;
|
||||
|
||||
function read(): boolean {
|
||||
try { return localStorage.getItem(KEY) === "on"; }
|
||||
catch { return fallback; }
|
||||
}
|
||||
|
||||
function publish(): void {
|
||||
listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
function refresh(event: StorageEvent): void {
|
||||
if (event.key === KEY || event.key === null) publish();
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
if (listeners.size === 1) window.addEventListener("storage", refresh);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
if (!listeners.size) window.removeEventListener("storage", refresh);
|
||||
};
|
||||
}
|
||||
|
||||
export function setShowsCompositionPicker(on: boolean): void {
|
||||
fallback = on;
|
||||
try { localStorage.setItem(KEY, on ? "on" : "off"); }
|
||||
catch { /* retain the choice for this window */ }
|
||||
publish();
|
||||
}
|
||||
|
||||
export function useShowsCompositionPicker(): boolean {
|
||||
return useSyncExternalStore(subscribe, read, read);
|
||||
}
|
||||
@@ -1617,7 +1617,7 @@ what exists.
|
||||
|
||||
- The Agent mode selector sits at the left of the top row; run status and token counts align at the right and wrap below on narrow panes. Controls remain outside the live status region; the input toolbar keeps task actions, model and execution settings.
|
||||
- The row and composition controls wrap independently when space is constrained. Long composition names truncate inside the selector; diagnostics wrap without widening the composer.
|
||||
- Preset editor styles live in `composition-presets.css` beside the catalog; the composer row remains in `app.css`.
|
||||
- Preset editor and identifier controls share field tokens in `composition-presets.css`; the composer row remains in `app.css`. The row is mounted only after the person enables the mode picker; otherwise the original run status is rendered directly.
|
||||
- Composition selection and management share one menu: choices show source and capability notes; the separated footer opens management. Started conversations keep the same trigger with lock details inside the menu, and choices explicitly create another conversation.
|
||||
- The trigger shows only the mode name and arrow. Source and lock state remain in its accessible name, tooltip and menu; they do not widen the trigger.
|
||||
- Preset management separates two built-in mode cards from custom presets. Card selection inspects a definition; setting a default affects new conversations and never switches the active binding.
|
||||
|
||||
@@ -31,10 +31,11 @@
|
||||
.composition-modules legend { font-weight: 600; padding-inline: 4px; }
|
||||
.composition-module { margin-block: 8px; }
|
||||
.composition-module .in { flex: 1; min-width: 100px; }
|
||||
.composition-editor :is(input, select) {
|
||||
#set-agent-presets :is(.composition-editor, .composition-toolbar) :is(input, select) {
|
||||
width: 100%; min-height: 34px; border: 1px solid var(--border); border-radius: var(--r-sm);
|
||||
background: var(--raised); color: var(--text); padding: 6px 9px; font: var(--w-reg) var(--fs-ui)/1.5 var(--ui);
|
||||
}
|
||||
.composition-editor :is(input, select):focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.composition-editor :is(input, select):disabled { opacity: .65; }
|
||||
#set-agent-presets :is(.composition-editor, .composition-toolbar) :is(input, select):focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
#set-agent-presets :is(.composition-editor, .composition-toolbar) :is(input, select):disabled { opacity: .65; }
|
||||
#set-agent-presets .composition-toolbar :is(input, select) { flex: 1; min-width: 100px; max-width: 100%; }
|
||||
.composition-editor h4 { margin: 10px 12px 0; font-size: var(--fs-ui); }
|
||||
@@ -1,15 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
import {cleanup,render,screen,waitFor} from "@testing-library/react";
|
||||
import {act,cleanup,render,screen,waitFor} from "@testing-library/react";
|
||||
import "./testkit";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {afterEach,expect,it,vi} from "vitest";
|
||||
import {afterEach,beforeEach,expect,it,vi} from "vitest";
|
||||
import {MockPort} from "../port/mock";
|
||||
import {AgentCompositionPicker} from "./AgentCompositionPicker";
|
||||
import {CompositionNewContext} from "./CompositionNewContext";
|
||||
import {RunLine} from "./RunLine";
|
||||
import { setShowsCompositionPicker } from "../state/compositionPicker";
|
||||
|
||||
beforeEach(() => setShowsCompositionPicker(true));
|
||||
afterEach(cleanup);
|
||||
|
||||
it("hides the picker and performs no composition reads until explicitly enabled", async () => {
|
||||
setShowsCompositionPicker(false);
|
||||
const port = new MockPort();
|
||||
const read = vi.spyOn(port, "agentPresets");
|
||||
const select = vi.spyOn(port, "selectAgentComposition");
|
||||
render(<AgentCompositionPicker port={port} running={false} onChanged={()=>{}} onSettings={()=>{}} onError={()=>{}}/>);
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
act(() => setShowsCompositionPicker(true));
|
||||
await screen.findByRole("button", { name: /^Agent 组合/ });
|
||||
act(() => setShowsCompositionPicker(false));
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an unavailable state with a management entry when the catalog fails", async () => {
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, "agentPresets").mockRejectedValue(new Error("catalog unavailable"));
|
||||
const settings = vi.fn();
|
||||
render(<AgentCompositionPicker port={port} running={false} onChanged={()=>{}} onSettings={settings} onError={()=>{}}/>);
|
||||
const failed = await screen.findByRole("button", { name: "Agent 预设暂不可用" });
|
||||
await userEvent.click(failed);
|
||||
expect(settings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("groups composition controls with the run state without announcing controls as live status",async()=>{
|
||||
const port=new MockPort();const changed=vi.fn();const settings=vi.fn();
|
||||
const user=userEvent.setup();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Picker, type MenuItem } from "./Menu";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { useShowsCompositionPicker } from "../state/compositionPicker";
|
||||
|
||||
const MANAGE = "__manage";
|
||||
|
||||
@@ -29,14 +30,21 @@ export function AgentCompositionPicker({ port, binding, locked: statusLocked, ru
|
||||
const [catalog, setCatalog] = useState<AgentPresetCatalog | null>(null);
|
||||
const [current, setCurrent] = useState<CompositionSummary | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [unavailable, setUnavailable] = useState(false);
|
||||
const enabled = useShowsCompositionPicker();
|
||||
const createNew = useContext(CompositionNewContext);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let active = true;
|
||||
setUnavailable(false);
|
||||
Promise.all([port.agentPresets(), port.agentComposition()]).then(([list, binding]) => {
|
||||
if (active) { setCatalog(list); setCurrent(binding); }
|
||||
}).catch(() => {});
|
||||
}).catch(() => { if (active) setUnavailable(true); });
|
||||
return () => { active = false; };
|
||||
}, [port, running, binding?.digest, binding?.started]);
|
||||
}, [enabled, port, running, binding?.digest, binding?.started]);
|
||||
if (!enabled) return null;
|
||||
if (unavailable) return <button className="mode composition-trigger" onClick={onSettings}
|
||||
data-action="agent-composition.unavailable">{t("Agent 预设暂不可用")}</button>;
|
||||
if (!catalog) return null;
|
||||
const selected = catalog.presets.find((v) => v.definition.id === (current?.id ?? "builtin:general"))?.definition;
|
||||
const locked = running || statusLocked || !!current?.started;
|
||||
|
||||
@@ -1,11 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
import "./testkit";
|
||||
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { AgentPresets } from "./AgentPresets";
|
||||
import { boot, STORAGE, t } from "../i18n";
|
||||
import { AgentCompositionPicker } from "./AgentCompositionPicker";
|
||||
import { RunLine } from "./RunLine";
|
||||
import { setShowsCompositionPicker } from "../state/compositionPicker";
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => { cleanup(); localStorage.setItem(STORAGE, "zh"); boot(); });
|
||||
|
||||
it("enables the picker from Settings without changing the active conversation, then hides it", async () => {
|
||||
localStorage.removeItem("rx-show-agent-composition");
|
||||
const port = new MockPort();
|
||||
const select = vi.spyOn(port, "selectAgentComposition");
|
||||
const user = userEvent.setup();
|
||||
render(<><AgentPresets port={port}/>
|
||||
<RunLine label="空闲" running={false} blocked={false} sent={0} received={0} estimated={false}>
|
||||
<AgentCompositionPicker port={port} running={false} onChanged={()=>{}} onSettings={()=>{}} onError={()=>{}}/>
|
||||
</RunLine>
|
||||
</>);
|
||||
await screen.findByRole("button", { name: "标准模式" });
|
||||
expect(screen.queryByRole("button", { name: /^Agent 组合/ })).toBeNull();
|
||||
expect(document.querySelector(".compose-context")).toBeNull();
|
||||
const toggle = screen.getByRole("switch", { name: "显示模式选择器" });
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("false");
|
||||
await user.click(toggle);
|
||||
const picker = await screen.findByRole("button", { name: /^Agent 组合/ });
|
||||
await user.click(picker);
|
||||
await user.click(screen.getByRole("menuitem", { name: /精简模式/ }));
|
||||
const before = await port.agentComposition();
|
||||
await user.click(toggle);
|
||||
expect(screen.queryByRole("button", { name: /^Agent 组合/ })).toBeNull();
|
||||
expect(await port.agentComposition()).toEqual(before);
|
||||
expect(select).toHaveBeenCalledExactlyOnceWith("builtin:minimal");
|
||||
setShowsCompositionPicker(false);
|
||||
});
|
||||
|
||||
it("copies both built-in name and description in the window language", async () => {
|
||||
localStorage.setItem(STORAGE, "en"); boot();
|
||||
const port = new MockPort();
|
||||
render(<AgentPresets port={port} />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Duplicate as custom preset" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save preset" }));
|
||||
await screen.findByRole("status");
|
||||
const catalog = await port.agentPresets();
|
||||
const base = catalog.presets.find((v) => v.definition.id === "builtin:general")!.definition;
|
||||
const copy = catalog.presets.find((v) => v.definition.id === "user:custom")!.definition;
|
||||
expect(copy.name).toBe("Standard mode Copy");
|
||||
expect(copy.description).toBe(t(base.description));
|
||||
});
|
||||
|
||||
describe("Agent preset management", () => {
|
||||
it("edits the existing user persona in Standard without migration or binding", async () => {
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import type { AgentComposition, AgentPresetCatalog, AgentPresetVersion, CompositionSummary } from "../port/composition";
|
||||
import type {
|
||||
AgentComposition,
|
||||
AgentPresetCatalog,
|
||||
AgentPresetVersion,
|
||||
CompositionSummary,
|
||||
} from "../port/composition";
|
||||
import { builtinCompositions } from "../port/composition";
|
||||
import { t } from "../i18n";
|
||||
import { reason,say } from "../i18n/kernel";
|
||||
import { reason, say } from "../i18n/kernel";
|
||||
import { Group } from "./Group";
|
||||
import { CompositionEditor } from "./CompositionEditor";
|
||||
import { CompositionCatalog } from "./CompositionCatalog";
|
||||
import { Persona } from "./Persona";
|
||||
import { Switch } from "./Switch";
|
||||
import { setShowsCompositionPicker, useShowsCompositionPicker } from "../state/compositionPicker";
|
||||
|
||||
export function AgentPresets({ port, focusPersona = false }: { port: AgentPort; focusPersona?: boolean }) {
|
||||
const showsPicker = useShowsCompositionPicker();
|
||||
const [catalog, setCatalog] = useState<AgentPresetCatalog | null>(null);
|
||||
const [version, setVersion] = useState<AgentPresetVersion | null>(null);
|
||||
const [draft, setDraft] = useState<AgentComposition | null>(null);
|
||||
@@ -19,67 +27,351 @@ export function AgentPresets({ port, focusPersona = false }: { port: AgentPort;
|
||||
const [saved, setSaved] = useState("");
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [exported, setExported] = useState("");
|
||||
const [valid,setValid] = useState(true);
|
||||
const [editorEpoch,setEditorEpoch] = useState(0);
|
||||
const [importDiagnostics,setImportDiagnostics] = useState<AgentPresetCatalog["diagnostics"]>([]);
|
||||
const select = (v: AgentPresetVersion) => { setVersion(v); setDraft(structuredClone(v.definition)); setSaved(""); setError(""); setDeleting(false); setExported(""); setImportDiagnostics([]);setValid(true);setEditorEpoch((n)=>n+1); };
|
||||
const [valid, setValid] = useState(true);
|
||||
const [editorEpoch, setEditorEpoch] = useState(0);
|
||||
const [importDiagnostics, setImportDiagnostics] = useState<AgentPresetCatalog["diagnostics"]>([]);
|
||||
const select = (v: AgentPresetVersion) => {
|
||||
setVersion(v);
|
||||
setDraft(structuredClone(v.definition));
|
||||
setSaved("");
|
||||
setError("");
|
||||
setDeleting(false);
|
||||
setExported("");
|
||||
setImportDiagnostics([]);
|
||||
setValid(true);
|
||||
setEditorEpoch((n) => n + 1);
|
||||
};
|
||||
const load = async () => {
|
||||
try {
|
||||
const [next, binding] = await Promise.all([port.agentPresets(), port.agentComposition()]);
|
||||
setCatalog(next); setCurrent(binding);
|
||||
const v = next.presets.find((v) => v.definition.id === (focusPersona ? "builtin:general" : binding?.id)) ?? next.presets.find((v) => v.definition.id === next.default) ?? next.presets[0];
|
||||
setCatalog(next);
|
||||
setCurrent(binding);
|
||||
const v =
|
||||
next.presets.find((v) => v.definition.id === (focusPersona ? "builtin:general" : binding?.id)) ??
|
||||
next.presets.find((v) => v.definition.id === next.default) ??
|
||||
next.presets[0];
|
||||
if (v) select(v);
|
||||
} catch (e) { setError(reason(e)); }
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
}
|
||||
};
|
||||
useEffect(() => { void load(); }, [port]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [port]);
|
||||
useEffect(() => {
|
||||
const standard = catalog?.presets.find((v) => v.definition.id === "builtin:general");
|
||||
if (focusPersona && standard) select(standard);
|
||||
}, [focusPersona]);
|
||||
const perform = async (work: () => Promise<void>) => { setBusy(true); setError(""); setSaved(""); try { await work(); } catch (e) { setError(reason(e)); } finally { setBusy(false); } };
|
||||
const perform = async (work: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSaved("");
|
||||
try {
|
||||
await work();
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const copy = (base: AgentComposition) => {
|
||||
const ids = new Set(catalog?.presets.map((v) => v.definition.id));
|
||||
let id = "user:custom"; let n = 1;
|
||||
let id = "user:custom";
|
||||
let n = 1;
|
||||
while (ids.has(id)) id = `user:custom-${n++}`;
|
||||
setVersion(null); setDraft({ ...structuredClone(base), id, name: `${base.id.startsWith("builtin:") ? t(base.name) : base.name} ${t("副本")}` }); setSaved(""); setError(""); setDeleting(false);setImportDiagnostics([]);setValid(true);setEditorEpoch((n)=>n+1);
|
||||
setVersion(null);
|
||||
setDraft({
|
||||
...structuredClone(base),
|
||||
id,
|
||||
name: `${base.id.startsWith("builtin:") ? t(base.name) : base.name} ${t("副本")}`,
|
||||
description: base.id.startsWith("builtin:") ? t(base.description) : base.description,
|
||||
});
|
||||
setSaved("");
|
||||
setError("");
|
||||
setDeleting(false);
|
||||
setImportDiagnostics([]);
|
||||
setValid(true);
|
||||
setEditorEpoch((n) => n + 1);
|
||||
};
|
||||
const immutable = draft?.id.startsWith("builtin:") ?? true;
|
||||
const changed = draft && (!version || JSON.stringify(draft) !== JSON.stringify(version.definition));
|
||||
return <Group id="agent-presets" title={t("Agent 预设")} hint={t("为不同会话组合人设、工具、技能与扩展。模型保持独立;已开始的会话保留原版本。") }>
|
||||
<div className="box">
|
||||
{!catalog ? <><p>{error || t("读取预设中…")}</p><button className="act" data-action="agent-preset.reload" onClick={() => void load()}>{t("重新加载")}</button></> : <>
|
||||
<CompositionCatalog catalog={catalog} selected={version?.definition.id} busy={busy} onSelect={select} onCreate={() => copy(builtinCompositions()[0])} onDefault={(id) => void perform(async () => { await port.setDefaultAgentPreset(id, false); setCatalog(await port.agentPresets()); setSaved(t("已设为个人首选预设")); })} />
|
||||
<div className="composition-toolbar">
|
||||
<button className="act" data-action="agent-preset.copy" disabled={!draft || busy} onClick={() => draft && copy(draft)}>{t("复制为自定义预设")}</button>
|
||||
<button className="act" data-action="agent-preset.reload" disabled={busy} onClick={() => void load()}>{t("重新加载")}</button>
|
||||
return (
|
||||
<>
|
||||
<Group
|
||||
id="composition-picker"
|
||||
title={t("显示模式选择器")}
|
||||
hint={t("默认关闭。开启后在输入框顶部选择模式;关闭只隐藏入口,不改变已有会话的模式。")}
|
||||
>
|
||||
<div className="lrow">
|
||||
<span className="tx">
|
||||
<span className="lb">{t("显示模式选择器")}</span>
|
||||
</span>
|
||||
<Switch
|
||||
label={t("显示模式选择器")}
|
||||
data-action="agent-preset.show-picker"
|
||||
on={showsPicker}
|
||||
onClick={() => setShowsCompositionPicker(!showsPicker)}
|
||||
/>
|
||||
</div>
|
||||
{current && <p className="ds">{t("当前会话")}: {current.id.startsWith("builtin:") ? t(current.name) : current.name} · {current.revision.slice(0, 12)} {current.started ? t("已锁定") : t("首次提交前可切换")}{version && current.id === version.definition.id && current.revision !== version.revision ? ` · ${t("当前会话使用旧版")}` : ""}</p>}
|
||||
{draft && <>
|
||||
{!version && <label className="pers-field"><span className="lb">{t("预设标识")}</span><div className="composition-toolbar"><select aria-label={t("存放范围")} data-action-change="agent-preset.scope" value={draft.id.split(":")[0]} onChange={(e) => setDraft({ ...draft, id: `${e.target.value}:${draft.id.split(":")[1]}` })}><option value="user">{t("个人")}</option><option value="project">{t("项目共享")}</option></select><input className="in" aria-label={t("预设标识")} data-action-change="agent-preset.id" value={draft.id.split(":")[1]} onChange={(e) => setDraft({ ...draft, id: `${draft.id.split(":")[0]}:${e.target.value}` })} /></div></label>}
|
||||
{immutable && draft.id !== "builtin:general" && <p className="ds">{t("内置预设只读,复制后即可编辑。")}</p>}
|
||||
{draft.id === "builtin:general" ? <>{current && <p className="ds">{t("当前会话已绑定预设,标准模式的人设修改不会更新此绑定。")}</p>}<Persona port={port} /></> : <CompositionEditor key={editorEpoch} draft={draft} modules={catalog.modules} disabled={busy || immutable} onValidityChange={setValid} onChange={(d) => { setDraft(d); setSaved(""); }} />}
|
||||
<div className="composition-toolbar">
|
||||
<button className="act" data-action="agent-preset.save" disabled={busy || immutable || !changed || !valid} onClick={() => void perform(async () => {
|
||||
const result = await port.saveAgentPreset(draft, version?.revision ?? "");
|
||||
setCatalog(await port.agentPresets()); setVersion(result); setDraft(structuredClone(result.definition)); setSaved(t("已保存,新会话使用新版"));
|
||||
})}>{t("保存预设")}</button>
|
||||
<button className="act" data-action="agent-preset.default" disabled={busy || !version || !!changed || draft.id.startsWith("project:")} onClick={() => void perform(async () => { await port.setDefaultAgentPreset(draft.id, false); setCatalog(await port.agentPresets()); setSaved(t("已设为个人首选预设")); })}>{t("设为个人首选")}</button>
|
||||
<button className="act" data-action="agent-preset.workspace-default" disabled={busy || !version || !!changed} onClick={() => void perform(async () => { await port.setDefaultAgentPreset(draft.id, true); setCatalog(await port.agentPresets()); setSaved(t("已设为当前项目首选预设")); })}>{t("设为项目首选")}</button>
|
||||
<button className="act" data-action="agent-preset.export" disabled={busy || !version || !!changed} onClick={() => void perform(async () => setExported(JSON.stringify(await port.exportAgentPreset(draft.id), null, 2)))}>{t("导出文件")}</button>
|
||||
{!immutable && version && <button className="act" data-action="agent-preset.delete-preview" disabled={busy} onClick={() => setDeleting(true)}>{t("删除预设")}</button>}
|
||||
</div>
|
||||
{deleting && version && <div className="find"><p>{t("删除后不再供新会话选择,已有会话保留绑定版本。")}</p><button className="act" data-action="agent-preset.delete" onClick={() => void perform(async () => { await port.deleteAgentPreset(version.definition.id, version.revision); await load(); })}>{t("确认删除")}</button><button className="act" data-action="agent-preset.delete-cancel" onClick={() => setDeleting(false)}>{t("取消")}</button></div>}
|
||||
</>}
|
||||
<label className="pers-field"><span className="lb">{t("导入预设文件(先预览,再保存)")}</span><input type="file" accept="application/json,.json" data-action-change="agent-preset.import" disabled={busy} onChange={(e) => {
|
||||
const file = e.target.files?.[0]; if (!file) return;
|
||||
void perform(async () => { if(file.size>4*1024*1024) throw {code:"agent_composition.invalid"};const imported = await port.importAgentPreset(JSON.parse(await file.text()) as AgentComposition); copy(imported.definition);setImportDiagnostics(imported.diagnostics); setSaved(t("导入预览已就绪,检查模块后保存。")); });
|
||||
}} /></label>
|
||||
{exported && <a className="act" data-action="agent-preset.download" download="agent-preset.json" href={`data:application/json;charset=utf-8,${encodeURIComponent(exported)}`}>{t("下载预设 JSON")}</a>}
|
||||
{catalog.diagnostics.map((d, i) => <p className="ds" key={i}>{d.module}: {d.detail}</p>)}
|
||||
{importDiagnostics.map((d,i)=><p className="ds" key={`import-${i}`}>{d.module}: {say({code:d.code})}</p>)}
|
||||
</>}
|
||||
{saved && <p className="find" role="status">{saved}</p>}
|
||||
{error && <p className="find" data-lvl="err" role="alert">{error}</p>}
|
||||
</div>
|
||||
</Group>;
|
||||
</Group>
|
||||
<Group
|
||||
id="agent-presets"
|
||||
title={t("Agent 预设")}
|
||||
hint={t("为不同会话组合人设、工具、技能与扩展。模型保持独立;已开始的会话保留原版本。")}
|
||||
>
|
||||
<div className="box">
|
||||
{!catalog ? (
|
||||
<>
|
||||
<p>{error || t("读取预设中…")}</p>
|
||||
<button className="act" data-action="agent-preset.reload" onClick={() => void load()}>
|
||||
{t("重新加载")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CompositionCatalog
|
||||
catalog={catalog}
|
||||
selected={version?.definition.id}
|
||||
busy={busy}
|
||||
onSelect={select}
|
||||
onCreate={() => copy(builtinCompositions()[0])}
|
||||
onDefault={(id) =>
|
||||
void perform(async () => {
|
||||
await port.setDefaultAgentPreset(id, false);
|
||||
setCatalog(await port.agentPresets());
|
||||
setSaved(t("已设为个人首选预设"));
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="composition-toolbar">
|
||||
<button
|
||||
className="act"
|
||||
data-action="agent-preset.copy"
|
||||
disabled={!draft || busy}
|
||||
onClick={() => draft && copy(draft)}
|
||||
>
|
||||
{t("复制为自定义预设")}
|
||||
</button>
|
||||
<button
|
||||
className="act"
|
||||
data-action="agent-preset.reload"
|
||||
disabled={busy}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
{t("重新加载")}
|
||||
</button>
|
||||
</div>
|
||||
{current && (
|
||||
<p className="ds">
|
||||
{t("当前会话")}: {current.id.startsWith("builtin:") ? t(current.name) : current.name} ·{" "}
|
||||
{current.revision.slice(0, 12)} {current.started ? t("已锁定") : t("首次提交前可切换")}
|
||||
{version && current.id === version.definition.id && current.revision !== version.revision
|
||||
? ` · ${t("当前会话使用旧版")}`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
{draft && (
|
||||
<>
|
||||
{!version && (
|
||||
<label className="pers-field">
|
||||
<span className="lb">{t("预设标识")}</span>
|
||||
<div className="composition-toolbar">
|
||||
<select
|
||||
aria-label={t("存放范围")}
|
||||
data-action-change="agent-preset.scope"
|
||||
value={draft.id.split(":")[0]}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, id: `${e.target.value}:${draft.id.split(":")[1]}` })
|
||||
}
|
||||
>
|
||||
<option value="user">{t("个人")}</option>
|
||||
<option value="project">{t("项目共享")}</option>
|
||||
</select>
|
||||
<input
|
||||
className="in"
|
||||
aria-label={t("预设标识")}
|
||||
data-action-change="agent-preset.id"
|
||||
value={draft.id.split(":")[1]}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, id: `${draft.id.split(":")[0]}:${e.target.value}` })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
{immutable && draft.id !== "builtin:general" && (
|
||||
<p className="ds">{t("内置预设只读,复制后即可编辑。")}</p>
|
||||
)}
|
||||
{draft.id === "builtin:general" ? (
|
||||
<>
|
||||
{current && (
|
||||
<p className="ds">{t("当前会话已绑定预设,标准模式的人设修改不会更新此绑定。")}</p>
|
||||
)}
|
||||
<Persona port={port} />
|
||||
</>
|
||||
) : (
|
||||
<CompositionEditor
|
||||
key={editorEpoch}
|
||||
draft={draft}
|
||||
modules={catalog.modules}
|
||||
disabled={busy || immutable}
|
||||
onValidityChange={setValid}
|
||||
onChange={(d) => {
|
||||
setDraft(d);
|
||||
setSaved("");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="composition-toolbar">
|
||||
<button
|
||||
className="act"
|
||||
data-action="agent-preset.save"
|
||||
disabled={busy || immutable || !changed || !valid}
|
||||
onClick={() =>
|
||||
void perform(async () => {
|
||||
const result = await port.saveAgentPreset(draft, version?.revision ?? "");
|
||||
setCatalog(await port.agentPresets());
|
||||
setVersion(result);
|
||||
setDraft(structuredClone(result.definition));
|
||||
setSaved(t("已保存,新会话使用新版"));
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("保存预设")}
|
||||
</button>
|
||||
<button
|
||||
className="act"
|
||||
data-action="agent-preset.default"
|
||||
disabled={busy || !version || !!changed || draft.id.startsWith("project:")}
|
||||
onClick={() =>
|
||||
void perform(async () => {
|
||||
await port.setDefaultAgentPreset(draft.id, false);
|
||||
setCatalog(await port.agentPresets());
|
||||
setSaved(t("已设为个人首选预设"));
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("设为个人首选")}
|
||||
</button>
|
||||
<button
|
||||
className="act"
|
||||
data-action="agent-preset.workspace-default"
|
||||
disabled={busy || !version || !!changed}
|
||||
onClick={() =>
|
||||
void perform(async () => {
|
||||
await port.setDefaultAgentPreset(draft.id, true);
|
||||
setCatalog(await port.agentPresets());
|
||||
setSaved(t("已设为当前项目首选预设"));
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("设为项目首选")}
|
||||
</button>
|
||||
<button
|
||||
className="act"
|
||||
data-action="agent-preset.export"
|
||||
disabled={busy || !version || !!changed}
|
||||
onClick={() =>
|
||||
void perform(async () =>
|
||||
setExported(JSON.stringify(await port.exportAgentPreset(draft.id), null, 2)),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("导出文件")}
|
||||
</button>
|
||||
{!immutable && version && (
|
||||
<button
|
||||
className="act"
|
||||
data-action="agent-preset.delete-preview"
|
||||
disabled={busy}
|
||||
onClick={() => setDeleting(true)}
|
||||
>
|
||||
{t("删除预设")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{deleting && version && (
|
||||
<div className="find">
|
||||
<p>{t("删除后不再供新会话选择,已有会话保留绑定版本。")}</p>
|
||||
<button
|
||||
className="act"
|
||||
data-action="agent-preset.delete"
|
||||
onClick={() =>
|
||||
void perform(async () => {
|
||||
await port.deleteAgentPreset(version.definition.id, version.revision);
|
||||
await load();
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("确认删除")}
|
||||
</button>
|
||||
<button
|
||||
className="act"
|
||||
data-action="agent-preset.delete-cancel"
|
||||
onClick={() => setDeleting(false)}
|
||||
>
|
||||
{t("取消")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<label className="pers-field">
|
||||
<span className="lb">{t("导入预设文件(先预览,再保存)")}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
data-action-change="agent-preset.import"
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
void perform(async () => {
|
||||
if (file.size > 4 * 1024 * 1024) throw { code: "agent_composition.invalid" };
|
||||
const imported = await port.importAgentPreset(
|
||||
JSON.parse(await file.text()) as AgentComposition,
|
||||
);
|
||||
copy(imported.definition);
|
||||
setImportDiagnostics(imported.diagnostics);
|
||||
setSaved(t("导入预览已就绪,检查模块后保存。"));
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{exported && (
|
||||
<a
|
||||
className="act"
|
||||
data-action="agent-preset.download"
|
||||
download="agent-preset.json"
|
||||
href={`data:application/json;charset=utf-8,${encodeURIComponent(exported)}`}
|
||||
>
|
||||
{t("下载预设 JSON")}
|
||||
</a>
|
||||
)}
|
||||
{catalog.diagnostics.map((d, i) => (
|
||||
<p className="ds" key={i}>
|
||||
{d.module}: {d.detail}
|
||||
</p>
|
||||
))}
|
||||
{importDiagnostics.map((d, i) => (
|
||||
<p className="ds" key={`import-${i}`}>
|
||||
{d.module}: {say({ code: d.code })}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{saved && (
|
||||
<p className="find" role="status">
|
||||
{saved}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="find" data-lvl="err" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +1,261 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { AgentComposition, AgentPresetCatalog,CompositionModule, CompositionKind, CompositionSelection } from "../port/composition";
|
||||
import type {
|
||||
AgentComposition,
|
||||
AgentPresetCatalog,
|
||||
CompositionModule,
|
||||
CompositionKind,
|
||||
CompositionSelection,
|
||||
} from "../port/composition";
|
||||
import { COMPOSITION_KINDS } from "../port/composition";
|
||||
import { Switch } from "./Switch";
|
||||
|
||||
const labels: Record<CompositionKind, string> = { tools: "工具", skills: "技能", mcp: "MCP 服务", extensions: "扩展", hooks: "自动化 Hook", commands: "斜杠命令" };
|
||||
const labels: Record<CompositionKind, string> = {
|
||||
tools: "工具",
|
||||
skills: "技能",
|
||||
mcp: "MCP 服务",
|
||||
extensions: "扩展",
|
||||
hooks: "自动化 Hook",
|
||||
commands: "斜杠命令",
|
||||
};
|
||||
|
||||
export function CompositionEditor({ draft, modules,disabled, onChange, onValidityChange }: { draft: AgentComposition; modules:AgentPresetCatalog["modules"];disabled: boolean; onChange: (d: AgentComposition) => void; onValidityChange: (valid:boolean)=>void }) {
|
||||
const [valid, setValid] = useState<Record<string,boolean>>({});
|
||||
useEffect(() => onValidityChange(Object.values(valid).every(Boolean)), [valid,onValidityChange]);
|
||||
export function CompositionEditor({
|
||||
draft,
|
||||
modules,
|
||||
disabled,
|
||||
onChange,
|
||||
onValidityChange,
|
||||
}: {
|
||||
draft: AgentComposition;
|
||||
modules: AgentPresetCatalog["modules"];
|
||||
disabled: boolean;
|
||||
onChange: (d: AgentComposition) => void;
|
||||
onValidityChange: (valid: boolean) => void;
|
||||
}) {
|
||||
const [valid, setValid] = useState<Record<string, boolean>>({});
|
||||
useEffect(() => onValidityChange(Object.values(valid).every(Boolean)), [valid, onValidityChange]);
|
||||
const persona = draft.persona;
|
||||
const updatePersona = (patch: Partial<AgentComposition["persona"]>) => onChange({ ...draft, persona: { ...persona, ...patch } });
|
||||
return <div className="composition-editor">
|
||||
<label className="pers-field"><span className="lb">{t("预设名称")}</span><input className="in" aria-label={t("预设名称")} data-action-change="agent-preset.name" disabled={disabled} value={draft.id.startsWith("builtin:") ? t(draft.name) : draft.name} onChange={(e) => onChange({ ...draft, name: e.target.value })} /></label>
|
||||
<label className="pers-field"><span className="lb">{t("预设说明")}</span><input className="in" data-action-change="agent-preset.description" disabled={disabled} value={draft.id.startsWith("builtin:") ? t(draft.description) : draft.description} onChange={(e) => onChange({ ...draft, description: e.target.value })} /></label>
|
||||
<h4>{t("系统人设")}</h4>
|
||||
<label className="pers-field"><span className="lb">{t("系统提示词开头")}</span><textarea aria-label={t("系统提示词开头")} data-action-change="persona.prefix" rows={4} maxLength={32768} disabled={disabled} value={persona.prefix} onChange={(e) => updatePersona({ prefix: e.target.value })} /></label>
|
||||
<label className="pers-field"><span className="lb">{t("第一方指令之后")}</span><textarea aria-label={t("第一方指令之后")} data-action-change="persona.suffix" rows={3} maxLength={32768} disabled={disabled} value={persona.suffix} onChange={(e) => updatePersona({ suffix: e.target.value })} /></label>
|
||||
<div className="lrow"><span className="tx"><span className="lb">{t("注入运行环境与离线提示")}</span></span><Switch data-action="persona.runtime-context" label={t("注入运行环境与离线提示")} on={persona.include_runtime_context} busy={disabled} onClick={() => updatePersona({ include_runtime_context: !persona.include_runtime_context })} /></div>
|
||||
{COMPOSITION_KINDS.map((kind) => <ModuleEditor key={`${draft.id}:${kind}`} kind={kind} choices={modules?.[kind] ?? []} value={draft[kind]} disabled={disabled} onValidityChange={(ok)=>setValid((prev)=>({...prev,[kind]:ok}))} onChange={(value) => onChange({ ...draft, [kind]: value })} />)}
|
||||
</div>;
|
||||
const updatePersona = (patch: Partial<AgentComposition["persona"]>) =>
|
||||
onChange({ ...draft, persona: { ...persona, ...patch } });
|
||||
return (
|
||||
<div className="composition-editor">
|
||||
<label className="pers-field">
|
||||
<span className="lb">{t("预设名称")}</span>
|
||||
<input
|
||||
className="in"
|
||||
aria-label={t("预设名称")}
|
||||
data-action-change="agent-preset.name"
|
||||
disabled={disabled}
|
||||
value={draft.id.startsWith("builtin:") ? t(draft.name) : draft.name}
|
||||
onChange={(e) => onChange({ ...draft, name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="pers-field">
|
||||
<span className="lb">{t("预设说明")}</span>
|
||||
<input
|
||||
className="in"
|
||||
data-action-change="agent-preset.description"
|
||||
disabled={disabled}
|
||||
value={draft.id.startsWith("builtin:") ? t(draft.description) : draft.description}
|
||||
onChange={(e) => onChange({ ...draft, description: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<h4>{t("系统人设")}</h4>
|
||||
<label className="pers-field">
|
||||
<span className="lb">{t("系统提示词开头")}</span>
|
||||
<textarea
|
||||
aria-label={t("系统提示词开头")}
|
||||
data-action-change="persona.prefix"
|
||||
rows={4}
|
||||
maxLength={32768}
|
||||
disabled={disabled}
|
||||
value={persona.prefix}
|
||||
onChange={(e) => updatePersona({ prefix: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="pers-field">
|
||||
<span className="lb">{t("第一方指令之后")}</span>
|
||||
<textarea
|
||||
aria-label={t("第一方指令之后")}
|
||||
data-action-change="persona.suffix"
|
||||
rows={3}
|
||||
maxLength={32768}
|
||||
disabled={disabled}
|
||||
value={persona.suffix}
|
||||
onChange={(e) => updatePersona({ suffix: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="lrow">
|
||||
<span className="tx">
|
||||
<span className="lb">{t("注入运行环境与离线提示")}</span>
|
||||
</span>
|
||||
<Switch
|
||||
data-action="persona.runtime-context"
|
||||
label={t("注入运行环境与离线提示")}
|
||||
on={persona.include_runtime_context}
|
||||
busy={disabled}
|
||||
onClick={() => updatePersona({ include_runtime_context: !persona.include_runtime_context })}
|
||||
/>
|
||||
</div>
|
||||
{COMPOSITION_KINDS.map((kind) => (
|
||||
<ModuleEditor
|
||||
key={`${draft.id}:${kind}`}
|
||||
kind={kind}
|
||||
choices={modules?.[kind] ?? []}
|
||||
value={draft[kind]}
|
||||
disabled={disabled}
|
||||
onValidityChange={(ok) => setValid((prev) => ({ ...prev, [kind]: ok }))}
|
||||
onChange={(value) => onChange({ ...draft, [kind]: value })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleEditor({ kind, choices,value, disabled, onChange, onValidityChange }: { kind: CompositionKind; choices:CompositionModule[];value: CompositionSelection; disabled: boolean; onChange: (value: CompositionSelection) => void; onValidityChange:(valid:boolean)=>void }) {
|
||||
function ModuleEditor({
|
||||
kind,
|
||||
choices,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
onValidityChange,
|
||||
}: {
|
||||
kind: CompositionKind;
|
||||
choices: CompositionModule[];
|
||||
value: CompositionSelection;
|
||||
disabled: boolean;
|
||||
onChange: (value: CompositionSelection) => void;
|
||||
onValidityChange: (valid: boolean) => void;
|
||||
}) {
|
||||
const [jsonErrors, setJsonErrors] = useState<Record<number, string>>({});
|
||||
const [rawParameters,setRawParameters] = useState<Record<number,string>>({});
|
||||
const validate = (errors:Record<number,string>) => { setJsonErrors(errors);onValidityChange(Object.values(errors).every((v)=>!v)); };
|
||||
const remove = (index:number) => {
|
||||
const shift = <T,>(source:Record<number,T>):Record<number,T> => Object.fromEntries(Object.entries(source).filter(([key])=>Number(key)!==index).map(([key,v])=>[Number(key)>index?Number(key)-1:Number(key),v]));
|
||||
validate(shift(jsonErrors));setRawParameters(shift(rawParameters));onChange({...value,members:value.members.filter((_,i)=>i!==index)});
|
||||
const [rawParameters, setRawParameters] = useState<Record<number, string>>({});
|
||||
const validate = (errors: Record<number, string>) => {
|
||||
setJsonErrors(errors);
|
||||
onValidityChange(Object.values(errors).every((v) => !v));
|
||||
};
|
||||
return <fieldset className="composition-modules" disabled={disabled}>
|
||||
<legend>{t(labels[kind])}</legend>
|
||||
{kind === "hooks" && <p className="ds">{t("安全 Hook 和权限规则始终生效;此处仅选择非阻断型自动化。")}</p>}
|
||||
<select aria-label={`${t(labels[kind])} ${t("选择方式")}`} data-action-change="agent-preset.module-mode" data-target={kind} value={value.mode} onChange={(e) => { validate({});setRawParameters({});onChange({ mode: e.target.value as "inherit" | "select", members: [] }); }}>
|
||||
<option value="inherit">{t("创建会话时继承当前配置")}</option><option value="select">{t("仅使用所选项(空列表表示禁用)")}</option>
|
||||
</select>
|
||||
{value.mode === "select" && <>
|
||||
{choices.length>0 && <select aria-label={`${t(labels[kind])} ${t("添加模块")}`} data-action-change="agent-preset.module-choose" data-target={kind} value="" onChange={(e)=>{if(e.target.value) onChange({...value,members:[...value.members,{id:e.target.value}]});}}><option value="">{t("添加模块")}</option>{choices.filter((m)=>!value.members.some((s)=>s.id===m.id)).map((m)=><option key={m.id} value={m.id}>{m.id}</option>)}</select>}
|
||||
{value.members.map((module, index) => <div className="composition-module" key={index}>
|
||||
<div className="composition-toolbar"><input className="in" aria-label={`${t(labels[kind])} ID ${index + 1}`} data-action-change="agent-preset.module-id" data-target={kind} value={module.id} onChange={(e) => onChange({ ...value, members: value.members.map((m, i) => i === index ? { ...m, id: e.target.value } : m) })} /><button type="button" className="act" data-action="agent-preset.module-remove" data-target={kind} onClick={() => remove(index)}>{t("移除")}</button></div>
|
||||
{(kind === "mcp" || kind === "extensions") && <label className="pers-field"><span className="ds">{t("独立参数(JSON);env 和 headers 仅填写 ${NAME} 引用")}</span><textarea aria-label={`${t(labels[kind])} ${t("独立参数")} ${index + 1}`} data-action-change="agent-preset.module-parameters" data-target={kind} rows={3} value={rawParameters[index]??JSON.stringify(module.parameters ?? {}, null, 2)} onChange={(e) => {
|
||||
setRawParameters({...rawParameters,[index]:e.target.value});
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(e.target.value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("object required");
|
||||
validate({ ...jsonErrors, [index]: "" });
|
||||
onChange({ ...value, members: value.members.map((m, i) => i === index ? { ...m, parameters: parsed as Record<string, unknown> } : m) });
|
||||
} catch { validate({ ...jsonErrors, [index]: t("参数需要有效的 JSON 对象") }); }
|
||||
}} />{jsonErrors[index] && <span role="alert">{jsonErrors[index]}</span>}</label>}
|
||||
</div>)}
|
||||
<button type="button" className="act" data-action="agent-preset.module-add" data-target={kind} onClick={() => onChange({ ...value, members: [...value.members, { id: "" }] })}>{t("添加模块")}</button>
|
||||
</>}
|
||||
</fieldset>;
|
||||
const remove = (index: number) => {
|
||||
const shift = <T,>(source: Record<number, T>): Record<number, T> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(source)
|
||||
.filter(([key]) => Number(key) !== index)
|
||||
.map(([key, v]) => [Number(key) > index ? Number(key) - 1 : Number(key), v]),
|
||||
);
|
||||
validate(shift(jsonErrors));
|
||||
setRawParameters(shift(rawParameters));
|
||||
onChange({ ...value, members: value.members.filter((_, i) => i !== index) });
|
||||
};
|
||||
return (
|
||||
<fieldset className="composition-modules" disabled={disabled}>
|
||||
<legend>{t(labels[kind])}</legend>
|
||||
{kind === "hooks" && (
|
||||
<p className="ds">{t("安全 Hook 和权限规则始终生效;此处仅选择非阻断型自动化。")}</p>
|
||||
)}
|
||||
<select
|
||||
aria-label={`${t(labels[kind])} ${t("选择方式")}`}
|
||||
data-action-change="agent-preset.module-mode"
|
||||
data-target={kind}
|
||||
value={value.mode}
|
||||
onChange={(e) => {
|
||||
validate({});
|
||||
setRawParameters({});
|
||||
onChange({ mode: e.target.value as "inherit" | "select", members: [] });
|
||||
}}
|
||||
>
|
||||
<option value="inherit">{t("创建会话时继承当前配置")}</option>
|
||||
<option value="select">{t("仅使用所选项(空列表表示禁用)")}</option>
|
||||
</select>
|
||||
{value.mode === "select" && (
|
||||
<>
|
||||
{choices.length > 0 && (
|
||||
<select
|
||||
aria-label={`${t(labels[kind])} ${t("添加模块")}`}
|
||||
data-action-change="agent-preset.module-choose"
|
||||
data-target={kind}
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value)
|
||||
onChange({ ...value, members: [...value.members, { id: e.target.value }] });
|
||||
}}
|
||||
>
|
||||
<option value="">{t("添加模块")}</option>
|
||||
{choices
|
||||
.filter((m) => !value.members.some((s) => s.id === m.id))
|
||||
.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{value.members.map((module, index) => (
|
||||
<div className="composition-module" key={index}>
|
||||
<div className="composition-toolbar">
|
||||
<input
|
||||
className="in"
|
||||
aria-label={`${t(labels[kind])} ID ${index + 1}`}
|
||||
data-action-change="agent-preset.module-id"
|
||||
data-target={kind}
|
||||
value={module.id}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...value,
|
||||
members: value.members.map((m, i) => (i === index ? { ...m, id: e.target.value } : m)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="act"
|
||||
data-action="agent-preset.module-remove"
|
||||
data-target={kind}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
{t("移除")}
|
||||
</button>
|
||||
</div>
|
||||
{(kind === "mcp" || kind === "extensions") && (
|
||||
<label className="pers-field">
|
||||
<span className="ds">{t("独立参数(JSON);env 和 headers 仅填写 ${NAME} 引用")}</span>
|
||||
<textarea
|
||||
aria-label={`${t(labels[kind])} ${t("独立参数")} ${index + 1}`}
|
||||
data-action-change="agent-preset.module-parameters"
|
||||
data-target={kind}
|
||||
rows={3}
|
||||
value={rawParameters[index] ?? JSON.stringify(module.parameters ?? {}, null, 2)}
|
||||
onChange={(e) => {
|
||||
setRawParameters({ ...rawParameters, [index]: e.target.value });
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(e.target.value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
||||
throw new Error("object required");
|
||||
validate({ ...jsonErrors, [index]: "" });
|
||||
onChange({
|
||||
...value,
|
||||
members: value.members.map((m, i) =>
|
||||
i === index ? { ...m, parameters: parsed as Record<string, unknown> } : m,
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
validate({ ...jsonErrors, [index]: t("参数需要有效的 JSON 对象") });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{jsonErrors[index] && <span role="alert">{jsonErrors[index]}</span>}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="act"
|
||||
data-action="agent-preset.module-add"
|
||||
data-target={kind}
|
||||
onClick={() => onChange({ ...value, members: [...value.members, { id: "" }] })}
|
||||
>
|
||||
{t("添加模块")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -148,7 +148,7 @@ export function Picker({
|
||||
el.style.top = `${at.top}px`;
|
||||
}, [align, place]);
|
||||
|
||||
useFollow(open, put, shown.length);
|
||||
useFollow(open, put, shown.length, { anchor: btn, container: menu });
|
||||
|
||||
// Headings are divs and never take focus, so walking the buttons is what
|
||||
// keeps one arrow press from landing on nothing.
|
||||
|
||||
@@ -4,19 +4,39 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
import { NativeSessionImport } from "./NativeSessionImport";
|
||||
import { HttpError } from "../port/http_error";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => { cleanup(); localStorage.setItem(STORAGE, "zh"); boot(); });
|
||||
|
||||
it("passes the complete native envelope to the backend and refreshes history", async () => {
|
||||
it("uploads the original file without reading or parsing it in the renderer", async () => {
|
||||
const hub = new MockHub();
|
||||
const imported = vi.spyOn(hub,"importNativeSession");
|
||||
const refresh = vi.fn();
|
||||
const archive = {format:"reasonix-native-session",version:2,records:[{role:"system",agentComposition:{digest:"unchanged"}}]};
|
||||
const file = new File([JSON.stringify(archive)],"session.json",{type:"application/json"});
|
||||
Object.defineProperty(file,"text",{value:async()=>JSON.stringify(archive)});
|
||||
const read = vi.fn().mockRejectedValue(new Error("renderer must not read the archive"));
|
||||
Object.defineProperty(file,"text",{value:read});
|
||||
render(<NativeSessionImport hub={hub} workspace="/workspace" onImported={refresh}/>);
|
||||
await userEvent.upload(screen.getByLabelText("选择会话 JSON 文件"),file);
|
||||
expect((await screen.findByRole("status")).textContent).toContain("绑定版本保持不变");
|
||||
expect(imported).toHaveBeenCalledWith(archive,"/workspace");
|
||||
expect(imported).toHaveBeenCalledWith(file,"/workspace");
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["zh", "en"])("localizes a malformed upload by host code in %s", async (language) => {
|
||||
localStorage.setItem(STORAGE, language); boot();
|
||||
const hub = new MockHub();
|
||||
const refresh = vi.fn();
|
||||
vi.spyOn(hub, "importNativeSession").mockRejectedValue(new HttpError(400,
|
||||
"SyntaxError: unexpected byte", { code: "session.archive_invalid", error: "raw parser detail" }));
|
||||
const file = new File(["{broken"], "broken.json", { type: "application/json" });
|
||||
render(<NativeSessionImport hub={hub} workspace="/workspace" onImported={refresh}/>);
|
||||
const label = language === "zh" ? "选择会话 JSON 文件" : "Choose a session JSON file";
|
||||
await userEvent.upload(screen.getByLabelText(label), file);
|
||||
expect((await screen.findByRole("alert")).textContent).toBe(language === "zh"
|
||||
? "会话文件无效,请选择完整的原生会话导出文件。"
|
||||
: "Invalid session file. Choose a complete native session export.");
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -15,8 +15,7 @@ export function NativeSessionImport({ hub, workspace, onImported }: { hub: HubPo
|
||||
if (!file) return;
|
||||
setBusy(true); setError(""); setMessage("");
|
||||
void (async () => {
|
||||
if (file.size > 192 * 1024 * 1024) throw new Error(t("会话文件超过大小限制。"));
|
||||
await hub.importNativeSession(JSON.parse(await file.text()), workspace);
|
||||
await hub.importNativeSession(file, workspace);
|
||||
setMessage(t("会话已导入,绑定版本保持不变。"));
|
||||
onImported();
|
||||
})().catch((e) => setError(reason(e))).finally(() => setBusy(false));
|
||||
|
||||
@@ -3,8 +3,18 @@ import { t } from "../i18n";
|
||||
import { RMark } from "./RMark";
|
||||
import { RunClock } from "./RunClock";
|
||||
import { RunTokens } from "./RunTokens";
|
||||
import { useShowsCompositionPicker } from "../state/compositionPicker";
|
||||
|
||||
export function RunLine({ label, running, blocked, since, sent, received, estimated, children }: {
|
||||
export function RunLine({
|
||||
label,
|
||||
running,
|
||||
blocked,
|
||||
since,
|
||||
sent,
|
||||
received,
|
||||
estimated,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
running: boolean;
|
||||
blocked: boolean;
|
||||
@@ -14,20 +24,30 @@ export function RunLine({ label, running, blocked, since, sent, received, estima
|
||||
estimated: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="compose-context">
|
||||
{children}
|
||||
<div
|
||||
className="studio-runstate"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-running={running && !blocked ? "" : undefined}
|
||||
data-waiting={blocked ? "" : undefined}
|
||||
data-idle={running || blocked ? undefined : ""}
|
||||
>
|
||||
<span className="studio-runlabel"><RMark /><span className="sr-only">{t(label)}</span>{running && since !== undefined && <RunClock since={since} />}</span>
|
||||
<RunTokens sent={sent} received={received} estimated={estimated} />
|
||||
</div>
|
||||
const showsPicker = useShowsCompositionPicker();
|
||||
const status = (
|
||||
<div
|
||||
className="studio-runstate"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-running={running && !blocked ? "" : undefined}
|
||||
data-waiting={blocked ? "" : undefined}
|
||||
data-idle={running || blocked ? undefined : ""}
|
||||
>
|
||||
<span className="studio-runlabel">
|
||||
<RMark />
|
||||
<span className="sr-only">{t(label)}</span>
|
||||
{running && since !== undefined && <RunClock since={since} />}
|
||||
</span>
|
||||
<RunTokens sent={sent} received={received} estimated={estimated} />
|
||||
</div>
|
||||
);
|
||||
return showsPicker && children ? (
|
||||
<div className="compose-context">
|
||||
{children}
|
||||
{status}
|
||||
</div>
|
||||
) : (
|
||||
status
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export function WorktreeChip({ sources, canCreate, selection, branch, busy, chan
|
||||
const height = panel.current.offsetHeight * zoom();
|
||||
pinToViewport(panel.current, anchor.left, beside(anchor, height, innerHeight, "above", 10), 12);
|
||||
}, []);
|
||||
useFollow(open, put, `${query}:${reading}:${sourceError}:${!!selection}:${locals.length}`);
|
||||
useFollow(open, put, `${query}:${reading}:${sourceError}:${!!selection}:${locals.length}`, { anchor: trigger, container: panel });
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
search.current?.focus();
|
||||
|
||||
@@ -42,7 +42,7 @@ export function ReplyMenu({
|
||||
if (!el || !to) return;
|
||||
pinToViewport(el, to.left, beside(to, el.offsetHeight * zoom(), innerHeight, "above", GAP));
|
||||
}, [anchor]);
|
||||
useFollow(open, place);
|
||||
useFollow(open, place, undefined, { anchor, container: pop });
|
||||
|
||||
// The list left the row's DOM, so the row keeps a stop where it was: Tab from
|
||||
// the trigger enters at the first item, Shift+Tab from the next control at
|
||||
|
||||
@@ -66,7 +66,7 @@ export function RewindControl({
|
||||
pinToViewport(el, to.right - box.width, fits || above < 6 ? to.bottom + GAP : above);
|
||||
}, []);
|
||||
|
||||
useFollow(open, place, stage);
|
||||
useFollow(open, place, stage, { anchor: btn, container: menu });
|
||||
|
||||
const work = async (prepare: () => Promise<RewindPlan>, commitNow: boolean) => {
|
||||
if (request.current.busy) return;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
import { useRef } from "react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { useFollow } from "./place";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("follows only its anchor/container transitions and releases listeners on close", () => {
|
||||
const place = vi.fn();
|
||||
function Popup({ open }: { open: boolean }) {
|
||||
const anchor = useRef<HTMLButtonElement>(null);
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
useFollow(open, place, undefined, { anchor, container });
|
||||
return <><section data-testid="parent"><button ref={anchor}>anchor</button></section>
|
||||
<div ref={container}>popup</div><div>unrelated</div></>;
|
||||
}
|
||||
const view = render(<Popup open />);
|
||||
place.mockClear();
|
||||
fireEvent.transitionEnd(screen.getByText("unrelated"));
|
||||
expect(place).not.toHaveBeenCalled();
|
||||
fireEvent.transitionEnd(screen.getByText("anchor"));
|
||||
fireEvent.transitionEnd(screen.getByText("popup"));
|
||||
fireEvent.transitionEnd(screen.getByTestId("parent"));
|
||||
expect(place).toHaveBeenCalledTimes(3);
|
||||
fireEvent.resize(window);
|
||||
expect(place).toHaveBeenCalledTimes(4);
|
||||
view.rerender(<Popup open={false} />);
|
||||
fireEvent.transitionEnd(screen.getByText("anchor"));
|
||||
fireEvent.resize(window);
|
||||
expect(place).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useLayoutEffect, type RefObject } from "react";
|
||||
import { onFolds } from "./viewport";
|
||||
|
||||
// Placing a fixed element while the interface is zoomed puts two coordinate
|
||||
@@ -65,19 +65,29 @@ export function beside(
|
||||
/** useFollow keeps a fixed layer on its anchor while it is open. Capture,
|
||||
* because the scroller that moves the anchor is not the window; `key` is
|
||||
* whatever else changes the layer's size. */
|
||||
export function useFollow(open: boolean, place: () => void, key?: unknown): void {
|
||||
export function useFollow(open: boolean, place: () => void, key?: unknown, scope?: {
|
||||
anchor: RefObject<HTMLElement | null>;
|
||||
container: RefObject<HTMLElement | null>;
|
||||
}): void {
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
place();
|
||||
const transition = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const anchor = scope?.anchor.current;
|
||||
const container = scope?.container.current;
|
||||
if (target === container || (anchor && target.contains(anchor))) place();
|
||||
};
|
||||
const stopFolds = onFolds(place);
|
||||
addEventListener("scroll", place, true);
|
||||
addEventListener("resize", place);
|
||||
addEventListener("transitionend", place, true);
|
||||
addEventListener("transitionend", transition, true);
|
||||
return () => {
|
||||
stopFolds();
|
||||
removeEventListener("scroll", place, true);
|
||||
removeEventListener("resize", place);
|
||||
removeEventListener("transitionend", place, true);
|
||||
removeEventListener("transitionend", transition, true);
|
||||
};
|
||||
}, [open, place, key]);
|
||||
}, [open, place, key, scope?.anchor, scope?.container]);
|
||||
}
|
||||
@@ -224,6 +224,7 @@ export const SETTINGS: SettingEntry[] = [
|
||||
// Written to the user file only; the kernel's background sweep reads it on each pass.
|
||||
{ section: "session", anchor: "auto-archive", title: "自动归档不活跃的对话", scope: "machine", apply: "immediate", keywords: ["归档", "过期", "清理", "闲置", "天数"] },
|
||||
// Explicit selections bind immutable versions; catalog saves do not rebuild.
|
||||
{ section: "persona", anchor: "composition-picker", title: "显示模式选择器", scope: "machine", apply: "immediate", keywords: ["预设", "模式", "picker", "Agent"] },
|
||||
{ section: "persona", anchor: "agent-presets", title: "Agent 预设", scope: "machine", apply: "on-open", keywords: ["预设", "组合", "Agent"] },
|
||||
{ section: "persona", anchor: "persona", title: "系统人设", scope: "machine", apply: "next-rebuild", keywords: ["人设", "persona", "身份", "提示词", "系统提示词", "前缀", "后缀"] },
|
||||
{ section: "session", anchor: "session-dir", title: "会话写入位置", scope: "workspace", apply: "none", keywords: ["工作目录", "路径"] },
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { HttpError } from "../port/http_error";
|
||||
import type { HubPort, RuntimeView } from "../port/hub";
|
||||
import { clearToasts, readToasts } from "../state/toasts";
|
||||
import { useOpenPane } from "./useOpenPane";
|
||||
|
||||
afterEach(() => { cleanup(); clearToasts(); });
|
||||
|
||||
function blankPane() {
|
||||
const port = {
|
||||
agentPresets: vi.fn().mockRejectedValue(new Error("presets unavailable")),
|
||||
selectAgentComposition: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
const setActive = vi.fn();
|
||||
const hub = { open: vi.fn(), close: vi.fn() };
|
||||
const hook = renderHook(() => useOpenPane({
|
||||
hub: hub as unknown as HubPort,
|
||||
runtimes: [{ id: "blank", root: "/w" } as RuntimeView],
|
||||
panePorts: new Map([["blank", port as unknown as AgentPort]]),
|
||||
active: "other", reportsRef: { current: {} }, runsRef: { current: {} },
|
||||
setRuntimes: vi.fn(), setActive, setTakeover: vi.fn(), reloadPanes: vi.fn(),
|
||||
sw: { path: "", pending: false, opening: () => false, begin: () => 1,
|
||||
current: () => true, landed: vi.fn(), end: vi.fn(), cancel: vi.fn() },
|
||||
}));
|
||||
return { port, setActive, hub, open: async (agentComposition?: string) => {
|
||||
await act(async () => { await hook.result.current({ root: "/w", agentComposition }); });
|
||||
} };
|
||||
}
|
||||
|
||||
it("keeps an existing blank pane's chosen mode without reading the catalog", async () => {
|
||||
const pane = blankPane();
|
||||
await pane.open("builtin:minimal");
|
||||
await pane.open();
|
||||
expect(pane.port.selectAgentComposition).toHaveBeenCalledExactlyOnceWith("builtin:minimal");
|
||||
expect(pane.port.agentPresets).not.toHaveBeenCalled();
|
||||
expect(pane.setActive).toHaveBeenLastCalledWith("blank");
|
||||
expect(pane.hub.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still opens the pane and reports a refused explicit mode without rebuilding it", async () => {
|
||||
const pane = blankPane();
|
||||
pane.port.selectAgentComposition.mockRejectedValue(new HttpError(409, "locked", { code: "agent_composition.locked" }));
|
||||
await pane.open("builtin:minimal");
|
||||
expect(pane.setActive).toHaveBeenCalledWith("blank");
|
||||
expect(pane.hub.open).not.toHaveBeenCalled();
|
||||
expect(readToasts()).toHaveLength(1);
|
||||
expect(readToasts()[0].text).toContain("不能切换组合");
|
||||
});
|
||||
@@ -4,7 +4,8 @@ import type { AgentPort } from "../port/port";
|
||||
import type { PaneReport } from "./Pane";
|
||||
import { t } from "../i18n";
|
||||
import { openSessionLimit } from "../state/prefs";
|
||||
import { pushNote } from "../state/toasts";
|
||||
import { pushNote, pushToast } from "../state/toasts";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import type { SessionSwitch } from "./sessionswitch";
|
||||
|
||||
interface Placed {
|
||||
@@ -41,8 +42,13 @@ export function useOpenPane({ hub, runtimes, panePorts, active, reportsRef, runs
|
||||
// folder: it is the pane being asked for. Rebuilding it would cost a full
|
||||
// assembly to arrive back where we started.
|
||||
if (blank && !req.sessionPath && blank.root === req.root) {
|
||||
const port=panePorts.get(blank.id);
|
||||
if(port) {const chosen=req.agentComposition??(await port.agentPresets()).default;await port.selectAgentComposition(chosen);}
|
||||
if (req.agentComposition !== undefined) {
|
||||
try {
|
||||
await panePorts.get(blank.id)?.selectAgentComposition(req.agentComposition);
|
||||
} catch (e) {
|
||||
pushToast(reason(e));
|
||||
}
|
||||
}
|
||||
return { id: blank.id, changed: false };
|
||||
}
|
||||
// Same folder: the pane just rebinds, so nothing is torn down and a draft
|
||||
|
||||
@@ -21,6 +21,16 @@ to bind a version.
|
||||
|
||||
## Choosing and editing
|
||||
|
||||
The mode picker is hidden by default. Enable **Settings → Agent presets → Show
|
||||
mode picker** on this machine to show it immediately in every pane.
|
||||
|
||||
With it off,
|
||||
the composer keeps its original controls and status layout; no catalog is read
|
||||
and no composition is selected by the hidden control.
|
||||
|
||||
Turning it off hides the
|
||||
entry without changing a conversation's binding or a saved preference.
|
||||
|
||||
Choose the mode at the left of the composer status row before
|
||||
the first prompt. One menu lists compositions with their sources and capabilities;
|
||||
**Manage Agent presets…** opens the management page below the choices. Once a prompt is
|
||||
@@ -231,9 +241,26 @@ send the selected composition ID to that host; local executable resources are no
|
||||
| `POST /agent-composition` | `{id}` → selected summary |
|
||||
| `POST /runtimes` | Existing create fields plus optional `agentComposition` |
|
||||
| `POST /tree/sessions/export` | `{path}` → `{name, content}`; native envelope for bound sessions |
|
||||
| `POST /tree/sessions/import-native` | `{workspace, content}` → imported `{path, agentComposition}` |
|
||||
| `POST /tree/sessions/import-native?workspace=...` | Original file as an `application/json` body → imported `{path, agentComposition}` |
|
||||
| `POST /tree/sessions/move-to` | `{path, target}` → moved unbound conversation; bound conversations are refused |
|
||||
|
||||
The renderer uploads the archive as an opaque file and never reads or parses it.
|
||||
The host uses the same native decoder and its 192 MiB / replay-record limits.
|
||||
|
||||
| Refusal | Code |
|
||||
| --- | --- |
|
||||
| Malformed | `session.archive_invalid` |
|
||||
| Unsupported version | `session.archive_unsupported` |
|
||||
| Byte or record limit | `session.archive_too_large` |
|
||||
|
||||
The original JSON `{workspace, content}` request remains supported on this same
|
||||
route. Neither transport grants trust or starts programs.
|
||||
|
||||
The open conversation-import proposal
|
||||
[#13728](https://github.com/esengine/DeepSeek-Reasonix/issues/13728) should reuse
|
||||
this decoder and limits rather than introduce a second parser; this entry will
|
||||
move to that common flow when it lands.
|
||||
|
||||
Preset save/delete/default/import and session composition selection require the
|
||||
request's provider-edit grant, just like configuration writes. Without it they
|
||||
return HTTP 403 `config.editing_disabled` before store access or selection.
|
||||
|
||||
@@ -13,6 +13,10 @@ Studio 的「Agent 预设」把人设、工具、技能、MCP、扩展、Hook
|
||||
|
||||
## 选择和修改
|
||||
|
||||
模式选择器默认隐藏。在本机「设置 → Agent 预设 → 显示模式选择器」开启后,
|
||||
所有窗格立即显示入口;关闭时输入框保留原有控件和状态布局,隐藏的入口不读取目录、
|
||||
不选择组合。关闭只隐藏入口,不改变已有会话绑定或保存的首选项。
|
||||
|
||||
首次提交前,在输入框顶部左侧打开 Agent 模式菜单;列表显示来源
|
||||
和能力说明,分隔线下的「管理 Agent 预设…」打开管理页。接纳首条消息后,会话保留原绑定;
|
||||
排队、待回答和后台工作期间也不能切换。锁定后可用其他组合新建会话。
|
||||
@@ -121,6 +125,24 @@ schema 4,旧程序会明确拒绝。无绑定历史仍走兼容路径,不套
|
||||
|
||||
原生导出使用 `reasonix-native-session` version 2 封装,包含权威日志中的绑定
|
||||
和保留资源,可在「设置 → 存储」导入。导入不创建本机授权,不启动程序。
|
||||
|
||||
前端直接上传原始文件,不在渲染器读取或解析 JSON。后端复用同一原生解码器和
|
||||
192 MiB/回放记录数限制。
|
||||
|
||||
| 拒绝原因 | 错误码 |
|
||||
| --- | --- |
|
||||
| 内容无效 | `session.archive_invalid` |
|
||||
| 版本不支持 | `session.archive_unsupported` |
|
||||
| 大小或记录数超限 | `session.archive_too_large` |
|
||||
|
||||
界面按错误码显示当前语言的提示。
|
||||
|
||||
`POST /tree/sessions/import-native?workspace=...` 接收原始 `application/json` 文件;
|
||||
同一路径原有 `{workspace, content}` JSON 请求继续兼容。
|
||||
|
||||
会话导入提案 [#13728](https://github.com/esengine/DeepSeek-Reasonix/issues/13728) 仍在开放中;
|
||||
后续共用此解码器和限制,落地时把此入口接到统一流程,避免第二套解析规则。
|
||||
|
||||
配置备份 version 3 同时包含个人预设声明及个人默认选择。
|
||||
|
||||
MCP 在绑定时锁定工具契约。刷新或重连后若工具目录不兼容,会阻止继续执行,
|
||||
|
||||
@@ -2,6 +2,7 @@ package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -10,21 +11,12 @@ import (
|
||||
)
|
||||
|
||||
func (h *Hub) importNativeSession(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Workspace string `json:"workspace"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, sessionstore.MaxNativeArchiveBytes+4096))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&body); err != nil {
|
||||
badBody(w)
|
||||
workspace, content, err := readNativeUpload(w, r)
|
||||
if err != nil {
|
||||
refuseNativeArchive(w, err)
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(new(any)); err != io.EOF {
|
||||
badBody(w)
|
||||
return
|
||||
}
|
||||
root, err := resolveWorkspaceDir(strings.TrimSpace(body.Workspace))
|
||||
root, err := resolveWorkspaceDir(strings.TrimSpace(workspace))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
@@ -33,9 +25,9 @@ func (h *Hub) importNativeSession(w http.ResponseWriter, r *http.Request) {
|
||||
refuse(w, http.StatusForbidden, "workspace.unknown", "select a known workspace", nil)
|
||||
return
|
||||
}
|
||||
session, err := sessionstore.DecodeNativeArchive(body.Content)
|
||||
session, err := sessionstore.DecodeNativeArchive(content)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
refuseNativeArchive(w, err)
|
||||
return
|
||||
}
|
||||
path := sessionstore.NewSessionPath(SessionDirFor(root), "imported-agent")
|
||||
@@ -45,3 +37,38 @@ func (h *Hub) importNativeSession(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, map[string]any{"path": path, "agentComposition": compositionView(session.Composition())})
|
||||
}
|
||||
|
||||
func readNativeUpload(w http.ResponseWriter, r *http.Request) (string, []byte, error) {
|
||||
if r.URL.Query().Has("workspace") {
|
||||
if r.ContentLength > sessionstore.MaxNativeArchiveBytes {
|
||||
return "", nil, sessionstore.ErrSessionReplayLimitExceeded
|
||||
}
|
||||
content, err := io.ReadAll(http.MaxBytesReader(w, r.Body, sessionstore.MaxNativeArchiveBytes))
|
||||
return r.URL.Query().Get("workspace"), content, err
|
||||
}
|
||||
var body struct {
|
||||
Workspace string `json:"workspace"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, sessionstore.MaxNativeArchiveBytes+4096))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&body); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if err := decoder.Decode(new(any)); err != io.EOF {
|
||||
return "", nil, sessionstore.ErrNativeArchiveInvalid
|
||||
}
|
||||
return body.Workspace, body.Content, nil
|
||||
}
|
||||
|
||||
func refuseNativeArchive(w http.ResponseWriter, err error) {
|
||||
var tooLarge *http.MaxBytesError
|
||||
switch {
|
||||
case errors.Is(err, sessionstore.ErrSessionReplayLimitExceeded), errors.As(err, &tooLarge):
|
||||
refuse(w, http.StatusRequestEntityTooLarge, "session.archive_too_large", "session archive exceeds safe limits", nil)
|
||||
case errors.Is(err, sessionstore.ErrNativeArchiveUnsupported):
|
||||
refuse(w, http.StatusBadRequest, "session.archive_unsupported", "unsupported session archive version", nil)
|
||||
default:
|
||||
refuse(w, http.StatusBadRequest, "session.archive_invalid", "invalid session archive", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/agentcomposition"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
func TestNativeArchiveUploadAndLegacyEnvelopeKeepTheExactBinding(t *testing.T) {
|
||||
_, srv, root, _ := twoProjects(t)
|
||||
definition := agentcomposition.Builtins()[1]
|
||||
binding := &agentcomposition.Binding{
|
||||
Version: agentcomposition.Version{Definition: definition, Revision: agentcomposition.Revision(definition)},
|
||||
Resolved: definition, Workspace: root,
|
||||
}
|
||||
binding.Seal()
|
||||
archive, err := json.Marshal(sessionstore.NativeArchive{
|
||||
Format: sessionstore.NativeArchiveFormat, Version: sessionstore.NativeArchiveVersion,
|
||||
Records: []provider.Message{{Role: provider.RoleSystem, Content: "preserved", AgentComposition: binding}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, legacy := range []bool{false, true} {
|
||||
path := "/tree/sessions/import-native?workspace=" + url.QueryEscape(root)
|
||||
content := string(archive)
|
||||
if legacy {
|
||||
path = "/tree/sessions/import-native"
|
||||
body, err := json.Marshal(map[string]any{"workspace": root, "content": json.RawMessage(archive)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content = string(body)
|
||||
}
|
||||
resp, err := http.Post(srv.URL+path, "application/json", strings.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var result struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
resp.Body.Close()
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("legacy=%v: %d %v", legacy, resp.StatusCode, err)
|
||||
}
|
||||
loaded, err := sessionstore.LoadSession(result.Path)
|
||||
if err != nil || loaded.Composition() == nil || loaded.Composition().Digest != binding.Digest {
|
||||
t.Fatalf("legacy=%v lost exact binding: %v", legacy, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeArchiveUploadUsesTheHostDecoderAndTypedRefusals(t *testing.T) {
|
||||
_, srv, root, _ := twoProjects(t)
|
||||
for _, tc := range []struct {
|
||||
name, data, code string
|
||||
}{
|
||||
{"valid", `{"format":"reasonix-native-session","version":2,"records":[{"role":"system","content":"retained prompt"}]}`, ""},
|
||||
{"malformed", `{broken`, "session.archive_invalid"},
|
||||
{"trailing", `{} {}`, "session.archive_invalid"},
|
||||
{"unknown version", `{"format":"reasonix-native-session","version":99,"records":[]}`, "session.archive_unsupported"},
|
||||
{"unknown field", `{"extra":true}`, "session.archive_invalid"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
before := len(namesIn(t, srv, root))
|
||||
resp, err := http.Post(srv.URL+"/tree/sessions/import-native?workspace="+url.QueryEscape(root),
|
||||
"application/json", strings.NewReader(tc.data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.code == "" {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("import = %d %v", resp.StatusCode, body)
|
||||
}
|
||||
loaded, err := sessionstore.LoadSession(body["path"].(string))
|
||||
if err != nil || !loaded.HasSystemMessage() {
|
||||
t.Fatalf("import lost history: %v", err)
|
||||
}
|
||||
} else {
|
||||
if resp.StatusCode != http.StatusBadRequest || body["code"] != tc.code {
|
||||
t.Fatalf("refusal = %d %v, want %s", resp.StatusCode, body, tc.code)
|
||||
}
|
||||
if len(namesIn(t, srv, root)) != before {
|
||||
t.Fatal("refused upload created a session")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeArchiveUploadRefusesOversizeBeforeReading(t *testing.T) {
|
||||
h, _, root, _ := twoProjects(t)
|
||||
r := httptest.NewRequest(http.MethodPost, "/tree/sessions/import-native?workspace="+url.QueryEscape(root), strings.NewReader(""))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.ContentLength = sessionstore.MaxNativeArchiveBytes + 1
|
||||
w := httptest.NewRecorder()
|
||||
h.importNativeSession(w, r)
|
||||
var body Reason
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != http.StatusRequestEntityTooLarge || body.Code != "session.archive_too_large" {
|
||||
t.Fatalf("oversize refusal = %d %+v", w.Code, body)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package sessionstore
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
@@ -21,6 +22,9 @@ const NativeArchiveFormat = "reasonix-native-session"
|
||||
const NativeArchiveVersion = 2
|
||||
const MaxNativeArchiveBytes = 192 << 20
|
||||
|
||||
var ErrNativeArchiveInvalid = errors.New("invalid native session archive")
|
||||
var ErrNativeArchiveUnsupported = errors.New("unsupported native session archive version")
|
||||
|
||||
func ExportNativeArchive(path string) ([]byte, error) {
|
||||
s, err := LoadSession(path)
|
||||
if err != nil {
|
||||
@@ -42,13 +46,13 @@ func DecodeNativeArchive(data []byte) (*Session, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&archive); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("%w: %w", ErrNativeArchiveInvalid, err)
|
||||
}
|
||||
if err := decoder.Decode(new(any)); err != io.EOF {
|
||||
return nil, fmt.Errorf("invalid native archive: trailing data")
|
||||
return nil, fmt.Errorf("%w: trailing data", ErrNativeArchiveInvalid)
|
||||
}
|
||||
if archive.Format != NativeArchiveFormat || archive.Version != NativeArchiveVersion {
|
||||
return nil, fmt.Errorf("unsupported native archive version %d", archive.Version)
|
||||
return nil, fmt.Errorf("%w: %d", ErrNativeArchiveUnsupported, archive.Version)
|
||||
}
|
||||
if len(archive.Records) > sessionEventReplayMaxMessages {
|
||||
return nil, ErrSessionReplayLimitExceeded
|
||||
@@ -69,7 +73,7 @@ func DecodeNativeArchive(data []byte) (*Session, error) {
|
||||
s := NewSession("")
|
||||
s.Messages = archive.Records
|
||||
if !s.HasSystemMessage() {
|
||||
return nil, fmt.Errorf("native archive has no system message")
|
||||
return nil, fmt.Errorf("%w: no system message", ErrNativeArchiveInvalid)
|
||||
}
|
||||
if binding != nil && s.HasContent() && !binding.Started {
|
||||
return nil, fmt.Errorf("%w: admitted archive is unlocked", agentcomposition.ErrInvalid)
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
- 远程新会话透传所选模式,同时保留并发打开与重复点击的保护。#13613
|
||||
- 预设写入与组合切换要求配置编辑授权;安全 Hook 与权限规则不受组合选择影响,旧绑定恢复和会话内策略编辑同样保持保护。#13613
|
||||
|
||||
- 模式选择器改为默认关闭的界面开关;重复新建会话保留空白窗格的已选模式。#13613
|
||||
- 原生归档直接交给后端解析,错误按码翻译;内置副本使用窗口语言,标识字段跟随主题。#13613
|
||||
|
||||
<!-- lang:en -->
|
||||
|
||||
- Add session Agent presets with versioned personas, tools and modules, building on #13588. #13613
|
||||
@@ -19,3 +22,5 @@
|
||||
- Refuse moving bound conversations to another project before copying or deleting them, preserving the original immutable composition. #13613
|
||||
- Carry the selected mode into new remote conversations while preserving concurrent-open ordering and duplicate-click protection. #13613
|
||||
- Require configuration-edit authority for preset writes and composition switches; keep safety hooks and permission rules in force across selection, old-binding restore and live policy edits. #13613
|
||||
- Make the mode picker a default-off interface choice; preserve blank-pane modes on repeated New session. #13613
|
||||
- Upload native archives to the host decoder; localize refusals, copied descriptions and themed identifier fields. #13613
|
||||
Reference in new issue
Block a user