feat: surface expert and runtime controls in composer toolbar
This commit is contained in:
@@ -1146,14 +1146,18 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
|||||||
function composerMenuTrigger(
|
function composerMenuTrigger(
|
||||||
label: "专家角色",
|
label: "专家角色",
|
||||||
): HTMLButtonElement {
|
): HTMLButtonElement {
|
||||||
if (label === "专家角色") openComposerOptions();
|
|
||||||
return screen.getByRole("button", {
|
return screen.getByRole("button", {
|
||||||
name: new RegExp(`^${label}:`, "u"),
|
name: new RegExp(`^${label}:`, "u"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openComposerOptions(): void {
|
function openComposerOptions(): void {
|
||||||
const trigger = screen.getByRole("button", { name: "选项" });
|
const trigger = screen.getByRole("button", { name: "故事图谱与知识库" });
|
||||||
|
if (trigger.getAttribute("aria-expanded") !== "true") fireEvent.click(trigger);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRuntimeOptions(runtime = "OpenCode"): Promise<void> {
|
||||||
|
const trigger = await screen.findByRole("button", { name: `${runtime} 专属功能` });
|
||||||
if (trigger.getAttribute("aria-expanded") !== "true") fireEvent.click(trigger);
|
if (trigger.getAttribute("aria-expanded") !== "true") fireEvent.click(trigger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6873,6 +6877,7 @@ describe("App", () => {
|
|||||||
evidence: [],
|
evidence: [],
|
||||||
});
|
});
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
await screen.findByRole("button", { name: "故事图谱与知识库" });
|
||||||
openComposerOptions();
|
openComposerOptions();
|
||||||
const knowledgeScopeTrigger = await screen.findByRole("button", {
|
const knowledgeScopeTrigger = await screen.findByRole("button", {
|
||||||
name: "选择知识库,本次已启用 0 个",
|
name: "选择知识库,本次已启用 0 个",
|
||||||
@@ -7101,6 +7106,7 @@ describe("App", () => {
|
|||||||
evidence: [],
|
evidence: [],
|
||||||
});
|
});
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
await screen.findByRole("button", { name: "故事图谱与知识库" });
|
||||||
openComposerOptions();
|
openComposerOptions();
|
||||||
const knowledgeScope = await screen.findByRole("button", {
|
const knowledgeScope = await screen.findByRole("button", {
|
||||||
name: "选择知识库,本次已启用 0 个",
|
name: "选择知识库,本次已启用 0 个",
|
||||||
@@ -8656,7 +8662,6 @@ describe("App", () => {
|
|||||||
|
|
||||||
it("matches the expert keyboard menu to the model picker", async () => {
|
it("matches the expert keyboard menu to the model picker", async () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
openComposerOptions();
|
|
||||||
|
|
||||||
const expertTrigger = await screen.findByRole("button", {
|
const expertTrigger = await screen.findByRole("button", {
|
||||||
name: "专家角色:通用助手",
|
name: "专家角色:通用助手",
|
||||||
@@ -8683,15 +8688,16 @@ describe("App", () => {
|
|||||||
screen.queryByRole("menu", { name: "专家角色" }),
|
screen.queryByRole("menu", { name: "专家角色" }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.keyDown(expertTrigger, { key: "Escape" });
|
const contextTrigger = screen.getByRole("button", { name: "故事图谱与知识库" });
|
||||||
const optionsTrigger = screen.getByRole("button", { name: "选项" });
|
fireEvent.click(contextTrigger);
|
||||||
expect(optionsTrigger).toHaveFocus();
|
expect(screen.getByRole("dialog", { name: "故事图谱与知识库" })).toBeVisible();
|
||||||
expect(optionsTrigger).toHaveAttribute("aria-expanded", "false");
|
await waitFor(() => expect(screen.getByRole("switch", { name: "使用故事图谱" })).toHaveFocus());
|
||||||
expect(screen.queryByRole("dialog", { name: "对话设置" })).not.toBeInTheDocument();
|
fireEvent.keyDown(screen.getByRole("switch", { name: "使用故事图谱" }), { key: "Escape" });
|
||||||
fireEvent.click(optionsTrigger);
|
expect(contextTrigger).toHaveFocus();
|
||||||
await waitFor(() => expect(screen.getByRole("button", { name: "专家角色:通用助手" })).toHaveFocus());
|
expect(contextTrigger).toHaveAttribute("aria-expanded", "false");
|
||||||
|
fireEvent.click(contextTrigger);
|
||||||
fireEvent.pointerDown(screen.getByLabelText("向 GoodBuddy 提问"));
|
fireEvent.pointerDown(screen.getByLabelText("向 GoodBuddy 提问"));
|
||||||
expect(optionsTrigger).toHaveAttribute("aria-expanded", "false");
|
expect(contextTrigger).toHaveAttribute("aria-expanded", "false");
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("menu", { name: "工作模式" }),
|
screen.queryByRole("menu", { name: "工作模式" }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
@@ -8731,8 +8737,6 @@ describe("App", () => {
|
|||||||
await screen.findByLabelText("向 GoodBuddy 提问")
|
await screen.findByLabelText("向 GoodBuddy 提问")
|
||||||
).closest<HTMLElement>(".composer");
|
).closest<HTMLElement>(".composer");
|
||||||
expect(composer).not.toBeNull();
|
expect(composer).not.toBeNull();
|
||||||
expect(screen.queryByRole("button", { name: "专家角色:通用助手" })).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("button", { name: "选项" })).toHaveAttribute("aria-expanded", "false");
|
|
||||||
|
|
||||||
const contentTools = within(composer!).getByRole("group", {
|
const contentTools = within(composer!).getByRole("group", {
|
||||||
name: "添加内容",
|
name: "添加内容",
|
||||||
@@ -8747,12 +8751,13 @@ describe("App", () => {
|
|||||||
const conversationSettings = within(composer!).getByRole("group", {
|
const conversationSettings = within(composer!).getByRole("group", {
|
||||||
name: "对话设置",
|
name: "对话设置",
|
||||||
});
|
});
|
||||||
openComposerOptions();
|
// Order: context sources, expert, Runtime, then Runtime-specific settings.
|
||||||
expect(
|
const contextTrigger = within(conversationSettings).getByRole("button", { name: "故事图谱与知识库" });
|
||||||
within(screen.getByRole("dialog", { name: "对话设置" })).getByRole("button", {
|
const expertTrigger = within(conversationSettings).getByRole("button", { name: "专家角色:通用助手" });
|
||||||
name: "专家角色:通用助手",
|
const runtimeTrigger = within(conversationSettings).getByRole("button", { name: /默认模型/u });
|
||||||
}),
|
expect(contextTrigger).toHaveAttribute("aria-expanded", "false");
|
||||||
).toBeInTheDocument();
|
expect(contextTrigger.compareDocumentPosition(expertTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
expect(expertTrigger.compareDocumentPosition(runtimeTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
within(conversationSettings).queryByRole("button", {
|
within(conversationSettings).queryByRole("button", {
|
||||||
name: /工作模式/u,
|
name: /工作模式/u,
|
||||||
@@ -10311,14 +10316,14 @@ describe("App", () => {
|
|||||||
));
|
));
|
||||||
// Let startup selection and its focus effects finish before opening these options.
|
// Let startup selection and its focus effects finish before opening these options.
|
||||||
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
||||||
openComposerOptions();
|
await openRuntimeOptions();
|
||||||
const agentPicker = await screen.findByRole("button", {
|
const agentPicker = await screen.findByRole("button", {
|
||||||
name: /OpenCode Runtime Agent/u,
|
name: /OpenCode Runtime Agent/u,
|
||||||
});
|
});
|
||||||
const runtimeToolbar = screen.getByRole("group", {
|
const runtimeToolbar = screen.getByRole("group", {
|
||||||
name: "OpenCode 专属功能",
|
name: "OpenCode 专属功能",
|
||||||
});
|
});
|
||||||
const options = screen.getByRole("dialog", { name: "对话设置" });
|
const options = screen.getByRole("dialog", { name: "OpenCode 专属功能" });
|
||||||
expect(runtimeToolbar).toHaveClass("composer__runtime-toolbar");
|
expect(runtimeToolbar).toHaveClass("composer__runtime-toolbar");
|
||||||
expect(runtimeToolbar).toContainElement(agentPicker);
|
expect(runtimeToolbar).toContainElement(agentPicker);
|
||||||
expect(options).toContainElement(agentPicker);
|
expect(options).toContainElement(agentPicker);
|
||||||
@@ -10346,7 +10351,7 @@ describe("App", () => {
|
|||||||
target: { value: "src/main" },
|
target: { value: "src/main" },
|
||||||
});
|
});
|
||||||
fireEvent.pointerDown(screen.getByLabelText("向 GoodBuddy 提问"));
|
fireEvent.pointerDown(screen.getByLabelText("向 GoodBuddy 提问"));
|
||||||
expect(screen.queryByRole("dialog", { name: "对话设置" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog", { name: "OpenCode 专属功能" })).not.toBeInTheDocument();
|
||||||
expect(document.querySelector(".composer__option-summary")).toHaveTextContent("Planner");
|
expect(document.querySelector(".composer__option-summary")).toHaveTextContent("Planner");
|
||||||
expect(document.querySelector(".composer__option-summary")).toHaveTextContent("/review");
|
expect(document.querySelector(".composer__option-summary")).toHaveTextContent("/review");
|
||||||
fireEvent.click(screen.getByLabelText("发送"));
|
fireEvent.click(screen.getByLabelText("发送"));
|
||||||
@@ -10468,7 +10473,7 @@ describe("App", () => {
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await screen.findByText("已就绪");
|
await screen.findByText("已就绪");
|
||||||
openComposerOptions();
|
await openRuntimeOptions();
|
||||||
const runtimeToolbar = await screen.findByRole("group", {
|
const runtimeToolbar = await screen.findByRole("group", {
|
||||||
name: "OpenCode 专属功能",
|
name: "OpenCode 专属功能",
|
||||||
});
|
});
|
||||||
@@ -10536,7 +10541,7 @@ describe("App", () => {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(2),
|
expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(2),
|
||||||
);
|
);
|
||||||
openComposerOptions();
|
expect(screen.queryByRole("button", { name: "OpenCode 专属功能" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: /OpenCode Runtime Agent/u })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /OpenCode Runtime Agent/u })).not.toBeInTheDocument();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
retry.resolve({
|
retry.resolve({
|
||||||
@@ -10548,6 +10553,7 @@ describe("App", () => {
|
|||||||
});
|
});
|
||||||
await retry.promise;
|
await retry.promise;
|
||||||
});
|
});
|
||||||
|
await openRuntimeOptions();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /OpenCode Runtime Agent/u }));
|
fireEvent.click(screen.getByRole("button", { name: /OpenCode Runtime Agent/u }));
|
||||||
expect(screen.getByRole("menuitemradio", { name: /Planner/u })).toBeVisible();
|
expect(screen.getByRole("menuitemradio", { name: /Planner/u })).toBeVisible();
|
||||||
fireEvent.keyDown(screen.getByRole("menu", { name: "OpenCode Runtime Agent" }), { key: "Escape" });
|
fireEvent.keyDown(screen.getByRole("menu", { name: "OpenCode Runtime Agent" }), { key: "Escape" });
|
||||||
@@ -10581,7 +10587,7 @@ describe("App", () => {
|
|||||||
.mockResolvedValueOnce(healthy)
|
.mockResolvedValueOnce(healthy)
|
||||||
.mockResolvedValueOnce(healthy);
|
.mockResolvedValueOnce(healthy);
|
||||||
render(<App />);
|
render(<App />);
|
||||||
openComposerOptions();
|
await openRuntimeOptions();
|
||||||
await screen.findByRole("button", { name: /OpenCode Runtime Agent/u });
|
await screen.findByRole("button", { name: /OpenCode Runtime Agent/u });
|
||||||
selectProjectOption(secondProject.name);
|
selectProjectOption(secondProject.name);
|
||||||
await waitFor(() => expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenLastCalledWith(
|
await waitFor(() => expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenLastCalledWith(
|
||||||
@@ -10596,7 +10602,7 @@ describe("App", () => {
|
|||||||
selectProjectOption(firstProject.name);
|
selectProjectOption(firstProject.name);
|
||||||
// Let the project picker restore focus before opening the dismiss-on-blur options.
|
// Let the project picker restore focus before opening the dismiss-on-blur options.
|
||||||
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
||||||
openComposerOptions();
|
await openRuntimeOptions();
|
||||||
const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" });
|
const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" });
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
pending.resolve(failed);
|
pending.resolve(failed);
|
||||||
@@ -10615,9 +10621,7 @@ describe("App", () => {
|
|||||||
vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce(empty);
|
vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce(empty);
|
||||||
selectProjectOption(firstProject.name);
|
selectProjectOption(firstProject.name);
|
||||||
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
||||||
openComposerOptions();
|
await waitFor(() => expect(screen.queryByRole("button", { name: "OpenCode 专属功能" })).not.toBeInTheDocument());
|
||||||
expect(screen.getByRole("button", { name: "选项" })).toHaveAttribute("aria-expanded", "true");
|
|
||||||
await waitFor(() => expect(screen.queryByRole("group", { name: "OpenCode 专属功能" })).not.toBeInTheDocument());
|
|
||||||
expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(callsBefore + 4);
|
expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(callsBefore + 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -10652,7 +10656,7 @@ describe("App", () => {
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await screen.findByRole("button", { name: new RegExp(`^${rows[0]!.title}`, "u") });
|
await screen.findByRole("button", { name: new RegExp(`^${rows[0]!.title}`, "u") });
|
||||||
openComposerOptions();
|
await openRuntimeOptions();
|
||||||
const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" });
|
const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" });
|
||||||
fireEvent.click(screen.getByRole("button", { name: /OpenCode Runtime Agent/u }));
|
fireEvent.click(screen.getByRole("button", { name: /OpenCode Runtime Agent/u }));
|
||||||
fireEvent.click(screen.getByRole("menuitemradio", { name: /Planner/u }));
|
fireEvent.click(screen.getByRole("menuitemradio", { name: /Planner/u }));
|
||||||
@@ -10660,7 +10664,7 @@ describe("App", () => {
|
|||||||
fireEvent.click(screen.getByRole("menuitemradio", { name: /\/review/u }));
|
fireEvent.click(screen.getByRole("menuitemradio", { name: /\/review/u }));
|
||||||
const inventoryCalls = vi.mocked(api.runtimeCustomization.getNativeSnapshot).mock.calls.length;
|
const inventoryCalls = vi.mocked(api.runtimeCustomization.getNativeSnapshot).mock.calls.length;
|
||||||
fireEvent.click(screen.getByText(rows[1]!.title).closest("button")!);
|
fireEvent.click(screen.getByText(rows[1]!.title).closest("button")!);
|
||||||
openComposerOptions();
|
await openRuntimeOptions();
|
||||||
expect(screen.getByRole("group", { name: "OpenCode 专属功能" })).toBe(toolbar);
|
expect(screen.getByRole("group", { name: "OpenCode 专属功能" })).toBe(toolbar);
|
||||||
expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(inventoryCalls);
|
expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(inventoryCalls);
|
||||||
await waitFor(() => expect(screen.getByRole("button", { name: /OpenCode Runtime Agent/u })).not.toHaveTextContent("Planner"));
|
await waitFor(() => expect(screen.getByRole("button", { name: /OpenCode Runtime Agent/u })).not.toHaveTextContent("Planner"));
|
||||||
@@ -10744,7 +10748,7 @@ describe("App", () => {
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
openComposerOptions();
|
await openRuntimeOptions("Continue");
|
||||||
const presetPicker = await screen.findByRole("button", {
|
const presetPicker = await screen.findByRole("button", {
|
||||||
name: /Continue 配置预设.*使用设置默认预设/u,
|
name: /Continue 配置预设.*使用设置默认预设/u,
|
||||||
});
|
});
|
||||||
@@ -10754,7 +10758,7 @@ describe("App", () => {
|
|||||||
expect(runtimeToolbar).toHaveClass("composer__runtime-toolbar");
|
expect(runtimeToolbar).toHaveClass("composer__runtime-toolbar");
|
||||||
expect(runtimeToolbar).toContainElement(presetPicker);
|
expect(runtimeToolbar).toContainElement(presetPicker);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("dialog", { name: "对话设置" }),
|
screen.getByRole("dialog", { name: "Continue 专属功能" }),
|
||||||
).toContainElement(presetPicker);
|
).toContainElement(presetPicker);
|
||||||
fireEvent.click(presetPicker);
|
fireEvent.click(presetPicker);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
|
|||||||
@@ -98,13 +98,14 @@ afterEach(() => {
|
|||||||
describe("Composer render boundary", () => {
|
describe("Composer render boundary", () => {
|
||||||
it("hides the story graph switch with Supervisor off and keeps conversation state while saving or failing", async () => {
|
it("hides the story graph switch with Supervisor off and keeps conversation state while saving or failing", async () => {
|
||||||
const { props, rerender, store } = setup();
|
const { props, rerender, store } = setup();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Options" }));
|
expect(screen.queryByRole("button", { name: "Story graph and knowledge" })).toBeNull();
|
||||||
expect(screen.queryByRole("switch", { name: "Use story graph" })).toBeNull();
|
expect(screen.queryByRole("switch", { name: "Use story graph", hidden: true })).toBeNull();
|
||||||
let finish!: () => void;
|
let finish!: () => void;
|
||||||
const save = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
|
const save = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
|
||||||
const notify = vi.fn();
|
const notify = vi.fn();
|
||||||
const next = { ...props, supervisorEnabled: true, actions: { ...actions, setStoryGraphEnabled: save, notify } };
|
const next = { ...props, supervisorEnabled: true, actions: { ...actions, setStoryGraphEnabled: save, notify } };
|
||||||
rerender(<Composer {...next} />);
|
rerender(<Composer {...next} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Story graph and knowledge" }));
|
||||||
const toggle = screen.getByRole("switch", { name: "Use story graph" });
|
const toggle = screen.getByRole("switch", { name: "Use story graph" });
|
||||||
expect(toggle).toBeChecked();
|
expect(toggle).toBeChecked();
|
||||||
expect(toggle.closest("label")).toHaveClass("toggle-row");
|
expect(toggle.closest("label")).toHaveClass("toggle-row");
|
||||||
@@ -121,7 +122,7 @@ describe("Composer render boundary", () => {
|
|||||||
fireEvent.click(toggle);
|
fireEvent.click(toggle);
|
||||||
await waitFor(() => { expect(toggle).toBeEnabled(); expect(toggle).not.toBeChecked(); expect(notify).toHaveBeenCalledWith({ tone: "error", message: "Save failed" }); });
|
await waitFor(() => { expect(toggle).toBeEnabled(); expect(toggle).not.toBeChecked(); expect(notify).toHaveBeenCalledWith({ tone: "error", message: "Save failed" }); });
|
||||||
rerender(<Composer {...next} conversationId="b" />);
|
rerender(<Composer {...next} conversationId="b" />);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Options" }));
|
fireEvent.click(screen.getByRole("button", { name: "Story graph and knowledge" }));
|
||||||
expect(screen.getByRole("switch", { name: "Use story graph" })).toBeChecked();
|
expect(screen.getByRole("switch", { name: "Use story graph" })).toBeChecked();
|
||||||
rerender(<Composer {...next} supervisorEnabled={false} />);
|
rerender(<Composer {...next} supervisorEnabled={false} />);
|
||||||
expect(screen.queryByRole("switch", { name: "Use story graph", hidden: true })).toBeNull();
|
expect(screen.queryByRole("switch", { name: "Use story graph", hidden: true })).toBeNull();
|
||||||
|
|||||||
+369
-284
@@ -208,11 +208,16 @@ export const Composer = memo(function Composer({
|
|||||||
`${conversationId}\u0000${isRunning}\u0000${workspaceView}`,
|
`${conversationId}\u0000${isRunning}\u0000${workspaceView}`,
|
||||||
);
|
);
|
||||||
const composerOptionsOpen = menus.optionsOpen;
|
const composerOptionsOpen = menus.optionsOpen;
|
||||||
|
const runtimeOptionsOpen = menus.runtimeOptionsOpen;
|
||||||
const knowledgeScopeOpen = menus.knowledgeScopeOpen;
|
const knowledgeScopeOpen = menus.knowledgeScopeOpen;
|
||||||
const composerMenuOpen = menus.menu;
|
const composerMenuOpen = menus.menu;
|
||||||
const runtimeMenuOpen = menus.runtimeMenuOpen;
|
const runtimeMenuOpen = menus.runtimeMenuOpen;
|
||||||
const setComposerOptionsOpen = useCallback(
|
const closeComposerOptions = useCallback(
|
||||||
(open: boolean): void => setMenus({ optionsOpen: open }),
|
(): void => setMenus({ optionsOpen: false, knowledgeScopeOpen: false }),
|
||||||
|
[setMenus],
|
||||||
|
);
|
||||||
|
const closeRuntimeOptions = useCallback(
|
||||||
|
(): void => setMenus({ runtimeOptionsOpen: false, menu: undefined }),
|
||||||
[setMenus],
|
[setMenus],
|
||||||
);
|
);
|
||||||
const setKnowledgeScopeOpen = useCallback(
|
const setKnowledgeScopeOpen = useCallback(
|
||||||
@@ -233,7 +238,13 @@ export const Composer = memo(function Composer({
|
|||||||
);
|
);
|
||||||
const setExpertMenuOpen = useCallback((open: boolean): void => {
|
const setExpertMenuOpen = useCallback((open: boolean): void => {
|
||||||
setMenus(open
|
setMenus(open
|
||||||
? { menu: "expert", runtimeMenuOpen: false, knowledgeScopeOpen: false }
|
? {
|
||||||
|
menu: "expert",
|
||||||
|
runtimeMenuOpen: false,
|
||||||
|
knowledgeScopeOpen: false,
|
||||||
|
optionsOpen: false,
|
||||||
|
runtimeOptionsOpen: false,
|
||||||
|
}
|
||||||
: { menu: undefined });
|
: { menu: undefined });
|
||||||
}, [setMenus]);
|
}, [setMenus]);
|
||||||
const setRuntimeAgentMenuOpen = useCallback((open: boolean): void => {
|
const setRuntimeAgentMenuOpen = useCallback((open: boolean): void => {
|
||||||
@@ -253,6 +264,8 @@ export const Composer = memo(function Composer({
|
|||||||
}, [setMenus]);
|
}, [setMenus]);
|
||||||
const composerOptionsRef = useRef<HTMLDivElement>(null);
|
const composerOptionsRef = useRef<HTMLDivElement>(null);
|
||||||
const composerOptionsTriggerRef = useRef<HTMLButtonElement>(null);
|
const composerOptionsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const runtimeOptionsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const runtimeOptionsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null);
|
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null);
|
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null);
|
||||||
const runtimeMenuRef = useRef<HTMLDivElement>(null);
|
const runtimeMenuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -308,32 +321,18 @@ export const Composer = memo(function Composer({
|
|||||||
};
|
};
|
||||||
}, [knowledgeScopeOpen, setKnowledgeScopeOpen]);
|
}, [knowledgeScopeOpen, setKnowledgeScopeOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useDismissibleComposerDialog(
|
||||||
if (!composerOptionsOpen) {
|
composerOptionsOpen,
|
||||||
return;
|
composerOptionsRef,
|
||||||
}
|
composerOptionsTriggerRef,
|
||||||
const isOptionsTarget = (target: EventTarget | null): boolean =>
|
closeComposerOptions,
|
||||||
target instanceof Node &&
|
);
|
||||||
(composerOptionsRef.current?.contains(target) === true ||
|
useDismissibleComposerDialog(
|
||||||
composerOptionsTriggerRef.current?.contains(target) === true);
|
runtimeOptionsOpen,
|
||||||
const dismissOutside = (event: Event): void => {
|
runtimeOptionsRef,
|
||||||
if (!isOptionsTarget(event.target)) {
|
runtimeOptionsTriggerRef,
|
||||||
setMenus({ optionsOpen: false, knowledgeScopeOpen: false });
|
closeRuntimeOptions,
|
||||||
}
|
);
|
||||||
};
|
|
||||||
const frame = requestAnimationFrame(() => {
|
|
||||||
// Keyboard users land on the same first control as the model picker
|
|
||||||
// and expert menu; optional switches above stay Tab-reachable.
|
|
||||||
composerOptionsRef.current?.querySelector<HTMLButtonElement>("button:not(:disabled)")?.focus();
|
|
||||||
});
|
|
||||||
document.addEventListener("pointerdown", dismissOutside);
|
|
||||||
document.addEventListener("focusin", dismissOutside);
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(frame);
|
|
||||||
document.removeEventListener("pointerdown", dismissOutside);
|
|
||||||
document.removeEventListener("focusin", dismissOutside);
|
|
||||||
};
|
|
||||||
}, [composerOptionsOpen, setMenus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runtimeMenuOpen) {
|
if (!runtimeMenuOpen) {
|
||||||
@@ -406,10 +405,9 @@ export const Composer = memo(function Composer({
|
|||||||
runtime: runtimeControlsProvider,
|
runtime: runtimeControlsProvider,
|
||||||
})
|
})
|
||||||
: "";
|
: "";
|
||||||
|
const composerContextAvailable =
|
||||||
|
supervisorEnabled === true || knowledgeLibraries.length > 0;
|
||||||
const composerOptionSummary = [
|
const composerOptionSummary = [
|
||||||
selectedExpertId && runtime?.capability !== "image-generation"
|
|
||||||
? assistantExpertOptions.find((option) => option.value === selectedExpertId)?.label
|
|
||||||
: undefined,
|
|
||||||
enabledKnowledgeLibraryIds.length > 0
|
enabledKnowledgeLibraryIds.length > 0
|
||||||
? t("composer.knowledge.select", { count: enabledKnowledgeLibraryIds.length })
|
? t("composer.knowledge.select", { count: enabledKnowledgeLibraryIds.length })
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -809,207 +807,214 @@ export const Composer = memo(function Composer({
|
|||||||
<Mic aria-hidden="true" size={18} />
|
<Mic aria-hidden="true" size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="composer__configuration" role="group" aria-label={t("composer.settings")}>
|
||||||
aria-controls="composer-options"
|
{composerContextAvailable && (
|
||||||
aria-expanded={composerOptionsOpen}
|
<>
|
||||||
aria-haspopup="dialog"
|
<button
|
||||||
aria-label={t("composer.options")}
|
aria-controls="composer-options"
|
||||||
className="composer__options-trigger"
|
aria-expanded={composerOptionsOpen}
|
||||||
onClick={() => {
|
aria-haspopup="dialog"
|
||||||
setComposerOptionsOpen(!composerOptionsOpen);
|
aria-label={t("composer.contextSources")}
|
||||||
setComposerMenuOpen(undefined);
|
className="composer__options-trigger"
|
||||||
setRuntimeMenuOpen(false);
|
onClick={() => {
|
||||||
setKnowledgeScopeOpen(false);
|
setMenus({
|
||||||
}}
|
optionsOpen: !composerOptionsOpen,
|
||||||
ref={composerOptionsTriggerRef}
|
runtimeOptionsOpen: false,
|
||||||
title={t("composer.settings")}
|
menu: undefined,
|
||||||
type="button"
|
runtimeMenuOpen: false,
|
||||||
>
|
knowledgeScopeOpen: false,
|
||||||
<SlidersHorizontal aria-hidden="true" size={18} />
|
});
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
aria-label={t("composer.settings")}
|
|
||||||
className="composer__options"
|
|
||||||
id="composer-options"
|
|
||||||
hidden={!composerOptionsOpen}
|
|
||||||
ref={composerOptionsRef}
|
|
||||||
role="dialog"
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (knowledgeScopeOpen) {
|
|
||||||
setKnowledgeScopeOpen(false);
|
|
||||||
knowledgeScopeTriggerRef.current?.focus();
|
|
||||||
} else {
|
|
||||||
setComposerOptionsOpen(false);
|
|
||||||
setComposerMenuOpen(undefined);
|
|
||||||
composerOptionsTriggerRef.current?.focus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong>{t("composer.settings")}</strong>
|
|
||||||
{supervisorEnabled && (
|
|
||||||
<label className="toggle-row">
|
|
||||||
<span className="composer__story-graph-label">
|
|
||||||
<Network aria-hidden="true" size={16} />
|
|
||||||
{t("composer.storyGraph.label")}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
role="switch"
|
|
||||||
checked={view?.storyGraphEnabled !== false}
|
|
||||||
disabled={storyGraphSaving}
|
|
||||||
onChange={(event) => {
|
|
||||||
setStoryGraphSaving(true);
|
|
||||||
void actions.setStoryGraphEnabled(conversationId, event.target.checked)
|
|
||||||
.catch((error: unknown) => actions.notify({ tone: "error", message: error instanceof Error ? error.message : String(error) }))
|
|
||||||
.finally(() => setStoryGraphSaving(false));
|
|
||||||
}}
|
}}
|
||||||
/>
|
ref={composerOptionsTriggerRef}
|
||||||
</label>
|
title={t("composer.contextSources")}
|
||||||
)}
|
type="button"
|
||||||
{knowledgeLibraries.length > 0 && (
|
|
||||||
<div
|
|
||||||
className="knowledge-scope"
|
|
||||||
onBlurCapture={(event) => {
|
|
||||||
if (
|
|
||||||
event.relatedTarget instanceof Node &&
|
|
||||||
!event.currentTarget.contains(
|
|
||||||
event.relatedTarget,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
setKnowledgeScopeOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
aria-controls="knowledge-scope-popover"
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
aria-label={t("composer.knowledge.select", {
|
|
||||||
count: enabledKnowledgeLibraryIds.length,
|
|
||||||
})}
|
|
||||||
aria-expanded={knowledgeScopeOpen}
|
|
||||||
onClick={() => {
|
|
||||||
setComposerMenuOpen(undefined);
|
|
||||||
setRuntimeMenuOpen(false);
|
|
||||||
setKnowledgeScopeOpen(
|
|
||||||
(current) => !current,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
ref={knowledgeScopeTriggerRef}
|
|
||||||
title={t("composer.knowledge.title")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Library aria-hidden="true" size={16} />
|
|
||||||
<span>
|
|
||||||
{t("navigation.knowledge")}
|
|
||||||
<strong>
|
|
||||||
{enabledKnowledgeLibraryIds.length}
|
|
||||||
</strong>
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
|
||||||
aria-hidden="true"
|
|
||||||
className="knowledge-scope__chevron"
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{knowledgeScopeOpen && (
|
|
||||||
<div
|
|
||||||
aria-label={t("composer.knowledge.scope")}
|
|
||||||
className="knowledge-scope__popover"
|
|
||||||
id="knowledge-scope-popover"
|
|
||||||
ref={knowledgeScopePopoverRef}
|
|
||||||
role="dialog"
|
|
||||||
>
|
>
|
||||||
<strong>
|
<Library aria-hidden="true" size={18} />
|
||||||
{t("composer.knowledge.scope")}
|
</button>
|
||||||
</strong>
|
<div
|
||||||
{knowledgeLibraries.map(
|
aria-label={t("composer.contextSources")}
|
||||||
(library) => {
|
className="composer__options"
|
||||||
const instance = externalInstances.find(item => item.id === library.external?.instanceId);
|
id="composer-options"
|
||||||
const externalStatus = !instance ? 'temporarily-unavailable' : !instance.enabled ? 'instance-disabled' : instance.credentialStatus !== 'configured' || instance.probeStatus === 'auth-failed' ? 'credential-error' : ['failed', 'unreachable'].includes(instance.probeStatus) ? 'temporarily-unavailable' : 'ready';
|
hidden={!composerOptionsOpen}
|
||||||
return (
|
ref={composerOptionsRef}
|
||||||
<label key={library.id}>
|
role="dialog"
|
||||||
<input
|
onKeyDown={(event) => {
|
||||||
disabled={!!library.external && externalStatus !== 'ready'}
|
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||||
checked={enabledKnowledgeLibraryIds.includes(
|
event.preventDefault();
|
||||||
library.id,
|
event.stopPropagation();
|
||||||
)}
|
if (knowledgeScopeOpen) {
|
||||||
onChange={(event) =>
|
setKnowledgeScopeOpen(false);
|
||||||
actions.setEnabledKnowledgeLibraryIds(
|
knowledgeScopeTriggerRef.current?.focus();
|
||||||
(current) =>
|
} else {
|
||||||
event.target.checked
|
closeComposerOptions();
|
||||||
? [
|
composerOptionsTriggerRef.current?.focus();
|
||||||
...new Set([
|
}
|
||||||
...current,
|
}}
|
||||||
library.id,
|
>
|
||||||
]),
|
<strong>{t("composer.contextSources")}</strong>
|
||||||
]
|
{supervisorEnabled && (
|
||||||
: current.filter(
|
<label className="toggle-row composer__context-row">
|
||||||
(id) =>
|
<span className="composer__story-graph-label">
|
||||||
id !== library.id,
|
<Network aria-hidden="true" size={16} />
|
||||||
),
|
{t("composer.storyGraph.label")}
|
||||||
)
|
</span>
|
||||||
}
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
role="switch"
|
||||||
<span>{library.name}</span>
|
checked={view?.storyGraphEnabled !== false}
|
||||||
<small>
|
disabled={storyGraphSaving}
|
||||||
{library.external ? `${({ dify: 'Dify', fastgpt: 'FastGPT', ragflow: 'RAGFlow' })[library.external.provider]} · ${instance?.name ?? library.external.instanceId} · ${t(`external.states.${externalStatus}`, { ns: 'knowledge' })}` : t(
|
onChange={(event) => {
|
||||||
"composer.knowledge.documents",
|
setStoryGraphSaving(true);
|
||||||
{
|
void actions.setStoryGraphEnabled(conversationId, event.target.checked)
|
||||||
count: library.documentCount,
|
.catch((error: unknown) => actions.notify({ tone: "error", message: error instanceof Error ? error.message : String(error) }))
|
||||||
},
|
.finally(() => setStoryGraphSaving(false));
|
||||||
)}
|
}}
|
||||||
</small>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)},
|
|
||||||
)}
|
)}
|
||||||
<div className="knowledge-scope__retrieval-mode">
|
{knowledgeLibraries.length > 0 && (
|
||||||
<strong>
|
<div
|
||||||
{t("composer.knowledge.modeLabel")}
|
className="knowledge-scope"
|
||||||
</strong>
|
onBlurCapture={(event) => {
|
||||||
<SegmentedControl
|
if (
|
||||||
ariaLabel={t(
|
event.relatedTarget instanceof Node &&
|
||||||
"composer.knowledge.modeLabel",
|
!event.currentTarget.contains(
|
||||||
)}
|
event.relatedTarget,
|
||||||
onChange={actions.setKnowledgeRetrievalMode}
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: "auto",
|
|
||||||
label: t(
|
|
||||||
"composer.knowledge.auto",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "always",
|
|
||||||
label: t(
|
|
||||||
"composer.knowledge.always",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={
|
|
||||||
view?.knowledgeRetrievalMode ??
|
|
||||||
"auto"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<small>
|
|
||||||
{view?.knowledgeRetrievalMode ===
|
|
||||||
"always"
|
|
||||||
? t(
|
|
||||||
"composer.knowledge.alwaysDescription",
|
|
||||||
)
|
)
|
||||||
: t(
|
) {
|
||||||
"composer.knowledge.autoDescription",
|
setKnowledgeScopeOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-controls="knowledge-scope-popover"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-label={t("composer.knowledge.select", {
|
||||||
|
count: enabledKnowledgeLibraryIds.length,
|
||||||
|
})}
|
||||||
|
aria-expanded={knowledgeScopeOpen}
|
||||||
|
onClick={() => {
|
||||||
|
setComposerMenuOpen(undefined);
|
||||||
|
setRuntimeMenuOpen(false);
|
||||||
|
setKnowledgeScopeOpen(
|
||||||
|
(current) => !current,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
ref={knowledgeScopeTriggerRef}
|
||||||
|
title={t("composer.knowledge.title")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Library aria-hidden="true" size={16} />
|
||||||
|
<span>
|
||||||
|
{t("navigation.knowledge")}
|
||||||
|
<strong>
|
||||||
|
{enabledKnowledgeLibraryIds.length}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
aria-hidden="true"
|
||||||
|
className="knowledge-scope__chevron"
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{knowledgeScopeOpen && (
|
||||||
|
<div
|
||||||
|
aria-label={t("composer.knowledge.scope")}
|
||||||
|
className="knowledge-scope__popover"
|
||||||
|
id="knowledge-scope-popover"
|
||||||
|
ref={knowledgeScopePopoverRef}
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
{t("composer.knowledge.scope")}
|
||||||
|
</strong>
|
||||||
|
{knowledgeLibraries.map(
|
||||||
|
(library) => {
|
||||||
|
const instance = externalInstances.find(item => item.id === library.external?.instanceId);
|
||||||
|
const externalStatus = !instance ? 'temporarily-unavailable' : !instance.enabled ? 'instance-disabled' : instance.credentialStatus !== 'configured' || instance.probeStatus === 'auth-failed' ? 'credential-error' : ['failed', 'unreachable'].includes(instance.probeStatus) ? 'temporarily-unavailable' : 'ready';
|
||||||
|
return (
|
||||||
|
<label key={library.id}>
|
||||||
|
<input
|
||||||
|
disabled={!!library.external && externalStatus !== 'ready'}
|
||||||
|
checked={enabledKnowledgeLibraryIds.includes(
|
||||||
|
library.id,
|
||||||
|
)}
|
||||||
|
onChange={(event) =>
|
||||||
|
actions.setEnabledKnowledgeLibraryIds(
|
||||||
|
(current) =>
|
||||||
|
event.target.checked
|
||||||
|
? [
|
||||||
|
...new Set([
|
||||||
|
...current,
|
||||||
|
library.id,
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
: current.filter(
|
||||||
|
(id) =>
|
||||||
|
id !== library.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span>{library.name}</span>
|
||||||
|
<small>
|
||||||
|
{library.external ? `${({ dify: 'Dify', fastgpt: 'FastGPT', ragflow: 'RAGFlow' })[library.external.provider]} · ${instance?.name ?? library.external.instanceId} · ${t(`external.states.${externalStatus}`, { ns: 'knowledge' })}` : t(
|
||||||
|
"composer.knowledge.documents",
|
||||||
|
{
|
||||||
|
count: library.documentCount,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
)},
|
||||||
)}
|
)}
|
||||||
</small>
|
<div className="knowledge-scope__retrieval-mode">
|
||||||
</div>
|
<strong>
|
||||||
|
{t("composer.knowledge.modeLabel")}
|
||||||
|
</strong>
|
||||||
|
<SegmentedControl
|
||||||
|
ariaLabel={t(
|
||||||
|
"composer.knowledge.modeLabel",
|
||||||
|
)}
|
||||||
|
onChange={actions.setKnowledgeRetrievalMode}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: "auto",
|
||||||
|
label: t(
|
||||||
|
"composer.knowledge.auto",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "always",
|
||||||
|
label: t(
|
||||||
|
"composer.knowledge.always",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={
|
||||||
|
view?.knowledgeRetrievalMode ??
|
||||||
|
"auto"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<small>
|
||||||
|
{view?.knowledgeRetrievalMode ===
|
||||||
|
"always"
|
||||||
|
? t(
|
||||||
|
"composer.knowledge.alwaysDescription",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"composer.knowledge.autoDescription",
|
||||||
|
)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
<div className="composer__configuration">
|
|
||||||
<ComposerMenuSelect
|
<ComposerMenuSelect
|
||||||
ariaLabel={t("composer.expertLabel")}
|
ariaLabel={t("composer.expertLabel")}
|
||||||
className="composer-picker--expert"
|
className="composer-picker--expert"
|
||||||
@@ -1024,55 +1029,6 @@ export const Composer = memo(function Composer({
|
|||||||
options={assistantExpertOptions}
|
options={assistantExpertOptions}
|
||||||
value={selectedExpertId}
|
value={selectedExpertId}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{runtimeControlsProvider && (
|
|
||||||
<div aria-label={runtimeControlsLabel} className="composer__runtime-toolbar" role="group">
|
|
||||||
<strong className="composer__runtime-toolbar-label">{runtimeControlsLabel}</strong>
|
|
||||||
<div className="composer__runtime-controls">
|
|
||||||
{runtimeAgentControlAvailable && (
|
|
||||||
<ComposerMenuSelect
|
|
||||||
ariaLabel={t("composer.runtimeControls.agentLabel")}
|
|
||||||
className="composer-picker--runtime"
|
|
||||||
disabled={isRunning}
|
|
||||||
icon={<TerminalSquare aria-hidden="true" size={15} />}
|
|
||||||
menuOpen={composerMenuOpen === "runtime-agent"}
|
|
||||||
onChange={actions.selectRuntimeAgent}
|
|
||||||
onOpenChange={setRuntimeAgentMenuOpen}
|
|
||||||
options={runtimeAgentOptions}
|
|
||||||
value={selectedRuntimeAgent}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{runtimePresetControlAvailable && (
|
|
||||||
<ComposerMenuSelect
|
|
||||||
ariaLabel={t("composer.runtimeControls.presetLabel")}
|
|
||||||
className="composer-picker--runtime"
|
|
||||||
disabled={isRunning}
|
|
||||||
icon={<TerminalSquare aria-hidden="true" size={15} />}
|
|
||||||
menuOpen={composerMenuOpen === "runtime-preset"}
|
|
||||||
onChange={actions.selectContinuePreset}
|
|
||||||
onOpenChange={setRuntimePresetMenuOpen}
|
|
||||||
options={runtimePresetOptions}
|
|
||||||
value={selectedContinuePreset}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{runtimeActionControlAvailable && (
|
|
||||||
<ComposerMenuSelect
|
|
||||||
ariaLabel={t("composer.runtimeControls.actionLabel")}
|
|
||||||
className="composer-picker--runtime-action"
|
|
||||||
disabled={isRunning}
|
|
||||||
icon={<TerminalSquare aria-hidden="true" size={15} />}
|
|
||||||
menuOpen={composerMenuOpen === "runtime-action"}
|
|
||||||
onChange={actions.selectRuntimeAction}
|
|
||||||
onOpenChange={setRuntimeActionMenuOpen}
|
|
||||||
options={runtimeActionOptions}
|
|
||||||
value={runtimeActionOptions.find((option) => option.action?.type === "command" && option.action.id === selectedRuntimeCommand)?.value ?? ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="composer__configuration" role="group" aria-label={t("composer.settings")}>
|
|
||||||
<div className="runtime-picker">
|
<div className="runtime-picker">
|
||||||
<button
|
<button
|
||||||
aria-expanded={runtimeMenuOpen}
|
aria-expanded={runtimeMenuOpen}
|
||||||
@@ -1080,10 +1036,13 @@ export const Composer = memo(function Composer({
|
|||||||
className="model-button"
|
className="model-button"
|
||||||
disabled={isRunning || runtimeSwitching}
|
disabled={isRunning || runtimeSwitching}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setComposerOptionsOpen(false);
|
setMenus({
|
||||||
setKnowledgeScopeOpen(false);
|
optionsOpen: false,
|
||||||
setComposerMenuOpen(undefined);
|
runtimeOptionsOpen: false,
|
||||||
setRuntimeMenuOpen(!runtimeMenuOpen);
|
knowledgeScopeOpen: false,
|
||||||
|
menu: undefined,
|
||||||
|
runtimeMenuOpen: !runtimeMenuOpen,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (
|
if (
|
||||||
@@ -1093,10 +1052,13 @@ export const Composer = memo(function Composer({
|
|||||||
event.key === " ")
|
event.key === " ")
|
||||||
) {
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setComposerOptionsOpen(false);
|
setMenus({
|
||||||
setKnowledgeScopeOpen(false);
|
optionsOpen: false,
|
||||||
setComposerMenuOpen(undefined);
|
runtimeOptionsOpen: false,
|
||||||
setRuntimeMenuOpen(true);
|
knowledgeScopeOpen: false,
|
||||||
|
menu: undefined,
|
||||||
|
runtimeMenuOpen: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
ref={runtimeMenuButtonRef}
|
ref={runtimeMenuButtonRef}
|
||||||
@@ -1143,6 +1105,91 @@ export const Composer = memo(function Composer({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{runtimeControlsProvider && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
aria-controls="composer-runtime-options"
|
||||||
|
aria-expanded={runtimeOptionsOpen}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-label={runtimeControlsLabel}
|
||||||
|
className="composer__options-trigger"
|
||||||
|
onClick={() => {
|
||||||
|
setMenus({
|
||||||
|
runtimeOptionsOpen: !runtimeOptionsOpen,
|
||||||
|
optionsOpen: false,
|
||||||
|
menu: undefined,
|
||||||
|
runtimeMenuOpen: false,
|
||||||
|
knowledgeScopeOpen: false,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
ref={runtimeOptionsTriggerRef}
|
||||||
|
title={runtimeControlsLabel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal aria-hidden="true" size={18} />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
aria-label={runtimeControlsLabel}
|
||||||
|
className="composer__options composer__options--runtime"
|
||||||
|
id="composer-runtime-options"
|
||||||
|
hidden={!runtimeOptionsOpen}
|
||||||
|
ref={runtimeOptionsRef}
|
||||||
|
role="dialog"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
closeRuntimeOptions();
|
||||||
|
runtimeOptionsTriggerRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div aria-label={runtimeControlsLabel} className="composer__runtime-toolbar" role="group">
|
||||||
|
<strong className="composer__runtime-toolbar-label">{runtimeControlsLabel}</strong>
|
||||||
|
<div className="composer__runtime-controls">
|
||||||
|
{runtimeAgentControlAvailable && (
|
||||||
|
<ComposerMenuSelect
|
||||||
|
ariaLabel={t("composer.runtimeControls.agentLabel")}
|
||||||
|
className="composer-picker--runtime"
|
||||||
|
disabled={isRunning}
|
||||||
|
icon={<TerminalSquare aria-hidden="true" size={15} />}
|
||||||
|
menuOpen={composerMenuOpen === "runtime-agent"}
|
||||||
|
onChange={actions.selectRuntimeAgent}
|
||||||
|
onOpenChange={setRuntimeAgentMenuOpen}
|
||||||
|
options={runtimeAgentOptions}
|
||||||
|
value={selectedRuntimeAgent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{runtimePresetControlAvailable && (
|
||||||
|
<ComposerMenuSelect
|
||||||
|
ariaLabel={t("composer.runtimeControls.presetLabel")}
|
||||||
|
className="composer-picker--runtime"
|
||||||
|
disabled={isRunning}
|
||||||
|
icon={<TerminalSquare aria-hidden="true" size={15} />}
|
||||||
|
menuOpen={composerMenuOpen === "runtime-preset"}
|
||||||
|
onChange={actions.selectContinuePreset}
|
||||||
|
onOpenChange={setRuntimePresetMenuOpen}
|
||||||
|
options={runtimePresetOptions}
|
||||||
|
value={selectedContinuePreset}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{runtimeActionControlAvailable && (
|
||||||
|
<ComposerMenuSelect
|
||||||
|
ariaLabel={t("composer.runtimeControls.actionLabel")}
|
||||||
|
className="composer-picker--runtime-action"
|
||||||
|
disabled={isRunning}
|
||||||
|
icon={<TerminalSquare aria-hidden="true" size={15} />}
|
||||||
|
menuOpen={composerMenuOpen === "runtime-action"}
|
||||||
|
onChange={actions.selectRuntimeAction}
|
||||||
|
onOpenChange={setRuntimeActionMenuOpen}
|
||||||
|
options={runtimeActionOptions}
|
||||||
|
value={runtimeActionOptions.find((option) => option.action?.type === "command" && option.action.id === selectedRuntimeCommand)?.value ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="composer__submit-actions">
|
<div className="composer__submit-actions">
|
||||||
@@ -1378,3 +1425,41 @@ export const Composer = memo(function Composer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const emptyLibraryIds: string[] = [];
|
const emptyLibraryIds: string[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes a composer toolbar dialog when pointer or focus leaves it and its
|
||||||
|
* trigger, and moves keyboard focus to its first enabled control on open.
|
||||||
|
*/
|
||||||
|
function useDismissibleComposerDialog(
|
||||||
|
open: boolean,
|
||||||
|
dialogRef: RefObject<HTMLElement | null>,
|
||||||
|
triggerRef: RefObject<HTMLElement | null>,
|
||||||
|
close: () => void,
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isDialogTarget = (target: EventTarget | null): boolean =>
|
||||||
|
target instanceof Node &&
|
||||||
|
(dialogRef.current?.contains(target) === true ||
|
||||||
|
triggerRef.current?.contains(target) === true);
|
||||||
|
const dismissOutside = (event: Event): void => {
|
||||||
|
if (!isDialogTarget(event.target)) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
dialogRef.current
|
||||||
|
?.querySelector<HTMLElement>("button:not(:disabled), input:not(:disabled)")
|
||||||
|
?.focus();
|
||||||
|
});
|
||||||
|
document.addEventListener("pointerdown", dismissOutside);
|
||||||
|
document.addEventListener("focusin", dismissOutside);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
document.removeEventListener("pointerdown", dismissOutside);
|
||||||
|
document.removeEventListener("focusin", dismissOutside);
|
||||||
|
};
|
||||||
|
}, [close, dialogRef, open, triggerRef]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
|
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open/closed state of the composer's popups (options dialog, knowledge
|
* Open/closed state of the composer's popups (context dialog, runtime
|
||||||
* scope, the pickers and the runtime menu). It lives outside App so opening a
|
* controls dialog, knowledge scope, the pickers and the runtime menu). It lives outside App so opening a
|
||||||
* menu re-renders only the composer.
|
* menu re-renders only the composer.
|
||||||
*
|
*
|
||||||
* The state belongs to one composer context (conversation, running state and
|
* The state belongs to one composer context (conversation, running state and
|
||||||
@@ -17,6 +17,7 @@ export type ComposerMenuId =
|
|||||||
|
|
||||||
export type ComposerMenuState = {
|
export type ComposerMenuState = {
|
||||||
readonly optionsOpen: boolean;
|
readonly optionsOpen: boolean;
|
||||||
|
readonly runtimeOptionsOpen: boolean;
|
||||||
readonly knowledgeScopeOpen: boolean;
|
readonly knowledgeScopeOpen: boolean;
|
||||||
readonly menu: ComposerMenuId | undefined;
|
readonly menu: ComposerMenuId | undefined;
|
||||||
readonly runtimeMenuOpen: boolean;
|
readonly runtimeMenuOpen: boolean;
|
||||||
@@ -24,6 +25,7 @@ export type ComposerMenuState = {
|
|||||||
|
|
||||||
export const closedComposerMenus: ComposerMenuState = Object.freeze({
|
export const closedComposerMenus: ComposerMenuState = Object.freeze({
|
||||||
optionsOpen: false,
|
optionsOpen: false,
|
||||||
|
runtimeOptionsOpen: false,
|
||||||
knowledgeScopeOpen: false,
|
knowledgeScopeOpen: false,
|
||||||
menu: undefined,
|
menu: undefined,
|
||||||
runtimeMenuOpen: false,
|
runtimeMenuOpen: false,
|
||||||
@@ -38,6 +40,7 @@ export type ComposerMenuStore = ReturnType<typeof createComposerMenuStore>;
|
|||||||
function sameMenus(left: ComposerMenuState, right: ComposerMenuState): boolean {
|
function sameMenus(left: ComposerMenuState, right: ComposerMenuState): boolean {
|
||||||
return (
|
return (
|
||||||
left.optionsOpen === right.optionsOpen &&
|
left.optionsOpen === right.optionsOpen &&
|
||||||
|
left.runtimeOptionsOpen === right.runtimeOptionsOpen &&
|
||||||
left.knowledgeScopeOpen === right.knowledgeScopeOpen &&
|
left.knowledgeScopeOpen === right.knowledgeScopeOpen &&
|
||||||
left.menu === right.menu &&
|
left.menu === right.menu &&
|
||||||
left.runtimeMenuOpen === right.runtimeMenuOpen
|
left.runtimeMenuOpen === right.runtimeMenuOpen
|
||||||
@@ -78,7 +81,7 @@ export function createComposerMenuStore() {
|
|||||||
},
|
},
|
||||||
/** Closes the pickers and the runtime menu of whatever context is current. */
|
/** Closes the pickers and the runtime menu of whatever context is current. */
|
||||||
closeMenus(): void {
|
closeMenus(): void {
|
||||||
store.update(key, { menu: undefined, runtimeMenuOpen: false });
|
store.update(key, { menu: undefined, runtimeMenuOpen: false, runtimeOptionsOpen: false });
|
||||||
},
|
},
|
||||||
closeRuntimeMenu(): void {
|
closeRuntimeMenu(): void {
|
||||||
store.update(key, { runtimeMenuOpen: false });
|
store.update(key, { runtimeMenuOpen: false });
|
||||||
|
|||||||
@@ -546,6 +546,7 @@ export const app = {
|
|||||||
removeAttachment: 'Remove {{name}}',
|
removeAttachment: 'Remove {{name}}',
|
||||||
settings: 'Conversation settings',
|
settings: 'Conversation settings',
|
||||||
storyGraph: { label: 'Use story graph' },
|
storyGraph: { label: 'Use story graph' },
|
||||||
|
contextSources: 'Story graph and knowledge',
|
||||||
options: 'Options',
|
options: 'Options',
|
||||||
expertLabel: 'Expert role',
|
expertLabel: 'Expert role',
|
||||||
runtimeControls: {
|
runtimeControls: {
|
||||||
|
|||||||
@@ -528,6 +528,7 @@ export const app = {
|
|||||||
removeAttachment: '移除 {{name}}',
|
removeAttachment: '移除 {{name}}',
|
||||||
settings: '对话设置',
|
settings: '对话设置',
|
||||||
storyGraph: { label: '使用故事图谱' },
|
storyGraph: { label: '使用故事图谱' },
|
||||||
|
contextSources: '故事图谱与知识库',
|
||||||
options: '选项',
|
options: '选项',
|
||||||
expertLabel: '专家角色',
|
expertLabel: '专家角色',
|
||||||
runtimeControls: {
|
runtimeControls: {
|
||||||
|
|||||||
@@ -8511,6 +8511,42 @@ button > svg {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Story graph and knowledge rows share one row style in the context dialog. */
|
||||||
|
.composer__options .composer__context-row {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--font-body);
|
||||||
|
font-weight: 400;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__options .composer__context-row:hover:not(:has(input:disabled)) {
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__options .composer__story-graph-label,
|
||||||
|
.composer__options .knowledge-scope > button > span {
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: inherit;
|
||||||
|
font-size: var(--font-body);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__options .knowledge-scope > button {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__options .knowledge-scope > button > span {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__options .knowledge-scope__chevron {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.composer__options .composer__runtime-toolbar-label {
|
.composer__options .composer__runtime-toolbar-label {
|
||||||
padding: var(--space-1) var(--space-2);
|
padding: var(--space-1) var(--space-2);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -8637,10 +8673,16 @@ button > svg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.composer__configuration {
|
.composer__configuration {
|
||||||
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer__options--runtime {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.composer-picker--expert > .model-button {
|
.composer-picker--expert > .model-button {
|
||||||
width: 148px;
|
width: 148px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user