feat: add external knowledge retrieval and cross-project activity
Connect Dify, FastGPT and RAGFlow through read-only bindings, encrypted local credentials, shared retrieval and persistent citations. Configuration and removal affect GoodBuddy only. Show active conversations across projects, retain older live work, and improve project dialog scrolling. Queue concurrent questions and route managed SSH OpenCode questions through the Agent. Preserve recoverable read/search errors and partial search results. Document the Electron UI automation standard, real model and external knowledge validation, and remaining acceptance limits. Validation: earlier full regression passed 4081 tests with 66 skipped; subsequent focused remote-question checks and real Linux evidence are recorded in feature progress. Precommit typecheck and lint passed; latest project dialog and shared UI suites passed 58 tests. The full suite was not rerun for the final dialog changes. Release note: Use external knowledge in conversations, find active work across projects, and answer concurrent local or managed SSH OpenCode questions. Managed SSH questions require the updated Agent adapter.
This commit is contained in:
@@ -10,12 +10,18 @@ type AgentQuestion = Extract<AgentEvent, { type: 'question' }>
|
||||
|
||||
type AgentQuestionCardProps = {
|
||||
value: AgentQuestion
|
||||
id?: string
|
||||
taskTitle?: string
|
||||
pendingCount?: number
|
||||
onReject: () => Promise<void>
|
||||
onSubmit: (answers: AgentQuestionAnswer[]) => Promise<void>
|
||||
}
|
||||
|
||||
export function AgentQuestionCard({
|
||||
value,
|
||||
id,
|
||||
taskTitle,
|
||||
pendingCount = 1,
|
||||
onReject,
|
||||
onSubmit
|
||||
}: AgentQuestionCardProps): React.JSX.Element {
|
||||
@@ -60,6 +66,8 @@ export function AgentQuestionCard({
|
||||
return (
|
||||
<form
|
||||
className="agent-question-card"
|
||||
id={id}
|
||||
tabIndex={-1}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (complete) {
|
||||
@@ -71,6 +79,8 @@ export function AgentQuestionCard({
|
||||
<CircleHelp aria-hidden="true" size={18} />
|
||||
<strong>{t('question.title')}</strong>
|
||||
</header>
|
||||
{taskTitle && <p>{t('question.task', { title: taskTitle })}</p>}
|
||||
{pendingCount > 1 && <p>{t('question.pendingCount', { count: pendingCount })}</p>}
|
||||
{value.questions.map((question, questionIndex) => (
|
||||
<fieldset key={`${question.header}:${questionIndex}`}>
|
||||
<legend>
|
||||
|
||||
@@ -815,6 +815,16 @@ const api: DesktopApi = {
|
||||
}),
|
||||
},
|
||||
knowledge: {
|
||||
externalInstancesList: vi.fn(async () => []),
|
||||
externalInstancesSave: vi.fn(),
|
||||
externalInstancesTest: vi.fn(),
|
||||
externalInstancesSetEnabled: vi.fn(),
|
||||
externalInstancesDelete: vi.fn(),
|
||||
externalCatalogList: vi.fn(async () => ({ items: [], hasMore: false })),
|
||||
externalCatalogGet: vi.fn(),
|
||||
externalBindingsCreate: vi.fn(),
|
||||
externalBindingsUpdate: vi.fn(),
|
||||
externalRetrievalTest: vi.fn(async () => ({ results: [], durationMs: 1 })),
|
||||
getSnapshot: vi.fn(async () => ({
|
||||
libraries: [],
|
||||
sources: [],
|
||||
@@ -1218,6 +1228,289 @@ describe("App", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("project activity integration", () => {
|
||||
const otherProject = {
|
||||
...project,
|
||||
id: "00000000-0000-4000-8000-000000000102",
|
||||
name: "Background project",
|
||||
builtInDefault: false,
|
||||
};
|
||||
const conversations: ConversationSnapshot[] = [
|
||||
{ id: "activity-current", projectId, title: "Current discussion", updatedAt: 300, messages: [] },
|
||||
{ id: "activity-other-latest", projectId: otherProject.id, title: "Other latest", updatedAt: 200, messages: [] },
|
||||
{ id: "activity-target", projectId: otherProject.id, title: "Exact background discussion", updatedAt: 100,
|
||||
messages: [{ id: "activity-message", role: "assistant", content: "Exact conversation content", createdAt: 100, state: "complete" }] },
|
||||
];
|
||||
const task: AssistantTask = {
|
||||
id: "activity-task", projectId: otherProject.id,
|
||||
conversationId: "activity-target", title: "Background scheduled task",
|
||||
instructions: "Check background progress", origin: "schedule", status: "running",
|
||||
createdAt: "2026-08-19T00:00:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.projects.list).mockResolvedValueOnce([project, otherProject]);
|
||||
vi.mocked(api.conversations.list).mockResolvedValue(conversations);
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([task]);
|
||||
});
|
||||
|
||||
it("keeps cross-project task counts independent of initial and current search and clears on refresh", async () => {
|
||||
const initialTasks = deferred<AssistantTask[]>();
|
||||
vi.mocked(api.tasks.list).mockReturnValueOnce(initialTasks.promise);
|
||||
render(<App />);
|
||||
const search = await screen.findByLabelText("搜索对话");
|
||||
fireEvent.change(search, { target: { value: "no matching conversation" } });
|
||||
await act(async () => initialTasks.resolve([task, { ...task, id: "duplicate-task" }]));
|
||||
const summary = await screen.findByRole("button", { name: /全项目活动/u });
|
||||
expect(summary).toHaveTextContent("1 个运行中");
|
||||
expect(screen.queryByTitle("Exact background discussion")).not.toBeInTheDocument();
|
||||
fireEvent.change(search, { target: { value: "Current discussion" } });
|
||||
expect(summary).toHaveTextContent("1 个运行中");
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
const projectRow = within(screen.getByRole("menu", { name: "当前项目" }))
|
||||
.getByRole("menuitemradio", { name: /Background project/u });
|
||||
expect(projectRow).toHaveTextContent("1 个运行中");
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
fireEvent.click(summary);
|
||||
const dialog = screen.getByRole("dialog", { name: "全项目活动" });
|
||||
const running = within(dialog).getByRole("region", { name: "运行中" });
|
||||
expect(within(running).getAllByRole("button")).toHaveLength(1);
|
||||
expect(within(running).getByRole("button", { name: /Background project Exact background discussion/u })).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole("region", { name: "待处理" })).not.toBeInTheDocument();
|
||||
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([{ ...task, status: "completed" }]);
|
||||
act(() => conversationQueueChangeListener?.(task.conversationId!));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument());
|
||||
expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
|
||||
expect(within(screen.getByRole("menu")).getByRole("menuitemradio", { name: /Background project/u }))
|
||||
.not.toHaveTextContent("个运行中");
|
||||
});
|
||||
|
||||
it("opens the exact off-project conversation and clears task selection, search, menu and narrow sidebar", async () => {
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([task, {
|
||||
...task, id: "current-task", projectId, conversationId: "activity-current", title: "Current task", status: "idle",
|
||||
}]);
|
||||
const { container } = render(<App />);
|
||||
fireEvent.click(await screen.findByLabelText("展开或折叠“Current discussion”中的 1 个任务"));
|
||||
fireEvent.click(screen.getByText("Current task", { selector: ".conversation-task-child__title" }).closest("button")!);
|
||||
expect(await screen.findByRole("region", { name: "当前会话的任务" })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("搜索对话"), { target: { value: "Current discussion" } });
|
||||
const originalWidth = window.innerWidth;
|
||||
try {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 680 });
|
||||
act(() => window.dispatchEvent(new Event("resize")));
|
||||
const sidebar = container.querySelector<HTMLElement>(".sidebar")!;
|
||||
await waitFor(() => expect(sidebar).toHaveClass("sidebar--closed"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
|
||||
await waitFor(() => expect(sidebar.querySelector(".conversation-more")).toBeInTheDocument());
|
||||
fireEvent.click(sidebar.querySelector<HTMLButtonElement>(".conversation-more")!);
|
||||
expect(sidebar.querySelector(".conversation-actions")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
|
||||
fireEvent.click(within(screen.getByRole("dialog", { name: "全项目活动" }))
|
||||
.getByRole("button", { name: /Exact background discussion/u }));
|
||||
expect(await screen.findByText("Exact conversation content")).toBeInTheDocument();
|
||||
expect(sidebar).toHaveClass("sidebar--closed");
|
||||
expect(within(screen.getByRole("region", { name: "当前会话的任务" }))
|
||||
.getByRole("button", { name: /会话任务/u })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
|
||||
expect(screen.getByLabelText("搜索对话")).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("Background project");
|
||||
expect((await within(sidebar).findByTitle("Exact background discussion")).closest("button"))
|
||||
.toHaveClass("conversation-item--active");
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
selectProjectOption(project.name);
|
||||
expect(sidebar.querySelector(".conversation-actions")).not.toBeInTheDocument();
|
||||
} finally {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps keyboard focus inside activity without dismissing the narrow sidebar", async () => {
|
||||
const { container } = render(<App />);
|
||||
await screen.findByRole("button", { name: /全项目活动/u });
|
||||
const originalWidth = window.innerWidth;
|
||||
try {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 680 });
|
||||
act(() => window.dispatchEvent(new Event("resize")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
|
||||
const trigger = screen.getByRole("button", { name: /全项目活动/u });
|
||||
fireEvent.click(trigger);
|
||||
const dialog = screen.getByRole("dialog", { name: "全项目活动" });
|
||||
const close = within(dialog).getByRole("button", { name: "关闭全项目活动" });
|
||||
expect(close).toHaveFocus();
|
||||
// Native Tab must remain available between controls, even through a React portal.
|
||||
expect(fireEvent.keyDown(close, { key: "Tab" })).toBe(true);
|
||||
fireEvent.keyDown(close, { key: "Tab", shiftKey: true });
|
||||
expect(within(dialog).getByRole("button", { name: /Exact background discussion/u })).toHaveFocus();
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".sidebar")).not.toHaveClass("sidebar--closed");
|
||||
expect(trigger).toHaveFocus();
|
||||
} finally {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth });
|
||||
}
|
||||
});
|
||||
|
||||
it("respects the Settings leave guard before changing project and conversation", async () => {
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: /全项目活动/u });
|
||||
fireEvent.click(await screen.findByText("本地工作区"));
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
fireEvent.change(await screen.findByLabelText("默认工作区目录"), { target: { value: "C:\\Unsaved activity draft" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
|
||||
fireEvent.click(within(screen.getByRole("dialog", { name: "全项目活动" }))
|
||||
.getByRole("button", { name: /Exact background discussion/u }));
|
||||
expect(screen.getByRole("heading", { name: "设置中心" })).toBeVisible();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("当前设置有未保存更改");
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("Background project");
|
||||
expect(screen.getByLabelText("默认工作区目录")).toHaveValue("C:\\Unsaved activity draft");
|
||||
fireEvent.click(screen.getByRole("button", { name: "放弃更改并关闭" }));
|
||||
expect(await screen.findByText("Exact conversation content")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("Background project");
|
||||
expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["question", "approval"] as const)("preserves live %s across snapshot refresh and clears persisted terminal prompts", async (kind) => {
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: /全项目活动/u });
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Refresh pending prompt" } });
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const request = run.mock.calls[0]![0];
|
||||
act(() => agentListener?.(kind === "question" ? {
|
||||
requestId: request.requestId, type: "question", questionId: "refresh-question",
|
||||
questions: [{ header: "Scope", question: "Refresh question?", options: [], multiple: false, custom: true }],
|
||||
} : {
|
||||
requestId: request.requestId, type: "approval", approvalId: "refresh-approval",
|
||||
title: "Refresh approval", description: "Confirm refresh", toolName: "Bash", argumentSummary: "echo refresh", allowPermanent: false,
|
||||
}));
|
||||
if (kind === "question") act(() => agentListener?.({
|
||||
requestId: request.requestId, type: "question", questionId: "refresh-queued",
|
||||
questions: [{ header: "Scope", question: "Queued across refresh?", options: [], multiple: false, custom: true }],
|
||||
}));
|
||||
await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({ header: expect.objectContaining({ id: request.conversationId }), messages: expect.arrayContaining([
|
||||
expect.objectContaining({ role: "assistant", state: "streaming", status: undefined }),
|
||||
]) }),
|
||||
])));
|
||||
const write = vi.mocked(api.conversations.saveLocal).mock.calls.at(-1)![0]
|
||||
.find((conversation) => conversation.header.id === request.conversationId)!;
|
||||
const saved = { ...write.header, messages: write.messages };
|
||||
for (const updatedAt of [saved.updatedAt, saved.updatedAt + 60_000]) {
|
||||
const refreshed = { ...saved, updatedAt, title: `Snapshot ${updatedAt}` };
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([refreshed]);
|
||||
act(() => conversationQueueChangeListener?.(request.conversationId!));
|
||||
await screen.findAllByText(refreshed.title);
|
||||
if (kind === "question") expect(screen.getByText("还有 2 个待答问题,请依次回答。")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: kind === "question" ? "提交回答" : "仅此次" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理");
|
||||
}
|
||||
let resolveReply: (() => void) | undefined;
|
||||
if (kind === "question") {
|
||||
vi.mocked(api.agent.respondQuestion).mockImplementationOnce(() => new Promise<void>((resolve) => { resolveReply = resolve; }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "跳过" }));
|
||||
await waitFor(() => expect(resolveReply).toBeDefined());
|
||||
}
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([{
|
||||
...saved, updatedAt: saved.updatedAt + 120_000,
|
||||
messages: saved.messages.map((message) => message.role === "assistant"
|
||||
? { ...message, state: "complete", content: "Persisted terminal result" } : message),
|
||||
}]);
|
||||
act(() => conversationQueueChangeListener?.(request.conversationId!));
|
||||
await screen.findByText("Persisted terminal result");
|
||||
await act(async () => resolveReply?.());
|
||||
expect(screen.getByText("Persisted terminal result")).toBeInTheDocument();
|
||||
if (kind === "question") expect(screen.getByText("问题与回答")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: kind === "question" ? "提交回答" : "仅此次" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it.each((["question", "approval"] as const).flatMap((kind) =>
|
||||
(["success", "question", "approval", "done", "failure"] as const).map((outcome) => ({ kind, outcome })),
|
||||
))("settles $kind response activity safely on $outcome", async ({ kind, outcome }) => {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: Error) => void;
|
||||
vi.mocked(kind === "question" ? api.agent.respondQuestion : api.agent.respondApproval)
|
||||
.mockImplementationOnce(() => new Promise<void>((yes, no) => { resolve = yes; reject = no; }));
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: /全项目活动/u });
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Response activity" } });
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const request = run.mock.calls[0]![0];
|
||||
const questionEvent = {
|
||||
requestId: request.requestId, type: "question" as const, questionId: "first-question",
|
||||
questions: [{ header: "Scope", question: "Response question?", options: [], multiple: false, custom: true }],
|
||||
};
|
||||
const approvalEvent = {
|
||||
requestId: request.requestId, type: "approval" as const, approvalId: "first-approval",
|
||||
title: "Response approval", description: "Confirm response", toolName: "Bash", argumentSummary: "echo response", allowPermanent: false,
|
||||
};
|
||||
act(() => agentListener?.(kind === "question" ? questionEvent : approvalEvent));
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理 1 个运行中");
|
||||
fireEvent.click(screen.getByRole("button", { name: kind === "question" ? "跳过" : "仅此次" }));
|
||||
await waitFor(() => expect(kind === "question" ? api.agent.respondQuestion : api.agent.respondApproval).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
if (outcome === "failure") reject(new Error("Response failed"));
|
||||
else {
|
||||
if (outcome === "question") agentListener?.({ ...questionEvent, questionId: "next-question", questions: [{ ...questionEvent.questions[0]!, question: "Next question?" }] });
|
||||
if (outcome === "approval") agentListener?.({ ...approvalEvent, approvalId: "next-approval", title: "Next approval" });
|
||||
if (outcome === "done") agentListener?.({ requestId: request.requestId, type: "done" });
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
const summary = screen.getByRole("button", { name: /全项目活动/u });
|
||||
expect(summary).toHaveTextContent(outcome === "success" ? "2 个运行中"
|
||||
: outcome === "done" ? "1 个运行中" : "1 个待处理 1 个运行中");
|
||||
if (outcome === "success" || outcome === "done") expect(summary).not.toHaveTextContent("待处理");
|
||||
if (outcome === "question") expect(screen.getByText("Next question?")).toBeInTheDocument();
|
||||
if (outcome === "approval") expect(screen.getAllByText("Next approval").length).toBeGreaterThan(0);
|
||||
if (outcome === "failure") expect(screen.getByRole("button", { name: kind === "question" ? "跳过" : "仅此次" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it.each(["question", "approval"] as const)("groups pending %s ahead of running, deduplicates live and task activity, and clears terminal events", async (kind) => {
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: /全项目活动/u });
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Track live activity" } });
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const request = run.mock.calls[0]![0];
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("2 个运行中");
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([task, {
|
||||
...task, id: "live-task", projectId, conversationId: request.conversationId, status: "waiting_approval",
|
||||
}]);
|
||||
act(() => {
|
||||
agentListener?.(kind === "question" ? {
|
||||
requestId: request.requestId, type: "question", questionId: "activity-question",
|
||||
questions: [{ header: "范围", question: "继续吗?", options: [], multiple: false, custom: true }],
|
||||
} : {
|
||||
requestId: request.requestId, type: "approval", approvalId: "activity-approval",
|
||||
title: "Approve activity", description: "Confirm execution", toolName: "Bash", argumentSummary: "echo activity", allowPermanent: true,
|
||||
});
|
||||
conversationQueueChangeListener?.(request.conversationId!);
|
||||
});
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理 1 个运行中"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
|
||||
const dialog = screen.getByRole("dialog", { name: "全项目活动" });
|
||||
expect(within(dialog).getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual(["待处理", "运行中"]);
|
||||
const pending = within(dialog).getByRole("region", { name: "待处理" });
|
||||
expect(within(pending).getAllByRole("button")).toHaveLength(1);
|
||||
expect(pending).toHaveTextContent(kind === "question" ? "等待你的回答" : "等待审批");
|
||||
expect(within(dialog).getByRole("region", { name: "运行中" })).toHaveTextContent("Exact background discussion");
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([]);
|
||||
act(() => {
|
||||
agentListener?.(kind === "question"
|
||||
? { requestId: request.requestId, type: "done" }
|
||||
: { requestId: request.requestId, type: "error", status: "failed", message: "Activity execution failed" });
|
||||
conversationQueueChangeListener?.(request.conversationId!);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument());
|
||||
expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("provides custom minimize, maximize, and close controls", async () => {
|
||||
const { unmount } = render(<App />);
|
||||
|
||||
@@ -1968,9 +2261,14 @@ describe("App", () => {
|
||||
expect(await screen.findAllByText("请求写入工作区")).not.toHaveLength(0);
|
||||
expect(screen.getByText("仅此次")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("任务结果:发布任务")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理");
|
||||
fireEvent.click(screen.getByRole("button", { name: "仅此次" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个运行中"));
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it("idle-preloads only the small Heartbeat route at startup", async () => {
|
||||
const previousLoads = { ...routeModuleLoads };
|
||||
render(<App />);
|
||||
|
||||
expect(window.requestIdleCallback).toHaveBeenCalledWith(
|
||||
@@ -1987,10 +2285,10 @@ describe("App", () => {
|
||||
});
|
||||
await waitFor(() => expect(routeModuleLoads.heartbeat).toBe(1));
|
||||
expect(routeModuleLoads).toMatchObject({
|
||||
activity: 0,
|
||||
knowledge: 0,
|
||||
magicNotes: 0,
|
||||
settings: 0,
|
||||
activity: previousLoads.activity,
|
||||
knowledge: previousLoads.knowledge,
|
||||
magicNotes: previousLoads.magicNotes,
|
||||
settings: previousLoads.settings,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4709,6 +5007,43 @@ describe("App", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves distinct external chunks with identical snippets across reference events", async () => {
|
||||
render(<App />);
|
||||
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "Find evidence" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const request = run.mock.calls[0]![0];
|
||||
const references = ["chunk-a", "chunk-b"].map((remoteChunkId) => ({
|
||||
libraryId: "11111111-1111-4111-8111-111111111111",
|
||||
libraryName: "External library",
|
||||
documentName: "Shared title",
|
||||
sourceName: "Shared source",
|
||||
snippet: "Identical evidence",
|
||||
rank: 1,
|
||||
external: {
|
||||
kind: "external" as const,
|
||||
provider: "dify" as const,
|
||||
instanceId: "instance-a",
|
||||
remoteKnowledgeBaseId: "dataset-a",
|
||||
remoteDocumentId: "document-a",
|
||||
remoteChunkId,
|
||||
},
|
||||
}));
|
||||
act(() => {
|
||||
for (const reference of [...references, references[0]!]) {
|
||||
agentListener?.({
|
||||
requestId: request.requestId,
|
||||
type: "source-references",
|
||||
references: [reference],
|
||||
});
|
||||
}
|
||||
agentListener?.({ requestId: request.requestId, type: "done" });
|
||||
});
|
||||
expect(await screen.findByText("查看 2 条证据引用")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits knowledge scope without eager search or prompt injection and merges runtime references", async () => {
|
||||
const libraryId = "11111111-1111-4111-8111-111111111111";
|
||||
vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({
|
||||
@@ -5998,6 +6333,41 @@ describe("App", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a new external binding to the current selection without re-enabling an excluded library", async () => {
|
||||
const existing = { id: '11111111-1111-4111-8111-111111111111', name: 'Existing', description: '', storageMode: 'managed' as const, graphEnabled: false, graphStrategy: 'rules' as const, sourceCount: 0, documentCount: 0, indexedDocumentCount: 0 };
|
||||
const excluded = { ...existing, id: '22222222-2222-4222-8222-222222222222', name: 'Excluded' };
|
||||
const instance = { id: 'instance-1', name: 'Company Dify', provider: 'dify' as const, baseUrl: 'https://knowledge.example', enabled: true, credentialStatus: 'configured' as const, probeStatus: 'healthy' as const, bindingCount: 0 };
|
||||
const created = { ...existing, id: '33333333-3333-4333-8333-333333333333', name: 'Remote handbook', external: { knowledgeBaseId: '33333333-3333-4333-8333-333333333333', instanceId: instance.id, provider: 'dify' as const, remoteKnowledgeBaseId: 'remote-1', remoteName: 'Remote handbook', commonConfig: { resultLimit: 6, requestTimeoutMs: 15000, maxSnippetCharacters: 4000 }, providerConfig: { provider: 'dify' as const, useDatasetDefaults: true as const }, lastVerifiedAt: '2026-09-12' } };
|
||||
const initial = { libraries: [existing, excluded], selectedLibraryId: existing.id, sources: [], documents: [], graphNodes: [], graphRelations: [], evidence: [] };
|
||||
const updated = { ...initial, libraries: [...initial.libraries, created], selectedLibraryId: created.id };
|
||||
vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce(initial).mockResolvedValueOnce(updated);
|
||||
vi.mocked(api.knowledge.externalInstancesList).mockResolvedValueOnce([instance]).mockResolvedValueOnce([instance]);
|
||||
vi.mocked(api.knowledge.externalCatalogList).mockResolvedValueOnce({ items: [{ id: 'remote-1', name: 'Remote handbook' }], hasMore: false });
|
||||
vi.mocked(api.knowledge.externalCatalogGet).mockResolvedValueOnce({ id: 'remote-1', name: 'Remote handbook' });
|
||||
vi.mocked(api.knowledge.externalBindingsCreate).mockResolvedValueOnce(updated);
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([{ id: '44444444-4444-4444-8444-444444444444', projectId, title: 'External scope test', updatedAt: 200, messages: [], knowledgeLibraryIds: [existing.id] }]);
|
||||
render(<App />);
|
||||
await screen.findByRole('button', { name: /^External scope test/u });
|
||||
fireEvent.click(screen.getByRole('button', { name: '知识库' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '新建知识库' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dify' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '外部实例' }));
|
||||
fireEvent.click(within(screen.getByRole('menu', { name: '外部实例' })).getByRole('menuitemradio', { name: /Company Dify/u }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Remote handbook/u }));
|
||||
fireEvent.change(screen.getByLabelText('测试查询'), { target: { value: 'network policy' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试检索' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '添加知识库' })).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加知识库' }));
|
||||
await screen.findByRole('heading', { name: 'Remote handbook' });
|
||||
expect(api.knowledge.getSnapshot).toHaveBeenLastCalledWith(created.id);
|
||||
fireEvent.click(screen.getByRole('button', { name: /^External scope test/u }));
|
||||
openComposerOptions();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '选择知识库,本次已启用 2 个' }));
|
||||
expect(screen.getByRole('checkbox', { name: /Existing/u })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /Excluded/u })).not.toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: /Remote handbook.*Dify.*Company Dify/u })).toBeChecked();
|
||||
});
|
||||
|
||||
it("ignores stale Git changes after switching projects", async () => {
|
||||
const secondProject = {
|
||||
...project,
|
||||
@@ -8049,6 +8419,8 @@ describe("App", () => {
|
||||
.mockResolvedValueOnce(failed);
|
||||
const callsBefore = vi.mocked(api.runtimeCustomization.getNativeSnapshot).mock.calls.length;
|
||||
selectProjectOption(firstProject.name);
|
||||
// Let the project picker restore focus before opening the dismiss-on-blur options.
|
||||
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
||||
openComposerOptions();
|
||||
const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" });
|
||||
await act(async () => {
|
||||
@@ -8067,6 +8439,9 @@ describe("App", () => {
|
||||
await waitFor(() => expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(callsBefore + 3));
|
||||
vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce(empty);
|
||||
selectProjectOption(firstProject.name);
|
||||
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
|
||||
openComposerOptions();
|
||||
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);
|
||||
});
|
||||
@@ -10267,6 +10642,89 @@ describe("App", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("queues concurrent task questions by ID without losing drafts on duplicates or failed replies", async () => {
|
||||
let reject!: (error: Error) => void;
|
||||
vi.mocked(api.agent.respondQuestion).mockImplementationOnce(() => new Promise<void>((_resolve, no) => { reject = no; }));
|
||||
render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Parallel questions" } });
|
||||
fireEvent.click(await screen.findByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
const first: Extract<AgentEvent, { type: "question" }> = {
|
||||
requestId, type: "question", questionId: "queue-first", childTaskId: "queue-child",
|
||||
questions: [{ header: "Scope", question: "First queued question?", options: [], multiple: false, custom: true }],
|
||||
};
|
||||
const second = { ...first, questionId: "queue-second", childTaskId: undefined,
|
||||
questions: [{ ...first.questions[0]!, question: "Second queued question?" }] };
|
||||
act(() => {
|
||||
agentListener?.({ requestId, type: "subagent", childTaskId: "queue-child", expertId: "expert",
|
||||
expertName: "general", routingMode: "native", state: "running", reason: "Inspect queue task" });
|
||||
agentListener?.(first);
|
||||
agentListener?.(first);
|
||||
});
|
||||
expect(screen.queryByText("还有 2 个待答问题,请依次回答。")).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "其他回答" }), { target: { value: "Keep this draft" } });
|
||||
act(() => { agentListener?.(second); agentListener?.(first); });
|
||||
expect(screen.getByText("任务:Inspect queue task")).toBeInTheDocument();
|
||||
expect(screen.getByText("还有 2 个待答问题,请依次回答。")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Second queued question?")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "其他回答" })).toHaveValue("Keep this draft");
|
||||
const taskCard = screen.getByText("Inspect queue task").closest("details")!;
|
||||
expect(taskCard).toHaveTextContent("等待你的回答");
|
||||
taskCard.open = true;
|
||||
fireEvent(taskCard, new Event("toggle"));
|
||||
const form = document.querySelector<HTMLFormElement>(".agent-question-card")!;
|
||||
form.scrollIntoView = vi.fn();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "前往问答表单" }));
|
||||
expect(document.activeElement).toHaveClass("agent-question-card");
|
||||
expect(form.scrollIntoView).toHaveBeenCalledWith({ block: "center" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "提交回答" }));
|
||||
const third = { ...second, questionId: "queue-third",
|
||||
questions: [{ ...first.questions[0]!, question: "Third queued question?" }] };
|
||||
await act(async () => { agentListener?.(third); reject(new Error("Queue reply failed")); });
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Queue reply failed");
|
||||
expect(screen.getByRole("textbox", { name: "其他回答" })).toHaveValue("Keep this draft");
|
||||
fireEvent.click(screen.getByRole("button", { name: "提交回答" }));
|
||||
await screen.findByText("Second queued question?");
|
||||
expect(api.agent.respondQuestion).toHaveBeenLastCalledWith("queue-first", [["Keep this draft"]]);
|
||||
expect(screen.getByRole("textbox", { name: "其他回答" })).toHaveValue("");
|
||||
act(() => agentListener?.(first));
|
||||
expect(screen.getByText("还有 2 个待答问题,请依次回答。")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "跳过" }));
|
||||
await screen.findByText("Third queued question?");
|
||||
expect(api.agent.respondQuestion).toHaveBeenLastCalledWith("queue-second", undefined);
|
||||
fireEvent.click(screen.getByRole("button", { name: "跳过" }));
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument());
|
||||
expect(api.agent.respondQuestion).toHaveBeenLastCalledWith("queue-third", undefined);
|
||||
expect(screen.getAllByText("问题与回答")).toHaveLength(3);
|
||||
act(() => agentListener?.(first));
|
||||
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it("removes remotely resolved questions without recording a skip or blocking the next question", async () => {
|
||||
render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Remote questions" } });
|
||||
fireEvent.click(await screen.findByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
act(() => {
|
||||
for (const id of ["first", "second"]) agentListener?.({ requestId, type: "question", questionId: id,
|
||||
questions: [{ header: "Input", question: `Remote ${id}?`, options: [], multiple: false, custom: true }] });
|
||||
});
|
||||
expect(screen.getByText("Remote first?")).toBeInTheDocument();
|
||||
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "first" }));
|
||||
expect(screen.getByText("Remote second?")).toBeInTheDocument();
|
||||
expect(screen.queryByText("问题与回答")).not.toBeInTheDocument();
|
||||
expect(api.agent.respondQuestion).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "其他回答" }), { target: { value: "Retain draft" } });
|
||||
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "first" }));
|
||||
expect(screen.getByRole("textbox", { name: "其他回答" })).toHaveValue("Retain draft");
|
||||
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "second" }));
|
||||
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
|
||||
});
|
||||
|
||||
it("renders, saves and reloads OpenCode answers across question rounds", async () => {
|
||||
const view = render(<App />);
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
@@ -10363,7 +10821,7 @@ describe("App", () => {
|
||||
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["done", "question", "failure"] as const)(
|
||||
it.each(["done", "cancelled", "error", "question", "resolved", "failure"] as const)(
|
||||
"preserves question state when %s occurs during answer submission",
|
||||
async (outcome) => {
|
||||
let resolve!: () => void;
|
||||
@@ -10386,6 +10844,10 @@ describe("App", () => {
|
||||
act(() => agentListener?.({
|
||||
requestId: request.requestId, type: "question", questionId: "first", questions,
|
||||
}));
|
||||
act(() => agentListener?.({
|
||||
requestId: request.requestId, type: "question", questionId: "queued",
|
||||
questions: [{ ...questions[0]!, question: "Already queued?" }],
|
||||
}));
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "其他回答" }), {
|
||||
target: { value: "保留完整自定义回答\n" + "检查".repeat(600) },
|
||||
});
|
||||
@@ -10395,8 +10857,12 @@ describe("App", () => {
|
||||
if (outcome === "failure") {
|
||||
reject(new Error("Answer delivery failed"));
|
||||
} else {
|
||||
agentListener?.(outcome === "done"
|
||||
? { requestId: request.requestId, type: "done" }
|
||||
agentListener?.(outcome === "cancelled" || outcome === "error"
|
||||
? { requestId: request.requestId, type: "error", status: outcome === "cancelled" ? "cancelled" : "failed", message: "Terminal queue result" }
|
||||
: outcome === "done"
|
||||
? { requestId: request.requestId, type: "done" }
|
||||
: outcome === "resolved"
|
||||
? { requestId: request.requestId, type: "question-resolved", questionId: "first" }
|
||||
: { requestId: request.requestId, type: "question", questionId: "second",
|
||||
questions: [{ ...questions[0]!, question: "是否继续下一步?" }] });
|
||||
resolve();
|
||||
@@ -10416,9 +10882,18 @@ describe("App", () => {
|
||||
})]),
|
||||
})]),
|
||||
));
|
||||
if (outcome === "question") {
|
||||
expect(screen.getByText("是否继续下一步?")).toBeInTheDocument();
|
||||
if (outcome === "question" || outcome === "resolved") {
|
||||
expect(screen.getByText("Already queued?")).toBeInTheDocument();
|
||||
expect(screen.queryByText("是否继续下一步?")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "提交回答" })).toBeInTheDocument();
|
||||
if (outcome === "question") {
|
||||
fireEvent.click(screen.getByRole("button", { name: "跳过" }));
|
||||
expect(await screen.findByText("是否继续下一步?")).toBeInTheDocument();
|
||||
}
|
||||
} else {
|
||||
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Already queued?")).not.toBeInTheDocument();
|
||||
if (outcome !== "done") expect(screen.getByRole("alert")).toHaveTextContent("Terminal queue result");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+222
-93
@@ -56,6 +56,7 @@ import {
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { knowledgeReferenceKey } from "../../shared/knowledge-reference";
|
||||
import type { TFunction } from "i18next";
|
||||
import type {
|
||||
ApprovalDecision,
|
||||
@@ -161,6 +162,8 @@ import {
|
||||
ScopeBadge,
|
||||
} from "./WorkspacePrimitives";
|
||||
import { ProjectSwitcher } from "./ProjectSwitcher";
|
||||
import { ProjectActivity } from "./ProjectActivity";
|
||||
import { deriveConversationActivity } from "./conversation-activity";
|
||||
import {
|
||||
RightAssistantSidebar,
|
||||
type AssistantSidebarTab,
|
||||
@@ -1461,10 +1464,14 @@ function mergePersistedConversations(
|
||||
);
|
||||
const messages = [
|
||||
...conversation.messages.map((message) => {
|
||||
const localMessage = localMessageById.get(message.id);
|
||||
if (!localIsNewer) {
|
||||
// Pending prompts are live-only and are omitted from persisted messages.
|
||||
if (localMessage?.state === "streaming" && message.state === "streaming") {
|
||||
return { ...message, approval: localMessage.approval, pendingQuestions: localMessage.pendingQuestions };
|
||||
}
|
||||
return message;
|
||||
}
|
||||
const localMessage = localMessageById.get(message.id);
|
||||
if (
|
||||
!localMessage ||
|
||||
persistedTerminalStateOverridesLocal(localMessage, message)
|
||||
@@ -2547,11 +2554,13 @@ function App(): React.JSX.Element {
|
||||
tasks: [],
|
||||
},
|
||||
);
|
||||
const [externalInstances, setExternalInstances] = useState<import('../../shared/external-knowledge-contracts').ExternalKnowledgeInstanceSummary[]>([]);
|
||||
const [knowledgeLoading, setKnowledgeLoading] = useState(true);
|
||||
const [knowledgeLoadError, setKnowledgeLoadError] = useState<string>();
|
||||
const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0);
|
||||
const knowledgeOperationCountRef = useRef(knowledgeOperationCount);
|
||||
const knowledgeLoadRequestRef = useRef(0);
|
||||
const citationRequestRef = useRef(0);
|
||||
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(undefined);
|
||||
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null);
|
||||
@@ -2855,6 +2864,7 @@ function App(): React.JSX.Element {
|
||||
?.focus();
|
||||
});
|
||||
const closeOnEscape = (event: KeyboardEvent): void => {
|
||||
if (sidebarRef.current?.closest<HTMLElement>(".app-shell")?.inert) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeNarrowSidebar();
|
||||
@@ -3763,6 +3773,26 @@ function App(): React.JSX.Element {
|
||||
),
|
||||
[projects, tWorkspace],
|
||||
);
|
||||
const projectActivity = useMemo(
|
||||
() => deriveConversationActivity(
|
||||
conversations.map((conversation) => ({
|
||||
...conversation,
|
||||
title: conversationTitles.get(conversation.id) ?? conversation.title,
|
||||
})),
|
||||
assistantTasks,
|
||||
activeConversationIds,
|
||||
projects.map((project) => ({
|
||||
id: project.id,
|
||||
name: projectNames.get(project.id) ?? project.name,
|
||||
})),
|
||||
tWorkspace("projectActivity.unassigned"),
|
||||
),
|
||||
[conversations, conversationTitles, assistantTasks, activeConversationIds,
|
||||
projects, projectNames, tWorkspace],
|
||||
);
|
||||
const activityByConversationId = new Map(
|
||||
projectActivity.activities.map((activity) => [activity.conversationId, activity]),
|
||||
);
|
||||
const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>(
|
||||
() =>
|
||||
conversations.flatMap((conversation) =>
|
||||
@@ -3939,13 +3969,16 @@ function App(): React.JSX.Element {
|
||||
async (libraryId?: string): Promise<KnowledgeSnapshot> => {
|
||||
const requestId = ++knowledgeLoadRequestRef.current;
|
||||
try {
|
||||
const snapshot =
|
||||
await window.goodbuddy.knowledge.getSnapshot(libraryId);
|
||||
const [snapshot, instances] = await Promise.all([
|
||||
window.goodbuddy.knowledge.getSnapshot(libraryId),
|
||||
window.goodbuddy.knowledge.externalInstancesList(),
|
||||
]);
|
||||
if (requestId !== knowledgeLoadRequestRef.current) {
|
||||
return snapshot;
|
||||
}
|
||||
failedKnowledgeLibraryIdRef.current = undefined;
|
||||
setKnowledgeSnapshot(snapshot);
|
||||
setExternalInstances(instances);
|
||||
setKnowledgeLoadError(undefined);
|
||||
const availableIds = new Set(
|
||||
snapshot.libraries.map((library) => library.id),
|
||||
@@ -4232,6 +4265,27 @@ function App(): React.JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
const liveMessage = conversationsRef.current
|
||||
.find((conversation) => conversation.id === run.conversationId)
|
||||
?.messages.find((message) => message.id === run.messageId);
|
||||
if (event.type === "question-resolved") {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||
if (!message.pendingQuestions?.some((question) => question.questionId === event.questionId)) return message;
|
||||
const pendingQuestions = message.pendingQuestions.filter((question) => question.questionId !== event.questionId);
|
||||
if (!pendingQuestions.length && !message.approval && message.state === "streaming") {
|
||||
setAssistantTasks((current) => current.map((task) =>
|
||||
task.id === event.requestId && task.status === "waiting_approval" ? { ...task, status: "running" } : task,
|
||||
));
|
||||
}
|
||||
return { ...message, pendingQuestions };
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.type === "question" && (
|
||||
liveMessage?.state !== "streaming" ||
|
||||
liveMessage.pendingQuestions?.some((question) => question.questionId === event.questionId) ||
|
||||
liveMessage.answeredQuestions?.some((question) => question.questionId === event.questionId)
|
||||
)) return;
|
||||
setAssistantTasks((current) => {
|
||||
let changed = false;
|
||||
const updated = current.map((task) => {
|
||||
@@ -4245,7 +4299,9 @@ function App(): React.JSX.Element {
|
||||
? "completed"
|
||||
: event.type === "error"
|
||||
? event.status
|
||||
: "running";
|
||||
: liveMessage?.pendingQuestions?.length || liveMessage?.approval
|
||||
? "waiting_approval"
|
||||
: "running";
|
||||
const completedAt =
|
||||
event.type === "done" || event.type === "error"
|
||||
? new Date().toISOString()
|
||||
@@ -4640,11 +4696,18 @@ function App(): React.JSX.Element {
|
||||
},
|
||||
}));
|
||||
} else if (event.type === "question") {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
...message,
|
||||
status: undefined,
|
||||
question: event,
|
||||
}));
|
||||
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||
if (message.state !== "streaming" ||
|
||||
message.pendingQuestions?.some((question) => question.questionId === event.questionId) ||
|
||||
message.answeredQuestions?.some((question) => question.questionId === event.questionId)) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
status: undefined,
|
||||
pendingQuestions: [...(message.pendingQuestions ?? []), event],
|
||||
};
|
||||
});
|
||||
} else if (event.type === "artifact") {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
...message,
|
||||
@@ -4673,27 +4736,19 @@ function App(): React.JSX.Element {
|
||||
}));
|
||||
} else if (event.type === "source-references") {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||
const referenceKey = (reference: KnowledgeSearchReference): string =>
|
||||
[
|
||||
reference.libraryId,
|
||||
reference.documentId,
|
||||
reference.chunkId ?? "",
|
||||
reference.locator ?? "",
|
||||
reference.snippet,
|
||||
].join("\0");
|
||||
const incoming = [
|
||||
...new Map(
|
||||
event.references.map((reference) => [
|
||||
referenceKey(reference),
|
||||
knowledgeReferenceKey(reference),
|
||||
reference,
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
const incomingKeys = new Set(incoming.map(referenceKey));
|
||||
const incomingKeys = new Set(incoming.map(knowledgeReferenceKey));
|
||||
const references = [
|
||||
...incoming,
|
||||
...(message.sourceReferences ?? []).filter(
|
||||
(reference) => !incomingKeys.has(referenceKey(reference)),
|
||||
(reference) => !incomingKeys.has(knowledgeReferenceKey(reference)),
|
||||
),
|
||||
].slice(0, 20);
|
||||
return {
|
||||
@@ -4812,7 +4867,7 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
: message.contextCompressions,
|
||||
approval: undefined,
|
||||
question: undefined,
|
||||
pendingQuestions: undefined,
|
||||
tools: toolTerminalState
|
||||
? message.tools?.map((tool) =>
|
||||
tool.state === "pending" || tool.state === "running"
|
||||
@@ -6715,11 +6770,16 @@ function App(): React.JSX.Element {
|
||||
|
||||
const openCitationContext = useCallback(
|
||||
async (reference: KnowledgeSearchReference): Promise<void> => {
|
||||
const request = ++citationRequestRef.current;
|
||||
if (reference.external) {
|
||||
setCitationDialog({ reference, loading: false });
|
||||
return;
|
||||
}
|
||||
setCitationDialog({
|
||||
reference,
|
||||
loading: true,
|
||||
});
|
||||
if (!reference.chunkId) {
|
||||
if (!reference.chunkId || !reference.documentId) {
|
||||
setCitationDialog({
|
||||
reference,
|
||||
loading: false,
|
||||
@@ -6733,6 +6793,7 @@ function App(): React.JSX.Element {
|
||||
documentId: reference.documentId,
|
||||
chunkId: reference.chunkId,
|
||||
});
|
||||
if (request !== citationRequestRef.current) return;
|
||||
setCitationDialog({
|
||||
reference,
|
||||
loading: false,
|
||||
@@ -6747,6 +6808,7 @@ function App(): React.JSX.Element {
|
||||
},
|
||||
});
|
||||
} catch (reason) {
|
||||
if (request !== citationRequestRef.current) return;
|
||||
setCitationDialog({
|
||||
reference,
|
||||
loading: false,
|
||||
@@ -6762,7 +6824,7 @@ function App(): React.JSX.Element {
|
||||
|
||||
const openCitationSource = useCallback(
|
||||
async (reference: KnowledgeSearchReference): Promise<void> => {
|
||||
if (!reference.chunkId) {
|
||||
if (reference.external || !reference.chunkId || !reference.documentId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -7123,7 +7185,9 @@ function App(): React.JSX.Element {
|
||||
startedAt,
|
||||
},
|
||||
...current,
|
||||
].slice(0, 100),
|
||||
].filter((task, index) =>
|
||||
index < 100 || task.status === "running" || task.status === "waiting_approval",
|
||||
),
|
||||
);
|
||||
recordActivity({
|
||||
conversationId,
|
||||
@@ -7349,6 +7413,12 @@ function App(): React.JSX.Element {
|
||||
approvalId: string,
|
||||
decision: ApprovalDecision,
|
||||
): Promise<void> => {
|
||||
const pendingMessage = conversationsRef.current
|
||||
.find((conversation) => conversation.id === conversationId)
|
||||
?.messages.find((message) => message.id === messageId);
|
||||
if (pendingMessage?.approval?.id !== approvalId) return;
|
||||
const taskId = pendingMessage.task?.id ?? [...activeRuns.current.entries()]
|
||||
.find(([, run]) => run.conversationId === conversationId && run.messageId === messageId)?.[0];
|
||||
try {
|
||||
await window.goodbuddy.agent.respondApproval(approvalId, decision);
|
||||
const approved = decision !== "deny";
|
||||
@@ -7380,20 +7450,29 @@ function App(): React.JSX.Element {
|
||||
return record;
|
||||
});
|
||||
});
|
||||
updateMessage(conversationId, messageId, (message) => ({
|
||||
...message,
|
||||
approval: undefined,
|
||||
status:
|
||||
approved && message.task
|
||||
? undefined
|
||||
: approved
|
||||
? tRef.current("chat.approval.executing", {
|
||||
decision: decisionLabel,
|
||||
})
|
||||
: tRef.current("chat.approval.denied"),
|
||||
}));
|
||||
updateMessage(conversationId, messageId, (message) => {
|
||||
if (message.approval?.id !== approvalId) return message;
|
||||
if (!message.pendingQuestions?.length) {
|
||||
setAssistantTasks((current) => current.map((task) =>
|
||||
task.id === taskId && task.status === "waiting_approval"
|
||||
? { ...task, status: "running" }
|
||||
: task,
|
||||
));
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
approval: undefined,
|
||||
status: message.pendingQuestions?.length
|
||||
? message.status
|
||||
: approved && message.task
|
||||
? undefined
|
||||
: approved
|
||||
? tRef.current("chat.approval.executing", { decision: decisionLabel })
|
||||
: tRef.current("chat.approval.denied"),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
updateMessage(conversationId, messageId, (message) => ({
|
||||
updateMessage(conversationId, messageId, (message) => message.approval?.id !== approvalId ? message : ({
|
||||
...message,
|
||||
status: tRef.current("chat.approval.responseFailed"),
|
||||
}));
|
||||
@@ -7409,35 +7488,48 @@ function App(): React.JSX.Element {
|
||||
questionId: string,
|
||||
answers?: AgentQuestionAnswer[],
|
||||
): Promise<void> => {
|
||||
const question = conversationsRef.current
|
||||
const pendingMessage = conversationsRef.current
|
||||
.find((conversation) => conversation.id === conversationId)
|
||||
?.messages.find((message) => message.id === messageId)?.question;
|
||||
?.messages.find((message) => message.id === messageId);
|
||||
const question = pendingMessage?.pendingQuestions?.[0];
|
||||
if (!question || question.questionId !== questionId) {
|
||||
return;
|
||||
}
|
||||
const taskId = pendingMessage.task?.id ?? [...activeRuns.current.entries()]
|
||||
.find(([, run]) => run.conversationId === conversationId && run.messageId === messageId)?.[0];
|
||||
await window.goodbuddy.agent.respondQuestion(questionId, answers);
|
||||
updateMessage(conversationId, messageId, (message) => ({
|
||||
...message,
|
||||
answeredQuestions: [
|
||||
...(message.answeredQuestions ?? []),
|
||||
{
|
||||
questionId,
|
||||
skipped: answers === undefined,
|
||||
questions: question.questions.map((item, index) => ({
|
||||
...item,
|
||||
answer: answers?.[index],
|
||||
})),
|
||||
},
|
||||
],
|
||||
question: message.question?.questionId === questionId
|
||||
? undefined
|
||||
: message.question,
|
||||
status: message.question?.questionId === questionId && message.state === "streaming"
|
||||
? answers
|
||||
? tRef.current("chat.status.answerSubmitted")
|
||||
: tRef.current("chat.status.questionSkipped")
|
||||
: message.status,
|
||||
}));
|
||||
updateMessage(conversationId, messageId, (message) => {
|
||||
const pendingQuestions = message.pendingQuestions?.filter((item) => item.questionId !== questionId);
|
||||
const resumed = message.pendingQuestions?.some((item) => item.questionId === questionId) &&
|
||||
!pendingQuestions?.length && !message.approval && message.state === "streaming";
|
||||
if (resumed) {
|
||||
setAssistantTasks((current) => current.map((task) =>
|
||||
task.id === taskId && task.status === "waiting_approval"
|
||||
? { ...task, status: "running" }
|
||||
: task,
|
||||
));
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
answeredQuestions: [
|
||||
...(message.answeredQuestions ?? []).filter((item) => item.questionId !== questionId),
|
||||
{
|
||||
questionId,
|
||||
skipped: answers === undefined,
|
||||
questions: question.questions.map((item, index) => ({
|
||||
...item,
|
||||
answer: answers?.[index],
|
||||
})),
|
||||
},
|
||||
],
|
||||
pendingQuestions,
|
||||
status: resumed
|
||||
? answers
|
||||
? tRef.current("chat.status.answerSubmitted")
|
||||
: tRef.current("chat.status.questionSkipped")
|
||||
: message.status,
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateMessage],
|
||||
);
|
||||
@@ -7752,29 +7844,46 @@ function App(): React.JSX.Element {
|
||||
};
|
||||
|
||||
const openActivityConversation = (conversationId: string): void => {
|
||||
const conversation = conversations.find(
|
||||
(candidate) => candidate.id === conversationId,
|
||||
);
|
||||
if (!conversation) {
|
||||
notify({
|
||||
tone: "info",
|
||||
message: t("notices.conversationDeleted"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (conversation.projectId) {
|
||||
setActiveProjectId(conversation.projectId);
|
||||
}
|
||||
setActiveId(conversationId);
|
||||
setUnreadConversationIds((current) => {
|
||||
if (!current.has(conversationId)) {
|
||||
return current;
|
||||
const open = async (): Promise<void> => {
|
||||
let conversation = conversationsRef.current.find(
|
||||
(candidate) => candidate.id === conversationId,
|
||||
);
|
||||
if (!conversation) {
|
||||
try {
|
||||
const persisted = await window.goodbuddy.conversations.list();
|
||||
conversation = persisted.find((candidate) => candidate.id === conversationId);
|
||||
setConversations((current) => mergePersistedConversations(
|
||||
current, persisted, persistedLocalConversationsRef.current,
|
||||
));
|
||||
} catch {
|
||||
notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const next = new Set(current);
|
||||
next.delete(conversationId);
|
||||
return next;
|
||||
});
|
||||
setView("chat");
|
||||
if (!conversation) {
|
||||
notify({ tone: "info", message: t("notices.conversationDeleted") });
|
||||
return;
|
||||
}
|
||||
setActiveProjectId(conversation.projectId ?? "");
|
||||
setConversationActionsId("");
|
||||
setSelectedAssistantTaskId(undefined);
|
||||
setSearchQuery("");
|
||||
setActiveId(conversationId);
|
||||
setUnreadConversationIds((current) => {
|
||||
if (!current.has(conversationId)) return current;
|
||||
const next = new Set(current);
|
||||
next.delete(conversationId);
|
||||
return next;
|
||||
});
|
||||
commitView("chat");
|
||||
if (narrowWindow) closeNarrowSidebar();
|
||||
};
|
||||
const requestLeave = settingsLeaveRequesterRef.current;
|
||||
if (viewRef.current === "settings" && requestLeave) {
|
||||
requestLeave(() => { void open(); });
|
||||
} else {
|
||||
void open();
|
||||
}
|
||||
};
|
||||
|
||||
const openAssistantTask = (task: AssistantTask): void => {
|
||||
@@ -8192,7 +8301,7 @@ function App(): React.JSX.Element {
|
||||
id="primary-sidebar"
|
||||
inert={!sidebarOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (mainSidebarOpen) {
|
||||
if (mainSidebarOpen && event.currentTarget.contains(event.target as Node)) {
|
||||
trapTabFocus(event, sidebarRef.current);
|
||||
}
|
||||
}}
|
||||
@@ -8220,6 +8329,7 @@ function App(): React.JSX.Element {
|
||||
|
||||
<ProjectSwitcher
|
||||
activeProjectId={activeProjectId}
|
||||
activityByProjectId={projectActivity.byProjectId}
|
||||
recoveryByProjectId={projectRecoveryByProjectId}
|
||||
runtimeSettings={runtimeSettings}
|
||||
onArchive={archiveProject}
|
||||
@@ -8233,6 +8343,10 @@ function App(): React.JSX.Element {
|
||||
projects={projects}
|
||||
remoteProjectsEnabled={remoteProjectsEnabled}
|
||||
/>
|
||||
<ProjectActivity
|
||||
activities={projectActivity.activities}
|
||||
onOpenConversation={openActivityConversation}
|
||||
/>
|
||||
|
||||
{activeProject?.kind !== "channel" && (
|
||||
<button className="new-chat" onClick={newConversation} type="button">
|
||||
@@ -8383,6 +8497,7 @@ function App(): React.JSX.Element {
|
||||
conversation,
|
||||
t("conversation.defaultTitle"),
|
||||
);
|
||||
const activity = activityByConversationId.get(conversation.id);
|
||||
const branchSourceTitle = conversation.branch
|
||||
? (conversationTitles.get(
|
||||
conversation.branch.sourceConversationId,
|
||||
@@ -8497,12 +8612,16 @@ function App(): React.JSX.Element {
|
||||
</time>
|
||||
</small>
|
||||
</button>
|
||||
{activeConversationIds.has(conversation.id) && (
|
||||
{activity && (
|
||||
<span
|
||||
aria-label={t("conversation.active")}
|
||||
className="conversation-activity-indicator"
|
||||
aria-label={activity.status === "running"
|
||||
? t("conversation.active")
|
||||
: tWorkspace(`projectActivity.status.${activity.status}`)}
|
||||
className={activity.status === "running"
|
||||
? "conversation-activity-indicator"
|
||||
: "conversation-activity-indicator conversation-activity-indicator--attention"}
|
||||
role="status"
|
||||
title={t("conversation.active")}
|
||||
title={tWorkspace(`projectActivity.status.${activity.status}`)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
@@ -9527,9 +9646,13 @@ function App(): React.JSX.Element {
|
||||
{t("composer.knowledge.scope")}
|
||||
</strong>
|
||||
{knowledgeSnapshot.libraries.map(
|
||||
(library) => (
|
||||
(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,
|
||||
)}
|
||||
@@ -9553,7 +9676,7 @@ function App(): React.JSX.Element {
|
||||
/>
|
||||
<span>{library.name}</span>
|
||||
<small>
|
||||
{t(
|
||||
{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,
|
||||
@@ -9561,7 +9684,7 @@ function App(): React.JSX.Element {
|
||||
)}
|
||||
</small>
|
||||
</label>
|
||||
),
|
||||
)},
|
||||
)}
|
||||
<div className="knowledge-scope__retrieval-mode">
|
||||
<strong>
|
||||
@@ -10269,6 +10392,12 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
>
|
||||
<KnowledgeWorkspace
|
||||
externalInstances={externalInstances}
|
||||
notify={notify}
|
||||
onExternalChanged={async (snapshot, createdId) => {
|
||||
await refreshKnowledge(createdId ?? snapshot?.selectedLibraryId ?? knowledgeSnapshot.selectedLibraryId);
|
||||
if (createdId) setEnabledKnowledgeLibraryIds(current => [...new Set([...current, createdId])]);
|
||||
}}
|
||||
documents={knowledgeSnapshot.documents}
|
||||
evidence={knowledgeSnapshot.evidence}
|
||||
graphNodes={knowledgeSnapshot.graphNodes}
|
||||
@@ -10784,10 +10913,10 @@ function App(): React.JSX.Element {
|
||||
context={citationDialog.context}
|
||||
error={citationDialog.error}
|
||||
loading={citationDialog.loading}
|
||||
onClose={() => setCitationDialog(undefined)}
|
||||
onClose={() => { citationRequestRef.current++; setCitationDialog(undefined); }}
|
||||
onOpenSource={async () => {
|
||||
const { reference } = citationDialog;
|
||||
if (!reference.chunkId) {
|
||||
if (reference.external || !reference.chunkId || !reference.documentId) {
|
||||
throw new Error(t("chat.citations.contextUnavailable"));
|
||||
}
|
||||
await window.goodbuddy.knowledge.openReferenceSource({
|
||||
|
||||
@@ -225,10 +225,10 @@ describe('ChatTimeline', () => {
|
||||
expect(screen.getByRole('status', { name: '等待后续进展' })).toBeVisible()
|
||||
rerender(<ChatTimeline {...props} messages={[{
|
||||
...message, status: 'OpenCode 正在处理请求',
|
||||
question: {
|
||||
pendingQuestions: [{
|
||||
requestId: 'question-request', type: 'question', questionId: 'q-1',
|
||||
questions: [{ header: '确认', question: '继续吗?', options: [], custom: true, multiple: false }]
|
||||
}
|
||||
}]
|
||||
}]} />)
|
||||
expect(screen.getByRole('status', { name: '等待你的回答' })).toBeVisible()
|
||||
expect(screen.queryByText('OpenCode 正在处理请求')).not.toBeInTheDocument()
|
||||
|
||||
@@ -69,7 +69,7 @@ export type Message = {
|
||||
argumentSummary?: string
|
||||
allowPermanent?: boolean
|
||||
}
|
||||
question?: Extract<AgentEvent, { type: 'question' }>
|
||||
pendingQuestions?: Extract<AgentEvent, { type: 'question' }>[]
|
||||
answeredQuestions?: ConversationMessage['answeredQuestions']
|
||||
sources?: string[]
|
||||
sourceReferences?: KnowledgeSearchReference[]
|
||||
@@ -297,10 +297,12 @@ function ToolExecutionList({
|
||||
const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
renderHtml,
|
||||
subagent,
|
||||
questionFormId,
|
||||
onCopy
|
||||
}: {
|
||||
renderHtml: boolean
|
||||
subagent: SubagentActivity
|
||||
questionFormId?: string
|
||||
onCopy: CopyContent
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
@@ -371,11 +373,11 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
</small>
|
||||
</span>
|
||||
<span
|
||||
aria-label={t(`chat.subagents.states.${subagent.state}`)}
|
||||
aria-label={questionFormId ? t('chat.status.waitingForAnswer') : t(`chat.subagents.states.${subagent.state}`)}
|
||||
className={`subagent-status-card__status subagent-status-card__status--${subagent.state}`}
|
||||
>
|
||||
<StateIcon aria-hidden="true" size={14} />
|
||||
{t(`chat.subagents.states.${subagent.state}`)}
|
||||
{questionFormId ? t('chat.status.waitingForAnswer') : t(`chat.subagents.states.${subagent.state}`)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
@@ -385,6 +387,15 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
</summary>
|
||||
{expanded && (
|
||||
<div className="subagent-status-card__details">
|
||||
{questionFormId && (
|
||||
<button className="secondary-button" type="button" onClick={() => {
|
||||
const form = document.getElementById(questionFormId)
|
||||
form?.scrollIntoView({ block: 'center' })
|
||||
form?.focus({ preventScroll: true })
|
||||
}}>
|
||||
{t('workspace:question.locate')}
|
||||
</button>
|
||||
)}
|
||||
{progress && progress.length > 0 && (
|
||||
<section aria-label={t('chat.subagents.progress')}>
|
||||
<strong>{t('chat.subagents.progress')}</strong>
|
||||
@@ -446,10 +457,14 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
|
||||
function SubagentStatusList({
|
||||
renderHtml,
|
||||
subagents,
|
||||
pendingQuestions,
|
||||
questionFormId,
|
||||
onCopy
|
||||
}: {
|
||||
renderHtml: boolean
|
||||
subagents: SubagentActivity[]
|
||||
pendingQuestions?: Message['pendingQuestions']
|
||||
questionFormId?: string
|
||||
onCopy: CopyContent
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
@@ -464,6 +479,7 @@ function SubagentStatusList({
|
||||
key={subagent.childTaskId}
|
||||
renderHtml={renderHtml}
|
||||
subagent={subagent}
|
||||
questionFormId={pendingQuestions?.some((question) => question.childTaskId === subagent.childTaskId) ? questionFormId : undefined}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
))}
|
||||
@@ -573,6 +589,9 @@ function ChatMessageRowView({
|
||||
? t('chat.contextCompression.agentFailed')
|
||||
: t('chat.contextCompression.failed')
|
||||
|
||||
const question = message.pendingQuestions?.[0]
|
||||
const questionTask = question?.childTaskId ? subagentsById.get(question.childTaskId) : undefined
|
||||
const questionFormId = `agent-question-${message.id}`
|
||||
const activeTool = message.tools?.find((tool) =>
|
||||
tool.state === 'pending' || tool.state === 'running')
|
||||
const activeSubagent = message.subagents?.some((subagent) =>
|
||||
@@ -580,7 +599,7 @@ function ChatMessageRowView({
|
||||
const activeCompression = compressionMarkers.find((marker) =>
|
||||
marker.state === 'compressing')
|
||||
const statusText = message.role === 'assistant' && message.state === 'streaming'
|
||||
? message.question
|
||||
? question
|
||||
? t('chat.status.waitingForAnswer')
|
||||
: message.approval
|
||||
? t('chat.status.waitingForApproval')
|
||||
@@ -713,6 +732,8 @@ function ChatMessageRowView({
|
||||
<ToolExecutionList key={item.id} tools={item.tools} onCopy={onCopyMessage} />
|
||||
) : item.kind === 'subagents' ? (
|
||||
<SubagentStatusList
|
||||
pendingQuestions={message.pendingQuestions}
|
||||
questionFormId={questionFormId}
|
||||
onCopy={onCopyMessage}
|
||||
key={item.id}
|
||||
renderHtml={renderAssistantHtml}
|
||||
@@ -771,6 +792,8 @@ function ChatMessageRowView({
|
||||
)}
|
||||
{unorderedSubagents && unorderedSubagents.length > 0 && (
|
||||
<SubagentStatusList
|
||||
pendingQuestions={message.pendingQuestions}
|
||||
questionFormId={questionFormId}
|
||||
onCopy={onCopyMessage}
|
||||
renderHtml={renderAssistantHtml}
|
||||
subagents={unorderedSubagents}
|
||||
@@ -923,6 +946,7 @@ function ChatMessageRowView({
|
||||
<small>{reference.locator}</small>
|
||||
)}
|
||||
<p>{reference.snippet}</p>
|
||||
{reference.external && <small>{reference.libraryName} · {reference.external.provider} · {reference.sourceName}</small>}
|
||||
{reference.retrievalChannels && (
|
||||
<small>
|
||||
{t('chat.citations.retrieval')}
|
||||
@@ -956,16 +980,16 @@ function ChatMessageRowView({
|
||||
>
|
||||
{t('chat.citations.viewContext')}
|
||||
</button>
|
||||
<button
|
||||
{!reference.external && <button
|
||||
className="secondary-button"
|
||||
disabled={!reference.chunkId}
|
||||
disabled={!reference.chunkId || !reference.documentId}
|
||||
onClick={() =>
|
||||
void onOpenCitationSource(reference)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.citations.openSource')}
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
@@ -1061,25 +1085,29 @@ function ChatMessageRowView({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{message.question && (
|
||||
{question && (
|
||||
<AgentQuestionCard
|
||||
key={message.question.questionId}
|
||||
id={questionFormId}
|
||||
pendingCount={message.pendingQuestions?.length}
|
||||
taskTitle={questionTask?.reason ??
|
||||
(questionTask && 'expertName' in questionTask ? questionTask.expertName : undefined) ?? message.task?.title}
|
||||
key={question.questionId}
|
||||
onReject={() =>
|
||||
onRespondQuestion(
|
||||
conversationId,
|
||||
message.id,
|
||||
message.question!.questionId
|
||||
question.questionId
|
||||
)
|
||||
}
|
||||
onSubmit={(answers) =>
|
||||
onRespondQuestion(
|
||||
conversationId,
|
||||
message.id,
|
||||
message.question!.questionId,
|
||||
question.questionId,
|
||||
answers
|
||||
)
|
||||
}
|
||||
value={message.question}
|
||||
value={question}
|
||||
/>
|
||||
)}
|
||||
{message.answeredQuestions?.map((review) => (
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DesktopApi, KnowledgeLibrary, KnowledgeSnapshot } from '../../shared/contracts'
|
||||
import type { ExternalKnowledgeInstanceSummary, ExternalKnowledgeCatalogPage } from '../../shared/external-knowledge-contracts'
|
||||
import { ExternalBindingForm, ExternalInstanceManager, ExternalLibraryDetail, externalProviderDefaults } from './ExternalKnowledge'
|
||||
import i18n from './i18n'
|
||||
|
||||
const instance: ExternalKnowledgeInstanceSummary = { id: 'instance-1', name: 'Company knowledge', provider: 'dify', baseUrl: 'https://knowledge.example', enabled: true, credentialStatus: 'configured', probeStatus: 'healthy', bindingCount: 0 }
|
||||
const library: KnowledgeLibrary = { id: 'external-1', name: 'Handbook', description: '', storageMode: 'managed', graphEnabled: false, graphStrategy: 'model', sourceCount: 0, documentCount: 0, indexedDocumentCount: 0, external: { knowledgeBaseId: 'external-1', instanceId: instance.id, provider: 'dify', remoteKnowledgeBaseId: 'remote-1', remoteName: 'Handbook', commonConfig: { resultLimit: 6, requestTimeoutMs: 15000, maxSnippetCharacters: 4000 }, providerConfig: externalProviderDefaults('dify'), lastVerifiedAt: '2026-09-12' } }
|
||||
const snapshot: KnowledgeSnapshot = { libraries: [library], sources: [], documents: [], graphNodes: [], graphRelations: [], evidence: [] }
|
||||
const bridge = {
|
||||
externalCatalogList: vi.fn(async (): Promise<ExternalKnowledgeCatalogPage> => ({ items: [{ id: 'remote-1', name: 'Handbook' }], hasMore: false })),
|
||||
externalCatalogGet: vi.fn(async () => ({ id: 'remote-1', name: 'Handbook' })),
|
||||
externalRetrievalTest: vi.fn(async () => ({ results: [], durationMs: 10 })),
|
||||
externalBindingsCreate: vi.fn(async () => snapshot),
|
||||
externalBindingsUpdate: vi.fn(async () => snapshot),
|
||||
externalInstancesSave: vi.fn(async () => instance),
|
||||
externalInstancesTest: vi.fn(async () => instance),
|
||||
externalInstancesDelete: vi.fn(async () => undefined),
|
||||
deleteLibrary: vi.fn<(id: string) => Promise<KnowledgeSnapshot>>(async () => snapshot)
|
||||
}
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
await i18n.changeLanguage('en-US')
|
||||
window.goodbuddy = { knowledge: bridge } as unknown as DesktopApi
|
||||
})
|
||||
afterEach(() => { cleanup(); void i18n.changeLanguage('zh-CN') })
|
||||
function selectInstance(index = 0): void {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'External instance' }))
|
||||
fireEvent.click(screen.getAllByRole('menuitemradio')[index]!)
|
||||
}
|
||||
|
||||
describe('external knowledge workflows', () => {
|
||||
it('navigates the rich instance menu, skips disabled items and restores focus', async () => {
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance, { ...instance, id: 'disabled', name: 'Disabled service', enabled: false }, { ...instance, id: 'other', name: 'Other service' }]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
const trigger = screen.getByRole('button', { name: 'External instance' })
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
const menu = screen.getByRole('menu')
|
||||
await waitFor(() => expect(screen.getByRole('menuitemradio', { name: /Company knowledge/ })).toHaveFocus())
|
||||
expect(screen.getByRole('menuitemradio', { name: /Disabled service/ })).toBeDisabled()
|
||||
fireEvent.keyDown(menu, { key: 'ArrowDown' })
|
||||
expect(screen.getByRole('menuitemradio', { name: /Other service/ })).toHaveFocus()
|
||||
fireEvent.keyDown(menu, { key: 'Home' })
|
||||
expect(screen.getByRole('menuitemradio', { name: /Company knowledge/ })).toHaveFocus()
|
||||
fireEvent.keyDown(menu, { key: 'End' })
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /Other service/ }))
|
||||
expect(trigger).toHaveFocus()
|
||||
fireEvent.click(trigger)
|
||||
await waitFor(() => expect(screen.getByRole('menuitemradio', { name: /Other service/ })).toHaveFocus())
|
||||
expect(screen.getByRole('menuitemradio', { name: /Other service/ })).toHaveAttribute('aria-checked', 'true')
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers clear search and manual entry for distinct empty catalog states', async () => {
|
||||
bridge.externalCatalogList.mockResolvedValueOnce({ items: [], hasMore: false }).mockResolvedValueOnce({ items: [], hasMore: false })
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
selectInstance()
|
||||
expect((await screen.findByText('No accessible libraries in this instance')).closest('.empty-state')).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('Search remote libraries'), { target: { value: 'missing' } })
|
||||
expect((await screen.findByText('No matching results')).closest('.empty-state')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
||||
expect(screen.getByLabelText('Search remote libraries')).toHaveValue('')
|
||||
await screen.findByRole('button', { name: /Handbook/ })
|
||||
})
|
||||
|
||||
it('can dismiss an instance menu when every instance is disabled', () => {
|
||||
render(<ExternalBindingForm provider="dify" instances={[{ ...instance, enabled: false }]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
const trigger = screen.getByRole('button', { name: 'External instance' })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByRole('menuitemradio')).toBeDisabled()
|
||||
fireEvent.keyDown(trigger, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('labels saving separately from testing and formats verification timestamps', async () => {
|
||||
let finish!: (value: KnowledgeSnapshot) => void
|
||||
bridge.externalBindingsUpdate.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
render(<ExternalLibraryDetail library={library} instances={[instance]} libraries={[library]} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} onUseInChat={vi.fn()} />)
|
||||
expect(document.querySelector('time')).toHaveAttribute('datetime', '2026-09-12')
|
||||
expect(document.querySelector('time')).not.toHaveTextContent('2026-09-12')
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Retrieval settings' }))
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Save retrieval settings' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save retrieval settings' }))
|
||||
expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Test retrieval' })).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: 'Testing…' })).not.toBeInTheDocument()
|
||||
await act(async () => finish(snapshot))
|
||||
})
|
||||
|
||||
it('keeps external tabs and filters from shrinking in the scrolling workspace', () => {
|
||||
const css = readFileSync('src/renderer/src/external-knowledge.css', 'utf8')
|
||||
expect(css).toMatch(/\.external-knowledge > \.page-tabs\s*\{[^}]*flex: 0 0 auto/)
|
||||
expect(css).toMatch(/\.external-knowledge__filters\s*\{[^}]*flex: 0 0 auto/)
|
||||
expect(css).toMatch(/\.external-knowledge-modal__surface\s*\{[^}]*width: min\(560px, 100%\)/)
|
||||
expect(css).toMatch(/\.external-knowledge__footer\s*\{[^}]*position: sticky/)
|
||||
expect(css).toMatch(/\.external-knowledge \.external-knowledge\s*\{[^}]*overflow: visible/)
|
||||
expect(css).not.toMatch(/\.external-knowledge__catalog\s*\{[^}]*overflow: auto/)
|
||||
expect(css).toContain(".external-knowledge__target:hover:not(:disabled):not([aria-pressed='true'])")
|
||||
expect(css).toMatch(/\.external-knowledge__fieldset :is\(input, select, textarea, summary\)\s*\{[^}]*scroll-margin-block:/)
|
||||
})
|
||||
|
||||
it('uses shared fields in the portal with one create credential and an adjacent visibility button', async () => {
|
||||
const view = render(<ExternalInstanceManager instances={[]} libraries={[]} onClose={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add instance' }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'Add instance' })
|
||||
expect(view.container).not.toContainElement(dialog)
|
||||
expect(screen.getByLabelText('Name').closest('label')).toHaveClass('field')
|
||||
expect(screen.getByLabelText('Provider').closest('label')).toHaveClass('field')
|
||||
expect(screen.queryByLabelText('Saved credential')).not.toBeInTheDocument()
|
||||
const key = screen.getByLabelText('API Key')
|
||||
expect(key).toHaveAttribute('type', 'password')
|
||||
const reveal = screen.getByRole('button', { name: 'Show credential' })
|
||||
expect(key.parentElement).toContainElement(reveal)
|
||||
fireEvent.click(reveal)
|
||||
expect(key).toHaveAttribute('type', 'text')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide credential' }))
|
||||
expect(key).toHaveAttribute('type', 'password')
|
||||
const enabled = screen.getByRole('switch')
|
||||
expect(enabled.previousElementSibling).toHaveTextContent('Enable instance')
|
||||
fireEvent.change(screen.getByLabelText('Service URL'), { target: { value: 'https://knowledge.example' } })
|
||||
expect(screen.queryByText(/without transport encryption/)).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('Service URL'), { target: { value: 'http://knowledge.example' } })
|
||||
expect(screen.getByLabelText('Service URL')).toHaveAccessibleDescription('This HTTP address sends credentials and queries without transport encryption.')
|
||||
expect(screen.getByText('How to test the connection').closest('details')).not.toHaveAttribute('open')
|
||||
})
|
||||
|
||||
it('blocks duplicate manual IDs before sending a retrieval request', async () => {
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[library]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
selectInstance()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enter library ID manually' }))
|
||||
fireEvent.change(screen.getByLabelText('Remote library ID'), { target: { value: ' remote-1 ' } })
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Already added')
|
||||
expect(screen.getByRole('button', { name: 'Test retrieval' })).toBeDisabled()
|
||||
expect(bridge.externalRetrievalTest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards earlier success when a retest fails or the instance changes', async () => {
|
||||
const props = { provider: 'dify' as const, instances: [instance], libraries: [], onCancel: vi.fn(), onChanged: vi.fn(), notify: vi.fn(), onManage: vi.fn() }
|
||||
const view = render(<ExternalBindingForm {...props} />)
|
||||
selectInstance()
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Handbook/ }))
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add library' })).toBeEnabled())
|
||||
bridge.externalRetrievalTest.mockRejectedValueOnce(new Error('Remote timeout'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Remote timeout')
|
||||
expect(screen.getByRole('button', { name: 'Add library' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add library' })).toBeEnabled())
|
||||
view.rerender(<ExternalBindingForm {...props} instances={[{ ...instance, baseUrl: 'https://other.example' }]} />)
|
||||
expect(screen.getByRole('button', { name: 'Add library' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('rebinds a manual target without retaining the previous remote name', async () => {
|
||||
bridge.externalCatalogGet.mockRejectedValueOnce(new Error('No detail endpoint')).mockRejectedValueOnce(new Error('No detail endpoint'))
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[library]} library={library} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enter library ID manually' }))
|
||||
fireEvent.change(screen.getByLabelText('Remote library ID'), { target: { value: 'new-target' } })
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'New target name' } })
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await screen.findByText(/Retrieval succeeded with 0 snippets/)
|
||||
expect(screen.getByRole('button', { name: 'Save retrieval settings' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save retrieval settings' }))
|
||||
await waitFor(() => expect(bridge.externalBindingsUpdate).toHaveBeenCalledWith(expect.objectContaining({ knowledgeBaseId: library.id, remoteKnowledgeBaseId: 'new-target', remoteName: 'New target name' })))
|
||||
})
|
||||
|
||||
it('allows disabling saved RAGFlow capabilities that are no longer available', async () => {
|
||||
const ragLibrary = { ...library, external: { ...library.external!, provider: 'ragflow' as const, providerConfig: { provider: 'ragflow' as const, similarityThreshold: 0.2, vectorSimilarityWeight: 0.3, knnTopK: 1024, useKg: true, includeKnowledgeCompilation: true } } }
|
||||
render(<ExternalBindingForm provider="ragflow" instances={[{ ...instance, provider: 'ragflow' }]} libraries={[ragLibrary]} library={ragLibrary} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Graph retrieval' }))
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Include Knowledge Compilation' }))
|
||||
expect(screen.getByRole('switch', { name: 'Graph retrieval' })).not.toBeChecked()
|
||||
expect(screen.getByRole('switch', { name: 'Graph retrieval' })).toBeDisabled()
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await waitFor(() => expect(bridge.externalRetrievalTest).toHaveBeenCalledWith(expect.objectContaining({ providerConfig: expect.objectContaining({ useKg: false, includeKnowledgeCompilation: false }) })))
|
||||
})
|
||||
|
||||
it('clears incomplete hidden Dify reranking fields when reranking is disabled', async () => {
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[library]} library={library} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Use dataset defaults' }))
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Enable Rerank' }))
|
||||
fireEvent.change(screen.getByLabelText('Reranking provider'), { target: { value: 'provider' } })
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Enable Rerank' }))
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
expect(screen.getByRole('button', { name: 'Test retrieval' })).toBeEnabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await waitFor(() => expect(bridge.externalRetrievalTest).toHaveBeenCalledWith(expect.objectContaining({ providerConfig: expect.objectContaining({ retrievalModel: expect.objectContaining({ reranking_enable: false, reranking_model: undefined }) }) })))
|
||||
})
|
||||
|
||||
it('requires explicit credential clearing and retains the saved credential by default', async () => {
|
||||
render(<ExternalInstanceManager instances={[instance]} libraries={[library]} onClose={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByLabelText('Saved credential')).toHaveValue('keep')
|
||||
expect(screen.queryByLabelText('API Key')).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('Saved credential'), { target: { value: 'replace' } })
|
||||
expect(screen.getByLabelText('API Key')).toHaveValue('')
|
||||
fireEvent.change(screen.getByLabelText('Saved credential'), { target: { value: 'clear' } })
|
||||
expect(screen.getByRole('button', { name: 'Save instance' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save instance' }))
|
||||
await waitFor(() => expect(bridge.externalInstancesSave).toHaveBeenCalledWith(expect.objectContaining({ id: instance.id, credential: { action: 'clear' } })))
|
||||
})
|
||||
|
||||
it('accepts the FastGPT API token range and restores defaults only in the draft', async () => {
|
||||
const fastLibrary = { ...library, external: { ...library.external!, provider: 'fastgpt' as const, providerConfig: externalProviderDefaults('fastgpt') } }
|
||||
render(<ExternalBindingForm provider="fastgpt" instances={[{ ...instance, provider: 'fastgpt' }]} libraries={[fastLibrary]} library={fastLibrary} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
fireEvent.change(screen.getByLabelText('Token limit'), { target: { value: '30000' } })
|
||||
expect(screen.getByLabelText('Token limit')).toBeValid()
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await screen.findByText(/Retrieval succeeded with 0 snippets/)
|
||||
expect(bridge.externalRetrievalTest).toHaveBeenCalledWith(expect.objectContaining({ providerConfig: expect.objectContaining({ tokenLimit: 30000 }) }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Restore defaults' }))
|
||||
expect(screen.getByLabelText('Token limit')).toHaveValue(5000)
|
||||
expect(screen.getByRole('button', { name: 'Save retrieval settings' })).toBeDisabled()
|
||||
expect(bridge.externalBindingsUpdate).not.toHaveBeenCalled()
|
||||
expect(bridge.externalInstancesSave).not.toHaveBeenCalled()
|
||||
expect(screen.getByText(/Settings are saved only in GoodBuddy/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('refreshes completed removals after a later binding deletion fails', async () => {
|
||||
const onChanged = vi.fn()
|
||||
bridge.deleteLibrary.mockResolvedValueOnce(snapshot).mockRejectedValueOnce(new Error('Binding removal failed'))
|
||||
render(<ExternalInstanceManager instances={[{ ...instance, bindingCount: 2 }]} libraries={[library, { ...library, id: 'external-2', name: 'Second binding' }]} onClose={vi.fn()} onChanged={onChanged} notify={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
expect(screen.getByRole('dialog')).toHaveAccessibleName('Delete instance “Company knowledge”?')
|
||||
expect(screen.getByRole('button', { name: 'Delete instance and local bindings' })).toHaveClass('danger-button')
|
||||
expect(screen.getByText('Handbook')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second binding')).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('dialog')).getAllByRole('button', { name: 'Cancel' }).find(button => button.textContent === 'Cancel')).toHaveFocus()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete instance and local bindings' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Binding removal failed')
|
||||
expect(onChanged).toHaveBeenCalledOnce()
|
||||
expect(bridge.externalInstancesDelete).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'Delete instance and local bindings' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('prevents retrieval through disabled instances', () => {
|
||||
render(<ExternalLibraryDetail library={library} libraries={[library]} instances={[{ ...instance, enabled: false }]} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} onUseInChat={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Test retrieval' }))
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
|
||||
expect(screen.getByRole('button', { name: 'Test retrieval' })).toBeDisabled()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Instance disabled')
|
||||
})
|
||||
|
||||
it.each(['dify', 'fastgpt', 'ragflow'] as const)('creates a %s binding after a zero-result retrieval', async provider => {
|
||||
const onChanged = vi.fn()
|
||||
render(<ExternalBindingForm provider={provider} instances={[{ ...instance, provider }]} libraries={[]} onCancel={vi.fn()} onChanged={onChanged} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
for (const control of document.querySelectorAll('input:not([type="checkbox"]), select, textarea')) {
|
||||
expect(control.closest('label')).toHaveClass('field')
|
||||
}
|
||||
for (const toggle of screen.getAllByRole('switch')) {
|
||||
expect(toggle.previousElementSibling?.tagName).toBe('SPAN')
|
||||
expect(toggle.closest('label')).toHaveClass('toggle-row')
|
||||
}
|
||||
selectInstance()
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Handbook/ }))
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'network policy' } })
|
||||
expect(screen.getByRole('button', { name: 'Add library' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add library' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add library' }))
|
||||
await waitFor(() => expect(bridge.externalBindingsCreate).toHaveBeenCalledWith(expect.objectContaining({ instanceId: instance.id, remoteKnowledgeBaseId: 'remote-1', testQuery: 'network policy', providerConfig: expect.objectContaining({ provider }) })))
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalledWith(snapshot, library.id))
|
||||
})
|
||||
|
||||
it('retains a manual target and query after retrieval fails', async () => {
|
||||
bridge.externalRetrievalTest.mockRejectedValueOnce(new Error('Authentication failed'))
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
selectInstance()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enter library ID manually' }))
|
||||
fireEvent.change(screen.getByLabelText('Remote library ID'), { target: { value: 'manual-id' } })
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'keep this query' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Authentication failed')
|
||||
expect(screen.getByLabelText('Remote library ID')).toHaveValue('manual-id')
|
||||
expect(screen.getByLabelText('Test query')).toHaveValue('keep this query')
|
||||
})
|
||||
|
||||
it('ignores an old catalog when the instance changes', async () => {
|
||||
let resolve!: (page: ExternalKnowledgeCatalogPage) => void
|
||||
bridge.externalCatalogList.mockImplementationOnce(() => new Promise(done => { resolve = done }))
|
||||
render(<ExternalBindingForm provider="dify" instances={[instance, { ...instance, id: 'instance-2' }]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
|
||||
selectInstance()
|
||||
await waitFor(() => expect(bridge.externalCatalogList).toHaveBeenCalledOnce())
|
||||
selectInstance(1)
|
||||
await screen.findByRole('button', { name: /Handbook/ })
|
||||
await act(async () => resolve({ items: [{ id: 'old', name: 'Stale catalog' }], hasMore: false }))
|
||||
expect(screen.queryByText('Stale catalog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tests saved configuration without exposing save or target editing in the test tab', async () => {
|
||||
render(<ExternalLibraryDetail library={library} libraries={[library]} instances={[instance]} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} onUseInChat={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Test retrieval' }))
|
||||
expect(screen.queryByRole('button', { name: 'Save retrieval settings' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('External instance')).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'saved config query' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
|
||||
await screen.findByText(/Retrieval succeeded with 0 snippets/)
|
||||
expect(bridge.externalBindingsUpdate).not.toHaveBeenCalled()
|
||||
expect(bridge.externalRetrievalTest).toHaveBeenCalledWith(expect.objectContaining({ commonConfig: library.external!.commonConfig, providerConfig: library.external!.providerConfig }))
|
||||
})
|
||||
|
||||
it('saves an instance without an all-address consent gate and preserves failed drafts', async () => {
|
||||
bridge.externalInstancesSave.mockRejectedValueOnce(new Error('Save failed'))
|
||||
render(<ExternalInstanceManager instances={[]} libraries={[]} onClose={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add instance' }))
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Company' } })
|
||||
fireEvent.change(screen.getByLabelText('Service URL'), { target: { value: 'https://knowledge.example' } })
|
||||
fireEvent.change(screen.getAllByLabelText('API Key').find(element => element.tagName === 'INPUT')!, { target: { value: 'test-key' } })
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save instance' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Save instance' })).toBeEnabled())
|
||||
expect(screen.getByLabelText('Service URL')).toHaveValue('https://knowledge.example')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,467 @@
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronDown, Database, Eye, EyeOff, FolderOpen, RefreshCw, X } from 'lucide-react'
|
||||
import type { KnowledgeLibrary, KnowledgeSnapshot } from '../../shared/contracts'
|
||||
import {
|
||||
externalKnowledgeBindingTestInputSchema,
|
||||
externalKnowledgeInstanceSaveInputSchema,
|
||||
type ExternalKnowledgeProvider,
|
||||
type ExternalKnowledgeProviderConfig,
|
||||
type ExternalKnowledgeCommonConfig,
|
||||
type ExternalKnowledgeCatalogItem,
|
||||
type ExternalKnowledgeCatalogPage,
|
||||
type ExternalKnowledgeInstanceSaveInput,
|
||||
type ExternalKnowledgeInstanceSummary,
|
||||
type ExternalKnowledgeTestResult
|
||||
} from '../../shared/external-knowledge-contracts'
|
||||
import { EmptyState, PageTabs, ScopeBadge, SegmentedControl } from './WorkspacePrimitives'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import './external-knowledge.css'
|
||||
|
||||
export const externalProviderNames = { dify: 'Dify', fastgpt: 'FastGPT', ragflow: 'RAGFlow' }
|
||||
export function externalLibraryStatus(library: KnowledgeLibrary, instances: ExternalKnowledgeInstanceSummary[]): 'ready' | 'untested' | 'instance-disabled' | 'credential-error' | 'temporarily-unavailable' {
|
||||
const instance = instances.find(item => item.id === library.external?.instanceId)
|
||||
if (!instance) return 'temporarily-unavailable'
|
||||
if (!instance.enabled) return 'instance-disabled'
|
||||
if (instance.credentialStatus !== 'configured' || instance.probeStatus === 'auth-failed') return 'credential-error'
|
||||
if (['failed', 'unreachable'].includes(instance.probeStatus)) return 'temporarily-unavailable'
|
||||
return library.external?.lastVerifiedAt ? 'ready' : 'untested'
|
||||
}
|
||||
type Notify = (input: AppNotificationInput) => void
|
||||
type Changed = (snapshot?: KnowledgeSnapshot, createdId?: string) => void | Promise<void>
|
||||
const commonDefaults: ExternalKnowledgeCommonConfig = { resultLimit: 6, requestTimeoutMs: 15000, maxSnippetCharacters: 4000 }
|
||||
export function externalProviderDefaults(provider: ExternalKnowledgeProvider): ExternalKnowledgeProviderConfig {
|
||||
if (provider === 'dify') return { provider, useDatasetDefaults: true }
|
||||
if (provider === 'fastgpt') return { provider, searchMode: 'embedding', tokenLimit: 5000, similarity: 0, usingRerank: false }
|
||||
return { provider, similarityThreshold: 0.2, vectorSimilarityWeight: 0.3, knnTopK: 1024, useKg: false, includeKnowledgeCompilation: false }
|
||||
}
|
||||
const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error)
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }): React.JSX.Element {
|
||||
return <label className="field"><span>{label}</span>{children}</label>
|
||||
}
|
||||
function Switch({ label, checked, disabled, onChange }: { label: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void }): React.JSX.Element {
|
||||
return <label className="toggle-row"><span>{label}</span><input type="checkbox" role="switch" checked={checked} disabled={disabled} onChange={event => onChange(event.currentTarget.checked)} /></label>
|
||||
}
|
||||
|
||||
function InstanceSelect({ instances, value, onChange }: { instances: ExternalKnowledgeInstanceSummary[]; value: string; onChange: (value: string) => void }): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [open, setOpen] = useState(false)
|
||||
const root = useRef<HTMLDivElement>(null)
|
||||
const trigger = useRef<HTMLButtonElement>(null)
|
||||
const menu = useRef<HTMLDivElement>(null)
|
||||
const id = useId()
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const items = Array.from(menu.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? [])
|
||||
const selected = items.find(item => item.getAttribute('aria-checked') === 'true') ?? items[0]
|
||||
items.forEach(item => { item.tabIndex = item === selected ? 0 : -1 })
|
||||
selected?.focus()
|
||||
})
|
||||
const dismiss = (event: Event): void => { if (event.target instanceof Node && !root.current?.contains(event.target)) setOpen(false) }
|
||||
document.addEventListener('pointerdown', dismiss)
|
||||
document.addEventListener('focusin', dismiss)
|
||||
return () => { cancelAnimationFrame(frame); document.removeEventListener('pointerdown', dismiss); document.removeEventListener('focusin', dismiss) }
|
||||
}, [open])
|
||||
return <div className="field external-knowledge__instance-select" ref={root}>
|
||||
<span id={`${id}-label`}>{t('external.instance')}</span>
|
||||
<button type="button" className="model-button" ref={trigger} aria-labelledby={`${id}-label`} aria-describedby={`${id}-value`} aria-expanded={open} aria-haspopup="menu" aria-controls={open ? id : undefined} onClick={() => setOpen(!open)} onKeyDown={event => {
|
||||
if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); setOpen(true) }
|
||||
if (event.key === 'Escape' && open) { event.preventDefault(); event.stopPropagation(); setOpen(false) }
|
||||
}}><span className="model-button__label" id={`${id}-value`}>{instances.find(item => item.id === value)?.name ?? t('external.selectInstance')}</span><ChevronDown size={14} aria-hidden="true" /></button>
|
||||
{open && <div className="runtime-picker__menu composer-picker__menu" role="menu" aria-labelledby={`${id}-label`} id={id} ref={menu} onKeyDown={event => {
|
||||
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'))
|
||||
const index = items.indexOf(document.activeElement as HTMLButtonElement)
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : event.key === 'ArrowDown' ? (index + 1) % items.length : event.key === 'ArrowUp' ? (index - 1 + items.length) % items.length : undefined
|
||||
if (next !== undefined && items[next]) { event.preventDefault(); items.forEach((item, i) => { item.tabIndex = i === next ? 0 : -1 }); items[next].focus() }
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); setOpen(false); trigger.current?.focus() }
|
||||
}}>
|
||||
{!instances.length && <p className="external-knowledge__help">{t('external.noInstancesHelp')}</p>}
|
||||
{instances.map(item => <button type="button" role="menuitemradio" key={item.id} aria-checked={item.id === value} disabled={!item.enabled || item.credentialStatus !== 'configured'} tabIndex={-1} onClick={() => { onChange(item.id); setOpen(false); trigger.current?.focus() }}>
|
||||
<span>{item.name}</span><small>{new URL(item.baseUrl).origin} · {item.enabled ? t(`external.credentials.${item.credentialStatus}`) : t('external.states.instance-disabled')}</small>
|
||||
</button>)}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function ExternalTime({ value }: { value: string }): React.JSX.Element {
|
||||
const { i18n, t } = useTranslation('knowledge')
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return <>{t('format.unknownTime')}</>
|
||||
const label = date.toLocaleString(i18n.resolvedLanguage, { dateStyle: 'medium', timeStyle: 'short' })
|
||||
return <time dateTime={value} title={date.toLocaleString(i18n.resolvedLanguage)}>{label}</time>
|
||||
}
|
||||
|
||||
export function ExternalConfig({ common, config, detail, onCommon, onConfig }: {
|
||||
common: ExternalKnowledgeCommonConfig; config: ExternalKnowledgeProviderConfig; detail?: ExternalKnowledgeCatalogItem
|
||||
onCommon: (value: ExternalKnowledgeCommonConfig) => void; onConfig: (value: ExternalKnowledgeProviderConfig) => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const number = (label: string, value: number, min: number, max: number, change: (value: number) => void, step = 1): ReactNode =>
|
||||
<Field label={label}><input type="number" required min={min} max={max} step={step} value={value} onChange={event => change(event.currentTarget.valueAsNumber)} /></Field>
|
||||
const text = (label: string, value: string, change: (value: string) => void): ReactNode =>
|
||||
<Field label={label}><input required value={value} onChange={event => change(event.currentTarget.value)} /></Field>
|
||||
return <section className="external-knowledge__fields">
|
||||
<fieldset className="external-knowledge__group">
|
||||
<legend>{t('external.common')}</legend>
|
||||
{number(t('external.resultLimit'), common.resultLimit, 1, 20, value => onCommon({ ...common, resultLimit: value }))}
|
||||
{number(t('external.timeout'), common.requestTimeoutMs, 1000, 60000, value => onCommon({ ...common, requestTimeoutMs: value }), 1000)}
|
||||
{number(t('external.snippetLimit'), common.maxSnippetCharacters, 100, 8000, value => onCommon({ ...common, maxSnippetCharacters: value }))}
|
||||
</fieldset>
|
||||
<fieldset className="external-knowledge__group">
|
||||
<legend>{externalProviderNames[config.provider]} · {t('external.config')}</legend>
|
||||
{config.provider === 'dify' && <>
|
||||
<Switch label={t('external.datasetDefaults')} checked={config.useDatasetDefaults} onChange={value => onConfig(value ? { provider: 'dify', useDatasetDefaults: true } : { provider: 'dify', useDatasetDefaults: false, retrievalModel: { search_method: 'semantic_search', top_k: 6, reranking_enable: false, score_threshold_enabled: false } })} />
|
||||
{config.useDatasetDefaults ? <p className="knowledge-muted">{t('external.datasetDefaultsHelp')}</p> : (() => {
|
||||
const model = config.retrievalModel
|
||||
const update = (value: Partial<typeof model>): void => onConfig({ ...config, retrievalModel: { ...model, ...value } })
|
||||
return <>
|
||||
<Field label={t('external.searchMethod')}><select value={model.search_method} onChange={event => update({ search_method: event.currentTarget.value as typeof model.search_method })}>{['keyword_search', 'semantic_search', 'full_text_search', 'hybrid_search'].map(value => <option key={value}>{value}</option>)}</select></Field>
|
||||
{number(t('external.topK'), model.top_k, 1, 20, value => update({ top_k: value }))}
|
||||
<Switch label={t('external.thresholdEnabled')} checked={model.score_threshold_enabled} onChange={value => update({ score_threshold_enabled: value, score_threshold: model.score_threshold ?? 0.5 })} />
|
||||
{model.score_threshold_enabled && number(t('external.threshold'), model.score_threshold ?? 0.5, 0, 1, value => update({ score_threshold: value }), 0.01)}
|
||||
<Switch label={t('external.rerank')} checked={model.reranking_enable} onChange={value => update(value ? { reranking_enable: true, reranking_mode: model.reranking_mode ?? 'reranking_model' } : { reranking_enable: false, reranking_mode: undefined, reranking_model: undefined, weights: undefined })} />
|
||||
{model.reranking_enable && <>
|
||||
<Field label={t('external.rerankMode')}><select value={model.reranking_mode ?? 'reranking_model'} onChange={event => update({ reranking_mode: event.currentTarget.value as 'weighted_score' | 'reranking_model', reranking_model: undefined, weights: undefined })}><option value="reranking_model">{t('external.rerankModel')}</option><option value="weighted_score">{t('external.weightedScore')}</option></select></Field>
|
||||
{model.reranking_mode !== 'weighted_score' ? <>
|
||||
{text(t('external.rerankProvider'), model.reranking_model?.reranking_provider_name ?? '', value => update({ reranking_model: { reranking_provider_name: value, reranking_model_name: model.reranking_model?.reranking_model_name ?? '' } }))}
|
||||
{text(t('external.rerankModel'), model.reranking_model?.reranking_model_name ?? '', value => update({ reranking_model: { reranking_provider_name: model.reranking_model?.reranking_provider_name ?? '', reranking_model_name: value } }))}
|
||||
</> : (() => {
|
||||
const weights = model.weights ?? { weight_type: 'customized' as const, vector_setting: { vector_weight: 0.5, embedding_provider_name: '', embedding_model_name: '' }, keyword_setting: { keyword_weight: 0.5 } }
|
||||
return <>
|
||||
{number(t('external.vectorWeight'), weights.vector_setting.vector_weight, 0, 1, value => update({ weights: { ...weights, vector_setting: { ...weights.vector_setting, vector_weight: value }, keyword_setting: { keyword_weight: 1 - value } } }), 0.01)}
|
||||
<p>{t('external.keywordWeight')}: {Number(weights.keyword_setting.keyword_weight.toFixed(2))}</p>
|
||||
{text(t('external.embeddingProvider'), weights.vector_setting.embedding_provider_name, value => update({ weights: { ...weights, vector_setting: { ...weights.vector_setting, embedding_provider_name: value } } }))}
|
||||
{text(t('external.embeddingModel'), weights.vector_setting.embedding_model_name, value => update({ weights: { ...weights, vector_setting: { ...weights.vector_setting, embedding_model_name: value } } }))}
|
||||
</>
|
||||
})()}
|
||||
</>}
|
||||
</>
|
||||
})()}
|
||||
</>}
|
||||
{config.provider === 'fastgpt' && <>
|
||||
<Field label={t('external.searchMethod')}><select value={config.searchMode} onChange={event => onConfig({ ...config, searchMode: event.currentTarget.value as typeof config.searchMode })}>{['embedding', 'fullTextRecall', 'mixedRecall'].map(value => <option key={value}>{value}</option>)}</select></Field>
|
||||
{number(t('external.tokenLimit'), config.tokenLimit, 1, 30000, value => onConfig({ ...config, tokenLimit: value }))}
|
||||
{number(t('external.threshold'), config.similarity, 0, 1, value => onConfig({ ...config, similarity: value }), 0.01)}
|
||||
<Switch label={t('external.rerank')} checked={config.usingRerank} onChange={value => onConfig({ ...config, usingRerank: value })} />
|
||||
</>}
|
||||
{config.provider === 'ragflow' && <>
|
||||
{number(t('external.threshold'), config.similarityThreshold, 0, 1, value => onConfig({ ...config, similarityThreshold: value }), 0.01)}
|
||||
{number(t('external.vectorWeight'), config.vectorSimilarityWeight, 0, 1, value => onConfig({ ...config, vectorSimilarityWeight: value }), 0.01)}
|
||||
{number(t('external.knnTopK'), config.knnTopK, 1, 2048, value => onConfig({ ...config, knnTopK: value }))}
|
||||
<Switch label={t('external.graph')} checked={config.useKg} disabled={!config.useKg && !detail?.graphEnabled} onChange={value => onConfig({ ...config, useKg: value })} />
|
||||
<p className="knowledge-muted">{t(detail?.graphEnabled ? 'external.graphHelp' : 'external.graphUnknown')}</p>
|
||||
<Switch label={t('external.compilation')} checked={config.includeKnowledgeCompilation} disabled={!config.includeKnowledgeCompilation && !detail?.knowledgeCompilationEnabled} onChange={value => onConfig({ ...config, includeKnowledgeCompilation: value })} />
|
||||
{!detail?.knowledgeCompilationEnabled && <p className="knowledge-muted">{t('external.compilationUnavailable')}</p>}
|
||||
</>}
|
||||
</fieldset>
|
||||
<p className="external-knowledge__help">{t('external.localOnly')}</p>
|
||||
<button className="secondary-button" type="button" onClick={() => { onCommon(commonDefaults); onConfig(externalProviderDefaults(config.provider)) }}>{t('external.defaults')}</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
function TestResults({ result }: { result: ExternalKnowledgeTestResult }): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
return <section className="external-knowledge__results" aria-live="polite">
|
||||
<p>{t('external.tested', { count: result.results.length })} · {t('external.elapsed', { ms: result.durationMs })}</p>
|
||||
<ol>{result.results.map((item, index) => <li key={`${item.remoteChunkId ?? item.remoteDocumentId ?? index}:${index}`}>
|
||||
<strong>{item.documentTitle}</strong><small>{item.sourceDisplayName} {item.location}</small>
|
||||
<p>{item.snippet}</p>
|
||||
{item.providerScore !== undefined && <small>{t('external.providerScore')}: {item.providerScore}</small>}
|
||||
{item.providerScores?.map((score, scoreIndex) => <small key={scoreIndex}>{t('external.providerScore')} · {score.type}{score.index === undefined ? '' : ` [${score.index}]`}: {score.value}</small>)}
|
||||
{item.remoteDocumentId && <small>{t('external.documentId')}: {item.remoteDocumentId}</small>}
|
||||
{item.remoteChunkId && <small>{t('external.chunkId')}: {item.remoteChunkId}</small>}
|
||||
</li>)}</ol>
|
||||
</section>
|
||||
}
|
||||
|
||||
export function ExternalBindingForm({ provider, instances, libraries, library, onCancel, onChanged, notify, onManage }: {
|
||||
provider: ExternalKnowledgeProvider; instances: ExternalKnowledgeInstanceSummary[]; libraries: readonly KnowledgeLibrary[]; library?: KnowledgeLibrary
|
||||
onCancel: () => void; onChanged: Changed; notify: Notify; onManage: () => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const binding = library?.external
|
||||
const [instanceId, setInstanceId] = useState(binding?.instanceId ?? '')
|
||||
const [remoteId, setRemoteId] = useState(binding?.remoteKnowledgeBaseId ?? '')
|
||||
const [name, setName] = useState(library?.name ?? '')
|
||||
const [detail, setDetail] = useState<ExternalKnowledgeCatalogItem>()
|
||||
const [common, setCommon] = useState(binding?.commonConfig ?? commonDefaults)
|
||||
const [config, setConfig] = useState(binding?.providerConfig ?? externalProviderDefaults(provider))
|
||||
const [manual, setManual] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [parentId, setParentId] = useState<string | null>(null)
|
||||
const [catalog, setCatalog] = useState<ExternalKnowledgeCatalogPage>()
|
||||
const [catalogError, setCatalogError] = useState<string>()
|
||||
const [catalogLoading, setCatalogLoading] = useState(false)
|
||||
const [refresh, setRefresh] = useState(0)
|
||||
const [query, setQuery] = useState('')
|
||||
const [result, setResult] = useState<ExternalKnowledgeTestResult>()
|
||||
const [testedInstance, setTestedInstance] = useState<string>()
|
||||
const [rebindConfirmed, setRebindConfirmed] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [operationError, setOperationError] = useState<string>()
|
||||
const generation = useRef(0)
|
||||
const errorId = useId()
|
||||
const activeInstance = instances.find(item => item.id === instanceId)
|
||||
const instanceVersion = JSON.stringify(activeInstance)
|
||||
const validInstance = activeInstance?.provider === provider && activeInstance.enabled && activeInstance.credentialStatus === 'configured'
|
||||
const verifiedResult = testedInstance === instanceVersion ? result : undefined
|
||||
const duplicate = libraries.some(item => item.id !== library?.id && item.external?.instanceId === instanceId && item.external.remoteKnowledgeBaseId === remoteId.trim())
|
||||
const invalidate = (): void => { generation.current++; setResult(undefined); setOperationError(undefined); setBusy(false); setRebindConfirmed(false) }
|
||||
useEffect(() => () => { generation.current++ }, [])
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void Promise.resolve().then(async () => {
|
||||
if (!active) return
|
||||
setCatalog(undefined); setCatalogError(undefined)
|
||||
if (!instanceId || !validInstance || manual) { setCatalogLoading(false); return }
|
||||
setCatalogLoading(true)
|
||||
const value = await window.goodbuddy.knowledge.externalCatalogList({ instanceId, page, pageSize: 30, parentId, search })
|
||||
if (active) setCatalog(value)
|
||||
}).catch(error => { if (active) setCatalogError(errorMessage(error)) }).finally(() => { if (active) setCatalogLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [instanceId, validInstance, instanceVersion, page, parentId, search, manual, refresh])
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (!validInstance || !remoteId.trim()) return
|
||||
void window.goodbuddy.knowledge.externalCatalogGet({ instanceId, remoteKnowledgeBaseId: remoteId }).then(value => { if (active) setDetail(value) }).catch(() => { /* Retrieval remains available when a deployment has no detail endpoint. */ })
|
||||
return () => { active = false }
|
||||
}, [instanceId, remoteId, validInstance, instanceVersion, refresh])
|
||||
const testInput = { instanceId, remoteKnowledgeBaseId: remoteId, commonConfig: common, providerConfig: config, testQuery: query }
|
||||
const valid = validInstance && !duplicate && externalKnowledgeBindingTestInputSchema.safeParse(testInput).success
|
||||
const rebinding = binding && (binding.instanceId !== instanceId || binding.remoteKnowledgeBaseId !== remoteId.trim())
|
||||
const remoteName = detail?.name || (!rebinding ? binding?.remoteName : undefined) || name.trim()
|
||||
const run = async (save: boolean): Promise<void> => {
|
||||
if (busy || !valid || (save && (!verifiedResult || !name.trim() || (rebinding && !rebindConfirmed)))) return
|
||||
const current = ++generation.current
|
||||
setBusy(true)
|
||||
setSaving(save)
|
||||
setOperationError(undefined)
|
||||
try {
|
||||
if (save) {
|
||||
const input = { ...testInput, name: name.trim(), remoteName, description: library?.description }
|
||||
const snapshot = library
|
||||
? await window.goodbuddy.knowledge.externalBindingsUpdate({ ...input, knowledgeBaseId: library.id })
|
||||
: await window.goodbuddy.knowledge.externalBindingsCreate(input)
|
||||
if (current !== generation.current) return
|
||||
const created = snapshot.libraries.find(item => item.external?.instanceId === instanceId && item.external.remoteKnowledgeBaseId === remoteId.trim())
|
||||
await onChanged(snapshot, library ? undefined : created?.id)
|
||||
notify({ tone: 'success', message: t('external.saved') }); onCancel()
|
||||
} else {
|
||||
setResult(undefined)
|
||||
const value = await window.goodbuddy.knowledge.externalRetrievalTest(testInput)
|
||||
if (current === generation.current) { setResult(value); setTestedInstance(instanceVersion) }
|
||||
}
|
||||
} catch (error) { if (current === generation.current) setOperationError(errorMessage(error)) }
|
||||
finally { if (current === generation.current) setBusy(false) }
|
||||
}
|
||||
return <form className="external-knowledge" onSubmit={event => { event.preventDefault(); void run(true) }}>
|
||||
<fieldset disabled={busy} className="external-knowledge__fieldset" aria-describedby={operationError ? errorId : undefined}>
|
||||
<div className="external-knowledge__columns">
|
||||
<section className="external-knowledge__fields">
|
||||
<h3>{t('external.source')}</h3>
|
||||
<InstanceSelect instances={instances.filter(item => item.provider === provider)} value={instanceId} onChange={value => { invalidate(); setDetail(undefined); setInstanceId(value); setRemoteId(''); setConfig(externalProviderDefaults(provider)); setPage(1); setParentId(null); setSearch('') }} />
|
||||
<button type="button" className="secondary-button" onClick={onManage}>{t('external.manage')}</button>
|
||||
<SegmentedControl ariaLabel={t('external.remote')} value={manual ? 'manual' : 'catalog'} options={[{ value: 'catalog', label: t('external.browse') }, { value: 'manual', label: t('external.manual') }]} onChange={value => setManual(value === 'manual')} />
|
||||
{manual ? <Field label={t('external.remoteId')}><input maxLength={512} value={remoteId} disabled={busy} aria-invalid={duplicate || undefined} aria-describedby={duplicate ? `${errorId}-duplicate` : undefined} onChange={event => { invalidate(); setDetail(undefined); setRemoteId(event.currentTarget.value) }} /></Field> : <>
|
||||
<Field label={t('external.search')}><input type="search" maxLength={512} value={search} onChange={event => { setSearch(event.currentTarget.value); setPage(1) }} /></Field>
|
||||
<div className="external-knowledge__actions"><button type="button" className="secondary-button" aria-label={t('external.refresh')} title={t('external.refresh')} onClick={() => { invalidate(); setRefresh(value => value + 1) }}><RefreshCw size={15} /></button>
|
||||
{parentId && <button type="button" className="secondary-button" onClick={() => { setParentId(null); setPage(1) }}>{t('external.root')}</button>}</div>
|
||||
{catalogLoading && <p role="status">{t('external.loading')}</p>}
|
||||
{catalogError && <div role="alert"><p>{catalogError}</p><button type="button" className="secondary-button" onClick={() => setRefresh(value => value + 1)}>{t('actions.retry')}</button></div>}
|
||||
{catalog && <div className="external-knowledge__catalog" aria-label={t('external.remote')}>
|
||||
{catalog.items.length === 0 && <EmptyState icon={<Database size={20} />} title={t(search ? 'external.noResults' : 'external.empty')} description={t(search ? 'external.noResultsHelp' : 'external.emptyHelp')} action={<button type="button" className="secondary-button" onClick={() => { if (search) { setSearch(''); setPage(1) } else setManual(true) }}>{t(search ? 'external.clearSearch' : 'external.manual')}</button>} />}
|
||||
{catalog.items.map(item => {
|
||||
const bound = libraries.some(candidate => candidate.id !== library?.id && candidate.external?.instanceId === instanceId && candidate.external.remoteKnowledgeBaseId === item.id)
|
||||
return <button type="button" className="external-knowledge__target" key={item.id} aria-pressed={item.kind === 'folder' ? undefined : remoteId === item.id} disabled={bound || busy} onClick={() => {
|
||||
if (item.kind === 'folder') { setParentId(item.id); setPage(1); return }
|
||||
invalidate(); setRemoteId(item.id); setDetail(item); if (!name) setName(item.name)
|
||||
}}>{item.kind === 'folder' ? <FolderOpen size={15} /> : <Database size={15} />}<span><strong>{item.name}</strong><small>{item.id}{bound ? ` · ${t('external.alreadyBound')}` : ''}</small></span></button>
|
||||
})}
|
||||
<div className="external-knowledge__actions"><button type="button" className="secondary-button" disabled={page === 1} onClick={() => setPage(value => value - 1)}>{t('external.previous')}</button><span>{page}</span><button type="button" className="secondary-button" disabled={!catalog.hasMore} onClick={() => setPage(value => value + 1)}>{t('external.next')}</button></div>
|
||||
</div>}
|
||||
</>}
|
||||
{remoteId && <p className="external-knowledge__identifier">{remoteName}<br />{remoteId}</p>}
|
||||
{duplicate && <p role="alert" id={`${errorId}-duplicate`}>{t('external.alreadyBound')}</p>}
|
||||
<Field label={t('fields.name')}><input required maxLength={512} value={name} onChange={event => setName(event.currentTarget.value)} /></Field>
|
||||
<Field label={t('external.query')}><textarea required maxLength={4000} value={query} onChange={event => { invalidate(); setQuery(event.currentTarget.value) }} /></Field>
|
||||
</section>
|
||||
<ExternalConfig common={common} config={config} detail={detail} onCommon={value => { invalidate(); setCommon(value) }} onConfig={value => { invalidate(); setConfig(value) }} />
|
||||
</div>
|
||||
<p className="knowledge-muted">{t('external.queryNote')}</p>
|
||||
{operationError && <p id={errorId} role="alert">{operationError}</p>}
|
||||
{verifiedResult ? <TestResults result={verifiedResult} /> : <p role="status">{t('external.untested')}</p>}
|
||||
{rebinding && <label><input type="checkbox" checked={rebindConfirmed} onChange={event => setRebindConfirmed(event.currentTarget.checked)} />{t('external.rebindConfirm')}</label>}
|
||||
<footer className="external-knowledge__actions external-knowledge__footer"><ScopeBadge scope={{ kind: 'global' }} />
|
||||
<button type="button" className="secondary-button" onClick={() => { invalidate(); onCancel() }}>{t('actions.cancel')}</button>
|
||||
<button type="button" className="secondary-button" disabled={busy || !valid} onClick={() => void run(false)}>{t(busy && !saving ? 'external.testing' : 'external.test')}</button>
|
||||
<button type="submit" className="primary-button" disabled={busy || !verifiedResult || !valid || !name.trim() || (!!rebinding && !rebindConfirmed)}>{t(busy && saving ? 'actions.saving' : library ? 'external.saveConfig' : 'external.add')}</button>
|
||||
</footer>
|
||||
</fieldset>
|
||||
</form>
|
||||
}
|
||||
|
||||
export function ExternalInstanceManager({ instances, libraries, onClose, onChanged, notify }: {
|
||||
instances: ExternalKnowledgeInstanceSummary[]; libraries: readonly KnowledgeLibrary[]; onClose: () => void; onChanged: Changed; notify: Notify
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [draft, setDraft] = useState<ExternalKnowledgeInstanceSaveInput>()
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
const [deleting, setDeleting] = useState<ExternalKnowledgeInstanceSummary>()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [clearConfirmed, setClearConfirmed] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState<string>()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const closeRef = useRef<HTMLButtonElement>(null)
|
||||
const generation = useRef(0)
|
||||
const keyId = useId()
|
||||
useEffect(() => activateModalFocus(() => closeRef.current), [])
|
||||
useEffect(() => () => { generation.current++ }, [])
|
||||
const run = async (action: () => Promise<unknown>, message?: string): Promise<void> => {
|
||||
const current = ++generation.current; setBusy(true)
|
||||
try { await action(); if (current !== generation.current) return; await onChanged(); if (message) notify({ tone: 'success', message }) }
|
||||
catch (error) { if (current === generation.current) notify({ tone: 'error', message: errorMessage(error) }) }
|
||||
finally { if (current === generation.current) setBusy(false) }
|
||||
}
|
||||
const edit = (instance?: ExternalKnowledgeInstanceSummary): void => {
|
||||
setDraft(instance ? { id: instance.id, name: instance.name, provider: instance.provider, baseUrl: instance.baseUrl, enabled: instance.enabled, credential: { action: 'keep' } } : { name: '', provider: 'dify', baseUrl: '', enabled: true, credential: { action: 'replace', value: '' } })
|
||||
setClearConfirmed(false); setShowKey(false)
|
||||
}
|
||||
const change = (value: Partial<ExternalKnowledgeInstanceSaveInput>): void => { if (draft) setDraft({ ...draft, ...value }); setClearConfirmed(false) }
|
||||
return createPortal(<div className="external-knowledge-modal" role="dialog" aria-modal="true" aria-labelledby="external-instance-title" ref={dialogRef} onKeyDown={event => { if (event.key === 'Escape' && !busy) { event.preventDefault(); onClose() }; trapTabFocus(event, dialogRef.current) }}>
|
||||
<section className="external-knowledge-modal__surface">
|
||||
<header><div><h2 id="external-instance-title">{deleting ? t('external.deleteInstanceTitle', { name: deleting.name }) : t(draft ? draft.id ? 'external.editInstance' : 'external.addInstance' : 'external.instances')}</h2><ScopeBadge scope={{ kind: 'global' }} /></div><button type="button" className="icon-button" ref={closeRef} disabled={busy} aria-label={t('external.close')} title={t('external.close')} onClick={onClose}><X size={16} /></button></header>
|
||||
<div className="external-knowledge-modal__body">
|
||||
{draft ? <form className="external-knowledge__fields" onSubmit={event => { event.preventDefault(); if (busy || (draft.credential.action === 'clear' && !clearConfirmed)) return; void run(async () => { await window.goodbuddy.knowledge.externalInstancesSave(draft); setDraft(undefined) }, t('external.saved')) }}>
|
||||
<fieldset disabled={busy} className="external-knowledge__fieldset">
|
||||
<div className="external-knowledge__columns">
|
||||
<Field label={t('fields.name')}><input autoFocus required value={draft.name} onChange={event => change({ name: event.currentTarget.value })} /></Field>
|
||||
<Field label={t('external.provider')}><select disabled={!!draft.id && instances.some(item => item.id === draft.id && item.bindingCount > 0)} value={draft.provider} onChange={event => change({ provider: event.currentTarget.value as ExternalKnowledgeProvider })}>{Object.entries(externalProviderNames).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></Field>
|
||||
</div>
|
||||
<Field label={t('external.address')}><input type="url" required value={draft.baseUrl} aria-describedby={/^http:\/\//i.test(draft.baseUrl) ? `${keyId}-transport` : undefined} onChange={event => change({ baseUrl: event.currentTarget.value })} /></Field>
|
||||
{/^http:\/\//i.test(draft.baseUrl) && <p className="external-knowledge__help" id={`${keyId}-transport`}>{t('external.transportNote')}</p>}
|
||||
<fieldset className="external-knowledge__group">
|
||||
<legend>{t('external.authentication')}</legend>
|
||||
{draft.id && <Field label={t('external.credentialAction')}><select value={draft.credential.action} onChange={event => { setShowKey(false); change({ credential: event.currentTarget.value === 'replace' ? { action: 'replace', value: '' } : { action: event.currentTarget.value as 'keep' | 'clear' } }) }}>
|
||||
{draft.id && <option value="keep">{t('external.keep')}</option>}<option value="replace">{t('external.replace')}</option>{draft.id && <option value="clear">{t('external.clear')}</option>}
|
||||
</select></Field>}
|
||||
{draft.credential.action === 'replace' && <div className="field">
|
||||
<span><label htmlFor={keyId}>{t('external.credential')}</label></span>
|
||||
<div className="external-knowledge__password"><input id={keyId} type={showKey ? 'text' : 'password'} autoComplete="new-password" required aria-describedby={`${keyId}-note`} value={draft.credential.value} onChange={event => change({ credential: { action: 'replace', value: event.currentTarget.value } })} />
|
||||
<button type="button" className="icon-button" aria-label={t(showKey ? 'external.hideKey' : 'external.showKey')} title={t(showKey ? 'external.hideKey' : 'external.showKey')} aria-pressed={showKey} onClick={() => setShowKey(value => !value)}>{showKey ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||
</div>
|
||||
</div>}
|
||||
<p className="external-knowledge__help" id={`${keyId}-note`}>{t('external.credentialNote')}</p>
|
||||
{draft.credential.action === 'clear' && <label><input type="checkbox" checked={clearConfirmed} onChange={event => setClearConfirmed(event.currentTarget.checked)} />{t('external.clearConfirm')}</label>}
|
||||
</fieldset>
|
||||
<div className="external-knowledge__fields">
|
||||
<Switch label={t('external.enabled')} checked={draft.enabled} onChange={value => change({ enabled: value })} />
|
||||
<p className="external-knowledge__help">{t('external.enabledHelp')}</p>
|
||||
</div>
|
||||
<details className="external-knowledge__help"><summary>{t('external.connectionHelp')}</summary><p>{t('external.saveThenTest')}</p></details>
|
||||
<footer className="external-knowledge__actions external-knowledge__footer"><ScopeBadge scope={{ kind: 'global' }} /><button type="button" className="secondary-button" disabled={busy} onClick={() => setDraft(undefined)}>{t('actions.cancel')}</button><button type="submit" className="primary-button" disabled={busy || (draft.credential.action === 'clear' && !clearConfirmed) || !URL.canParse(draft.baseUrl) || !externalKnowledgeInstanceSaveInputSchema.safeParse(draft).success}>{t(busy ? 'actions.saving' : 'external.saveInstance')}</button></footer>
|
||||
</fieldset>
|
||||
</form> : deleting ? <section className="external-knowledge__fields" aria-labelledby="external-instance-title">
|
||||
<p>{t('external.deleteInstanceConfirm', { count: libraries.filter(item => item.external?.instanceId === deleting.id).length })}</p>
|
||||
<ul>{libraries.filter(item => item.external?.instanceId === deleting.id).map(item => <li key={item.id}>{item.name}</li>)}</ul>
|
||||
{deleteError && <p role="alert" id="external-delete-error">{deleteError}</p>}
|
||||
<footer className="external-knowledge__actions external-knowledge__footer"><ScopeBadge scope={{ kind: 'global' }} /><button type="button" className="secondary-button" autoFocus disabled={busy} onClick={() => setDeleting(undefined)}>{t('actions.cancel')}</button><button type="button" className="danger-button" disabled={busy} aria-describedby={deleteError ? 'external-delete-error' : undefined} onClick={() => {
|
||||
setBusy(true); setDeleteError(undefined)
|
||||
void (async () => {
|
||||
try {
|
||||
for (const item of libraries.filter(item => item.external?.instanceId === deleting.id)) await window.goodbuddy.knowledge.deleteLibrary(item.id)
|
||||
await window.goodbuddy.knowledge.externalInstancesDelete({ instanceId: deleting.id })
|
||||
setDeleting(undefined); notify({ tone: 'success', message: t('external.removed') })
|
||||
} catch (error) { setDeleteError(errorMessage(error)) }
|
||||
finally {
|
||||
// A failed later deletion must still refresh bindings already removed.
|
||||
try { await onChanged() } catch (error) { notify({ tone: 'error', message: errorMessage(error) }) }
|
||||
setBusy(false)
|
||||
}
|
||||
})()
|
||||
}}>{t('external.deleteInstance')}</button></footer>
|
||||
</section> : <>
|
||||
<button type="button" className="primary-button" disabled={busy} onClick={() => edit()}>{t('external.addInstance')}</button>
|
||||
{!instances.length && <EmptyState title={t('external.noInstances')} description={t('external.noInstancesHelp')} icon={<Database size={24} />} />}
|
||||
<ul className="external-knowledge__instances">{instances.map(instance => <li key={instance.id}>
|
||||
<div><strong>{instance.name}</strong><p>{externalProviderNames[instance.provider]} · {new URL(instance.baseUrl).origin}</p><small>{instance.enabled ? t(`external.probe.${instance.probeStatus}`) : t('external.states.instance-disabled')} · {t(`external.credentials.${instance.credentialStatus}`)} · {t('external.boundCount', { count: instance.bindingCount })}</small>{instance.lastTestedAt && <small>{t('external.lastTest')}: <ExternalTime value={instance.lastTestedAt} /></small>}</div>
|
||||
<div className="external-knowledge__actions"><Switch label={`${t('external.enabled')}: ${instance.name}`} checked={instance.enabled} disabled={busy} onChange={enabled => void run(() => window.goodbuddy.knowledge.externalInstancesSetEnabled({ instanceId: instance.id, enabled }))} />
|
||||
<button type="button" className="secondary-button" disabled={busy || !instance.enabled || instance.credentialStatus !== 'configured'} onClick={() => void run(async () => { const value = await window.goodbuddy.knowledge.externalInstancesTest({ instanceId: instance.id }); notify({ tone: value.probeStatus === 'auth-failed' || value.probeStatus === 'failed' || value.probeStatus === 'unreachable' ? 'error' : 'info', message: `${instance.name}: ${t(`external.probe.${value.probeStatus}`)}` }) })}>{t('external.testConnection')}</button>
|
||||
<button type="button" className="secondary-button" disabled={busy} onClick={() => edit(instance)}>{t('actions.edit')}</button><button type="button" className="danger-ghost" disabled={busy} onClick={() => setDeleting(instance)}>{t('actions.remove')}</button>
|
||||
</div>
|
||||
</li>)}</ul>
|
||||
</>}
|
||||
</div>
|
||||
</section>
|
||||
</div>, document.body)
|
||||
}
|
||||
|
||||
function ExternalRetrievalTest({ library, instance }: { library: KnowledgeLibrary; instance?: ExternalKnowledgeInstanceSummary }): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [query, setQuery] = useState('')
|
||||
const [result, setResult] = useState<ExternalKnowledgeTestResult>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const generation = useRef(0)
|
||||
const errorId = useId()
|
||||
useEffect(() => () => { generation.current++ }, [])
|
||||
const binding = library.external!
|
||||
const available = instance?.enabled && instance.credentialStatus === 'configured'
|
||||
return <form className="external-knowledge__fields" onSubmit={event => {
|
||||
event.preventDefault()
|
||||
if (busy || !available || !query.trim()) return
|
||||
const current = ++generation.current
|
||||
setBusy(true); setError(undefined); setResult(undefined)
|
||||
void window.goodbuddy.knowledge.externalRetrievalTest({ instanceId: binding.instanceId, remoteKnowledgeBaseId: binding.remoteKnowledgeBaseId, commonConfig: binding.commonConfig, providerConfig: binding.providerConfig, testQuery: query }).then(value => { if (current === generation.current) setResult(value) }).catch(reason => { if (current === generation.current) setError(errorMessage(reason)) }).finally(() => { if (current === generation.current) setBusy(false) })
|
||||
}}>
|
||||
<p>{externalProviderNames[binding.provider]} · {instance?.name} · {binding.remoteName}</p>
|
||||
<p>{t('external.queryNote')}</p>
|
||||
{!available && <p role="status">{t(`external.states.${externalLibraryStatus(library, instance ? [instance] : [])}`)}</p>}
|
||||
<Field label={t('external.query')}><textarea required disabled={busy} maxLength={4000} value={query} aria-describedby={error ? errorId : undefined} onChange={event => { generation.current++; setError(undefined); setResult(undefined); setQuery(event.currentTarget.value) }} /></Field>
|
||||
<button type="submit" className="secondary-button" disabled={busy || !available || !query.trim()}>{t(busy ? 'external.testing' : 'external.test')}</button>
|
||||
{error && <p role="alert" id={errorId}>{error}</p>}
|
||||
{result && <TestResults result={result} />}
|
||||
</form>
|
||||
}
|
||||
|
||||
export function ExternalLibraryDetail({ library, instances, libraries, onChanged, notify, onManage, onUseInChat }: {
|
||||
library: KnowledgeLibrary; instances: ExternalKnowledgeInstanceSummary[]; libraries: readonly KnowledgeLibrary[]; onChanged: Changed; notify: Notify; onManage: () => void; onUseInChat: (id: string) => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [tab, setTab] = useState<'overview' | 'config' | 'test'>('overview')
|
||||
const [configVisited, setConfigVisited] = useState(false)
|
||||
const selectTab = (value: typeof tab): void => { if (value === 'config') setConfigVisited(true); setTab(value) }
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [remoteDetail, setRemoteDetail] = useState<ExternalKnowledgeCatalogItem>()
|
||||
const [detailError, setDetailError] = useState<string>()
|
||||
const detailGeneration = useRef(0)
|
||||
useEffect(() => () => { detailGeneration.current++ }, [])
|
||||
const binding = library.external!
|
||||
const instance = instances.find(item => item.id === binding.instanceId)
|
||||
return <div className="external-knowledge">
|
||||
<header><h2>{library.name}</h2><p>{externalProviderNames[binding.provider]} · {instance?.name} · {binding.remoteName}</p><ScopeBadge scope={{ kind: 'global' }} /></header>
|
||||
<PageTabs idPrefix={`external-${library.id}`} ariaLabel={library.name} value={tab} onChange={selectTab} tabs={[{ id: 'overview', label: t('external.overview') }, { id: 'config', label: t('external.config') }, { id: 'test', label: t('external.test') }]} />
|
||||
{tab !== 'config' && <div role="tabpanel" id={`external-${library.id}-panel-${tab}`} aria-labelledby={`external-${library.id}-tab-${tab}`}>
|
||||
{tab === 'overview' ? <section className="external-knowledge__fields">
|
||||
<dl><dt>{t('external.remoteId')}</dt><dd className="external-knowledge__identifier">{binding.remoteKnowledgeBaseId}</dd><dt>{t('external.status')}</dt><dd>{t(`external.states.${externalLibraryStatus(library, instances)}`)}</dd><dt>{t('external.lastTest')}</dt><dd>{binding.lastVerifiedAt ? <ExternalTime value={binding.lastVerifiedAt} /> : t('external.never')}</dd></dl>
|
||||
<p>{t('external.remoteData')}</p><p>{t('external.localOnly')}</p>
|
||||
<button type="button" className="secondary-button" disabled={busy} onClick={() => {
|
||||
const current = ++detailGeneration.current
|
||||
setBusy(true); setDetailError(undefined)
|
||||
void window.goodbuddy.knowledge.externalCatalogGet({ instanceId: binding.instanceId, remoteKnowledgeBaseId: binding.remoteKnowledgeBaseId }).then(value => { if (current === detailGeneration.current) setRemoteDetail(value) }).catch(error => { if (current === detailGeneration.current) setDetailError(errorMessage(error)) }).finally(() => { if (current === detailGeneration.current) setBusy(false) })
|
||||
}}>{t('external.refreshDetail')}</button>
|
||||
{detailError && <p role="alert">{detailError}</p>}
|
||||
{remoteDetail && <p role="status">{remoteDetail.name} · {remoteDetail.description}</p>}
|
||||
<div className="external-knowledge__actions"><button type="button" className="primary-button" disabled={externalLibraryStatus(library, instances) !== 'ready'} onClick={() => onUseInChat(library.id)}>{t('actions.useInChat')}</button><button type="button" className="secondary-button" onClick={() => selectTab('config')}>{t('external.rebind')}</button><button type="button" className="secondary-button" onClick={onManage}>{t('external.manage')}</button><button type="button" className="danger-ghost" onClick={() => setRemoving(true)}>{t('external.remove')}</button></div>
|
||||
{removing && <div className="external-knowledge__group"><p>{t('external.removeBindingConfirm')}</p><div className="external-knowledge__actions"><button type="button" className="secondary-button" autoFocus disabled={busy} onClick={() => setRemoving(false)}>{t('actions.cancel')}</button><button type="button" className="danger-button" disabled={busy} onClick={() => { setBusy(true); void window.goodbuddy.knowledge.deleteLibrary(library.id).then(async () => { await onChanged(); notify({ tone: 'success', message: t('external.removed') }) }).catch(error => notify({ tone: 'error', message: errorMessage(error) })).finally(() => setBusy(false)) }}>{t('external.remove')}</button></div></div>}
|
||||
</section> : <ExternalRetrievalTest key={JSON.stringify([binding, instance])} library={library} instance={instance} />}
|
||||
</div>}
|
||||
{configVisited && <div hidden={tab !== 'config'} role="tabpanel" id={`external-${library.id}-panel-config`} aria-labelledby={`external-${library.id}-tab-config`}><ExternalBindingForm provider={binding.provider} instances={instances} libraries={libraries} library={library} onCancel={() => { setConfigVisited(false); setTab('overview') }} onChanged={onChanged} notify={notify} onManage={onManage} /></div>}
|
||||
</div>
|
||||
}
|
||||
@@ -30,6 +30,14 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('KnowledgeCitationDialog', () => {
|
||||
it('reads historical external snippets and typed scores without local source actions', () => {
|
||||
const onOpenSource = vi.fn()
|
||||
render(<KnowledgeCitationDialog reference={{ ...reference, documentId: undefined, chunkId: undefined, external: { kind: 'external', provider: 'fastgpt', instanceId: 'removed-instance', remoteKnowledgeBaseId: 'remote-library', providerScores: [{ type: 'embedding', value: 0.8, index: 2 }] } }} onClose={vi.fn()} onOpenSource={onOpenSource} />)
|
||||
expect(screen.getByText(reference.snippet)).toBeInTheDocument()
|
||||
expect(screen.getByText('embedding [2]: 0.8')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '打开来源' })).not.toBeInTheDocument()
|
||||
expect(onOpenSource).not.toHaveBeenCalled()
|
||||
})
|
||||
it('shows matched and surrounding context and restores close behavior', async () => {
|
||||
const onClose = vi.fn()
|
||||
const onOpenSource = vi.fn(async () => undefined)
|
||||
|
||||
@@ -33,6 +33,7 @@ export function KnowledgeCitationDialog({
|
||||
onOpenSource
|
||||
}: KnowledgeCitationDialogProps): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const { t: tk } = useTranslation('knowledge')
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const closeRef = useRef<HTMLButtonElement>(null)
|
||||
const [opening, setOpening] = useState(false)
|
||||
@@ -43,6 +44,7 @@ export function KnowledgeCitationDialog({
|
||||
}, [])
|
||||
|
||||
const openSource = async (): Promise<void> => {
|
||||
if (reference.external || !reference.documentId) return
|
||||
setOpening(true)
|
||||
setOpenError(undefined)
|
||||
try {
|
||||
@@ -84,7 +86,7 @@ export function KnowledgeCitationDialog({
|
||||
<h2 id="knowledge-citation-dialog-title">
|
||||
{t('chat.citations.contextTitle')}
|
||||
</h2>
|
||||
<p>{t('chat.citations.contextDescription')}</p>
|
||||
<p>{reference.external ? tk('external.snapshot') : t('chat.citations.contextDescription')}</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t('chat.citations.closeContext')}
|
||||
@@ -99,13 +101,21 @@ export function KnowledgeCitationDialog({
|
||||
</header>
|
||||
|
||||
<dl className="knowledge-citation-dialog__metadata">
|
||||
{reference.external && <>
|
||||
<div><dt>{tk('external.provider')}</dt><dd>{({ dify: 'Dify', fastgpt: 'FastGPT', ragflow: 'RAGFlow' })[reference.external.provider]}</dd></div>
|
||||
<div><dt>{tk('external.instance')}</dt><dd>{reference.external.instanceId}</dd></div>
|
||||
<div><dt>{tk('external.remoteId')}</dt><dd>{reference.external.remoteKnowledgeBaseId}</dd></div>
|
||||
{reference.external.remoteDocumentId && <div><dt>{tk('external.documentId')}</dt><dd>{reference.external.remoteDocumentId}</dd></div>}
|
||||
{reference.external.remoteChunkId && <div><dt>{tk('external.chunkId')}</dt><dd>{reference.external.remoteChunkId}</dd></div>}
|
||||
<div><dt>{tk('external.providerScore')}</dt>{reference.external.providerScore !== undefined && <dd>{reference.external.providerScore}</dd>}{reference.external.providerScores?.map((score, index) => <dd key={index}>{score.type}{score.index === undefined ? '' : ` [${score.index}]`}: {score.value}</dd>)}</div>
|
||||
</>}
|
||||
<div>
|
||||
<dt>{reference.documentName}</dt>
|
||||
<dd>{context?.sourceName ?? reference.sourceName}</dd>
|
||||
</div>
|
||||
{(context?.locator ?? reference.locator) && (
|
||||
{(context?.locator ?? reference.external?.location ?? reference.locator) && (
|
||||
<div>
|
||||
<dt>{context?.locator ?? reference.locator}</dt>
|
||||
<dt>{context?.locator ?? reference.external?.location ?? reference.locator}</dt>
|
||||
{reference.score !== undefined && (
|
||||
<dd>
|
||||
{t('chat.citations.score', {
|
||||
@@ -117,7 +127,7 @@ export function KnowledgeCitationDialog({
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{loading ? (
|
||||
{reference.external ? <div className="knowledge-citation-dialog__content"><section><h3>{t('chat.citations.matchedChunk')}</h3><p>{reference.snippet}</p></section></div> : loading ? (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="knowledge-citation-dialog__state"
|
||||
@@ -175,7 +185,7 @@ export function KnowledgeCitationDialog({
|
||||
>
|
||||
{t('chat.citations.closeContext')}
|
||||
</button>
|
||||
<button
|
||||
{!reference.external && reference.documentId && <button
|
||||
className="primary-button"
|
||||
disabled={opening}
|
||||
onClick={() => void openSource()}
|
||||
@@ -187,7 +197,7 @@ export function KnowledgeCitationDialog({
|
||||
<ExternalLink aria-hidden="true" size={15} />
|
||||
)}
|
||||
{t('chat.citations.openSource')}
|
||||
</button>
|
||||
</button>}
|
||||
</footer>
|
||||
</section>
|
||||
</div>,
|
||||
|
||||
@@ -40,8 +40,9 @@ export type KnowledgeRetrievalChannelDetail = {
|
||||
}
|
||||
|
||||
export type KnowledgeRetrievalWorkbenchResult = {
|
||||
chunkId: string
|
||||
documentId: string
|
||||
external?: import('../../shared/external-knowledge-contracts').ExternalKnowledgeLocator
|
||||
chunkId?: string
|
||||
documentId?: string
|
||||
rank: number
|
||||
documentName: string
|
||||
sourceName: string
|
||||
@@ -1013,7 +1014,7 @@ export function KnowledgeRetrievalWorkbench({
|
||||
{results.map((result) => (
|
||||
<li
|
||||
className="knowledge-retrieval-result"
|
||||
key={result.chunkId}
|
||||
key={result.chunkId ?? `external-${result.rank}`}
|
||||
>
|
||||
<article
|
||||
aria-label={t('retrieval.results.resultAriaLabel', {
|
||||
@@ -1094,7 +1095,7 @@ export function KnowledgeRetrievalWorkbench({
|
||||
<p>{result.contextText}</p>
|
||||
</details>
|
||||
)}
|
||||
<footer>
|
||||
{!result.external && result.documentId && result.chunkId && <footer>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => onViewContext(result)}
|
||||
@@ -1111,7 +1112,7 @@ export function KnowledgeRetrievalWorkbench({
|
||||
<ExternalLink aria-hidden="true" size={14} />
|
||||
{t('retrieval.actions.openSource')}
|
||||
</button>
|
||||
</footer>
|
||||
</footer>}
|
||||
</article>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -262,6 +262,25 @@ function createProps(
|
||||
}
|
||||
|
||||
describe('KnowledgeWorkspace', () => {
|
||||
it('searches external names and instances and filters the unified library list', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const external = { ...library, id: 'external-1', name: 'Company handbook', external: { knowledgeBaseId: 'external-1', instanceId: 'instance-1', provider: 'dify' as const, remoteKnowledgeBaseId: 'remote-1', remoteName: 'Remote policies', commonConfig: { resultLimit: 6, requestTimeoutMs: 15000, maxSnippetCharacters: 4000 }, providerConfig: { provider: 'dify' as const, useDatasetDefaults: true as const }, lastVerifiedAt: '2026-09-12' } }
|
||||
const props = createProps({ libraries: [library, external], externalInstances: [{ id: 'instance-1', name: 'Enterprise service', provider: 'dify', baseUrl: 'https://knowledge.example', enabled: true, credentialStatus: 'configured', probeStatus: 'healthy', bindingCount: 1 }] })
|
||||
render(<KnowledgeWorkspace {...props} />)
|
||||
const nav = screen.getByRole('navigation', { name: 'Library list' })
|
||||
for (const query of ['Remote policies', 'Enterprise service', 'dify']) {
|
||||
fireEvent.change(screen.getByLabelText('Search libraries'), { target: { value: query } })
|
||||
expect(within(nav).getAllByRole('button')).toHaveLength(1)
|
||||
expect(within(nav).getByRole('button', { name: /Company handbook/ })).toBeInTheDocument()
|
||||
}
|
||||
fireEvent.change(screen.getByLabelText('Library source filter'), { target: { value: 'local' } })
|
||||
expect(within(nav).getByText('No matching results')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear filters' }))
|
||||
expect(within(nav).getAllByRole('button')).toHaveLength(2)
|
||||
fireEvent.click(within(nav).getByRole('button', { name: /Company handbook/ }))
|
||||
expect(props.onSelectLibrary).toHaveBeenCalledWith(external.id)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
@@ -2022,7 +2041,8 @@ describe('KnowledgeWorkspace', () => {
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '新建知识库' })
|
||||
).not.toBeInTheDocument()
|
||||
).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '管理外部实例' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a retryable load error instead of the first-library empty state', () => {
|
||||
|
||||
@@ -94,6 +94,10 @@ import {
|
||||
} from './KnowledgeRetrievalWorkbench'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { KnowledgeEmbeddingIndexSection } from './KnowledgeEmbeddingIndexSection'
|
||||
import { ExternalBindingForm, ExternalInstanceManager, ExternalLibraryDetail, externalProviderNames, externalLibraryStatus } from './ExternalKnowledge'
|
||||
import type { ExternalKnowledgeInstanceSummary, ExternalKnowledgeProvider } from '../../shared/external-knowledge-contracts'
|
||||
import type { KnowledgeSnapshot } from '../../shared/contracts'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
|
||||
type KnowledgeGraphChartModule = typeof import('./KnowledgeGraphChart')
|
||||
|
||||
@@ -226,6 +230,9 @@ export type KnowledgeRelationInput = {
|
||||
}
|
||||
|
||||
export type KnowledgeWorkspaceProps = {
|
||||
externalInstances?: ExternalKnowledgeInstanceSummary[]
|
||||
onExternalChanged?: (snapshot?: KnowledgeSnapshot, createdId?: string) => void | Promise<void>
|
||||
notify?: (input: AppNotificationInput) => void
|
||||
libraries: readonly KnowledgeLibrary[]
|
||||
selectedLibraryId?: string
|
||||
sources: readonly KnowledgeSource[]
|
||||
@@ -455,8 +462,9 @@ function toWorkbenchResponse(
|
||||
rerank: response.diagnostics.rerank
|
||||
},
|
||||
results: response.results.map((result) => {
|
||||
const context = contextByChunkId.get(result.chunkId)
|
||||
const context = result.chunkId ? contextByChunkId.get(result.chunkId) : undefined
|
||||
return {
|
||||
external: result.external,
|
||||
chunkId: result.chunkId,
|
||||
documentId: result.documentId,
|
||||
rank: result.rank,
|
||||
@@ -4302,6 +4310,9 @@ function GraphView({
|
||||
}
|
||||
|
||||
export function KnowledgeWorkspace({
|
||||
externalInstances = [],
|
||||
onExternalChanged = () => {},
|
||||
notify = () => {},
|
||||
libraries,
|
||||
selectedLibraryId,
|
||||
sources,
|
||||
@@ -4354,6 +4365,15 @@ export function KnowledgeWorkspace({
|
||||
const { i18n, t } = useTranslation('knowledge')
|
||||
const locale = resolvedLocale(i18n.resolvedLanguage ?? i18n.language)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [creationType, setCreationType] = useState<'local' | ExternalKnowledgeProvider>('local')
|
||||
const [managingExternal, setManagingExternal] = useState(false)
|
||||
const [librarySearch, setLibrarySearch] = useState('')
|
||||
const [librarySource, setLibrarySource] = useState('all')
|
||||
const filteredLibraries = libraries.filter(library => {
|
||||
if (librarySource !== 'all' && (library.external?.provider ?? 'local') !== librarySource) return false
|
||||
const instanceName = externalInstances.find(instance => instance.id === library.external?.instanceId)?.name ?? ''
|
||||
return [library.name, library.external?.remoteName, library.external?.provider ?? t('external.local'), instanceName].join(' ').toLocaleLowerCase(locale).includes(librarySearch.trim().toLocaleLowerCase(locale))
|
||||
})
|
||||
const [mobileListOpen, setMobileListOpen] = useState(false)
|
||||
const [tab, setTab] = useState<WorkspaceTab>('documents')
|
||||
const [graphTab, setGraphTab] =
|
||||
@@ -4505,9 +4525,11 @@ export function KnowledgeWorkspace({
|
||||
|
||||
return (
|
||||
<div className="knowledge-page">
|
||||
{managingExternal && <ExternalInstanceManager instances={externalInstances} libraries={libraries} onClose={() => setManagingExternal(false)} onChanged={onExternalChanged} notify={notify} />}
|
||||
<PageHeader
|
||||
actions={
|
||||
libraries.length > 0 ? (
|
||||
<>
|
||||
<button type="button" className="secondary-button" onClick={() => setManagingExternal(true)}><Database size={16} />{t('external.manage')}</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={loading}
|
||||
@@ -4520,7 +4542,7 @@ export function KnowledgeWorkspace({
|
||||
<Plus aria-hidden="true" size={16} />
|
||||
{t('actions.newLibrary')}
|
||||
</button>
|
||||
) : undefined
|
||||
</>
|
||||
}
|
||||
description={t('page.description')}
|
||||
headingId="knowledge-workspace-title"
|
||||
@@ -4542,22 +4564,27 @@ export function KnowledgeWorkspace({
|
||||
<strong>{t('workspace.libraryList')}</strong>
|
||||
</span>
|
||||
<small>
|
||||
{formatNumber(libraries.length, locale)}
|
||||
{formatNumber(filteredLibraries.length, locale)}
|
||||
</small>
|
||||
</div>
|
||||
<div className="external-knowledge__filters">
|
||||
<label className="knowledge-field">{t('external.searchLibraries')}<input type="search" value={librarySearch} onChange={event => setLibrarySearch(event.currentTarget.value)} /></label>
|
||||
<label className="knowledge-field">{t('external.sourceFilter')}<select value={librarySource} onChange={event => setLibrarySource(event.currentTarget.value)}><option value="all">{t('external.allSources')}</option><option value="local">{t('external.local')}</option>{Object.entries(externalProviderNames).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
</div>
|
||||
<nav
|
||||
aria-label={t('workspace.libraryList')}
|
||||
className="knowledge-workspace__library-nav"
|
||||
>
|
||||
{libraries.length === 0 ? (
|
||||
{(loading || loadError) && libraries.length === 0 ? null : filteredLibraries.length === 0 ? (
|
||||
<div className="knowledge-workspace__library-empty">
|
||||
{t('workspace.libraryListEmpty')}
|
||||
{t(libraries.length ? 'external.noResults' : 'workspace.libraryListEmpty')}
|
||||
{(librarySearch || librarySource !== 'all') && <button type="button" className="secondary-button" onClick={() => { setLibrarySearch(''); setLibrarySource('all') }}>{t('external.clearFilters')}</button>}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="knowledge-workspace__library-list">
|
||||
{libraries.map((library) => {
|
||||
{filteredLibraries.map((library) => {
|
||||
const selected = library.id === selectedLibrary?.id
|
||||
const libraryMeta = t('workspace.libraryMeta', {
|
||||
const libraryMeta = library.external ? `${externalProviderNames[library.external.provider]} · ${externalInstances.find(instance => instance.id === library.external?.instanceId)?.name ?? library.external.instanceId} · ${t(`external.states.${externalLibraryStatus(library, externalInstances)}`)}` : t('workspace.libraryMeta', {
|
||||
ready: formatNumber(
|
||||
library.indexedDocumentCount,
|
||||
locale
|
||||
@@ -4658,27 +4685,24 @@ export function KnowledgeWorkspace({
|
||||
title={t('errors.loadTitle')}
|
||||
/>
|
||||
) : creating ? (
|
||||
<>
|
||||
<SegmentedControl ariaLabel={t('external.type')} value={creationType} onChange={setCreationType} options={[{ value: 'local', label: t('external.local') }, ...Object.entries(externalProviderNames).map(([value, label]) => ({ value: value as ExternalKnowledgeProvider, label }))]} />
|
||||
{creationType === 'local' ?
|
||||
<CreateLibraryWizard
|
||||
onCancel={() => setCreating(false)}
|
||||
onCreate={onCreateLibrary}
|
||||
/>
|
||||
: <ExternalBindingForm key={creationType} provider={creationType} instances={externalInstances} libraries={libraries} onCancel={() => setCreating(false)} onChanged={onExternalChanged} notify={notify} onManage={() => setManagingExternal(true)} />}
|
||||
</>
|
||||
) : !selectedLibrary ? (
|
||||
<EmptyState
|
||||
action={
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => setCreating(true)}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" size={16} />
|
||||
{t('actions.newLibrary')}
|
||||
</button>
|
||||
}
|
||||
description={t('empty.description')}
|
||||
icon={<BookOpen size={34} />}
|
||||
level="page"
|
||||
title={t('empty.title')}
|
||||
/>
|
||||
) : selectedLibrary.external ? (
|
||||
<ExternalLibraryDetail key={selectedLibrary.id} library={selectedLibrary} instances={externalInstances} libraries={libraries} onChanged={onExternalChanged} notify={notify} onManage={() => setManagingExternal(true)} onUseInChat={onUseInChat} />
|
||||
) : (
|
||||
<>
|
||||
<header
|
||||
@@ -4948,6 +4972,7 @@ export function KnowledgeWorkspace({
|
||||
libraryName={selectedLibrary.name}
|
||||
onClose={() => setRetrievalOpen(false)}
|
||||
onOpenSource={(result) => {
|
||||
if (result.external || !result.documentId || !result.chunkId) return
|
||||
void Promise.resolve(
|
||||
onOpenReferenceSource({
|
||||
knowledgeBaseId: selectedLibrary.id,
|
||||
@@ -5007,6 +5032,7 @@ export function KnowledgeWorkspace({
|
||||
}
|
||||
}}
|
||||
onViewContext={(result) => {
|
||||
if (result.external || !result.documentId || !result.chunkId) return
|
||||
const document = libraryDocuments.find(
|
||||
(item) => item.id === result.documentId
|
||||
)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { createInstance } from 'i18next'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ProjectActivity, ProjectActivityCounts } from './ProjectActivity'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
|
||||
const i18n = createInstance()
|
||||
void i18n.init({
|
||||
lng: 'en',
|
||||
initImmediate: false,
|
||||
resources: { en: { workspace: { projectActivity: {
|
||||
title: 'All project activity', close: 'Close activity',
|
||||
attentionCount: '{{count}} need attention', runningCount: '{{count}} running',
|
||||
groups: { attention: 'Needs attention', running: 'Running' },
|
||||
status: { running: 'Running', approval: 'Waiting for approval', question: 'Waiting for your answer', attention: 'Needs attention' }
|
||||
} } } }
|
||||
})
|
||||
|
||||
const activities: ConversationActivity[] = [
|
||||
{ conversationId: 'running', projectId: 'local', projectName: 'Local', title: 'Build', status: 'running' },
|
||||
{ conversationId: 'question', projectId: 'remote', projectName: 'Remote', title: 'Choose target', status: 'question' },
|
||||
{ conversationId: 'approval', projectId: 'remote', projectName: 'Remote', title: 'Approve command', status: 'approval' },
|
||||
{ conversationId: 'unloaded', projectName: 'Channel', title: 'Background task', status: 'attention' }
|
||||
]
|
||||
|
||||
function view(rows = activities, onOpenConversation = vi.fn()): React.JSX.Element {
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<div className="app-shell">
|
||||
<button type="button">Destination</button>
|
||||
<ProjectActivity activities={rows} onOpenConversation={onOpenConversation} />
|
||||
</div>
|
||||
</I18nextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('ProjectActivity', () => {
|
||||
it('hides idle activity and zero counts', () => {
|
||||
render(view([]))
|
||||
expect(screen.queryByRole('button', { name: /All project activity/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
const { container } = render(
|
||||
<I18nextProvider i18n={i18n}><ProjectActivityCounts attention={0} running={0} /></I18nextProvider>
|
||||
)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('shows global counts and attention-first groups with precise labels', () => {
|
||||
render(view())
|
||||
fireEvent.click(screen.getByRole('button', { name: /All project activity 3 need attention 1 running/ }))
|
||||
const dialog = screen.getByRole('dialog', { name: 'All project activity' })
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
expect(within(dialog).getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent))
|
||||
.toEqual(['Needs attention', 'Running'])
|
||||
expect(within(dialog).getByRole('button', { name: 'Remote Choose target Waiting for your answer' })).toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('button', { name: 'Remote Approve command Waiting for approval' })).toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('button', { name: 'Channel Background task Needs attention' })).toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('button', { name: 'Local Build Running' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('isolates the background, traps Tab both ways, and restores focus on Escape', () => {
|
||||
const { container } = render(view())
|
||||
const trigger = screen.getByRole('button', { name: /All project activity/ })
|
||||
fireEvent.click(trigger)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const close = within(dialog).getByRole('button', { name: 'Close activity' })
|
||||
const last = within(dialog).getByRole('button', { name: 'Local Build Running' })
|
||||
expect(close).toHaveFocus()
|
||||
expect(container.querySelector<HTMLElement>('.app-shell')?.inert).toBe(true)
|
||||
fireEvent.keyDown(close, { key: 'Tab', shiftKey: true })
|
||||
expect(last).toHaveFocus()
|
||||
fireEvent.keyDown(last, { key: 'Tab' })
|
||||
expect(close).toHaveFocus()
|
||||
fireEvent.keyDown(close, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(container.querySelector<HTMLElement>('.app-shell')?.inert).toBe(false)
|
||||
})
|
||||
|
||||
it('navigates directly to an unloaded conversation without stealing destination focus', () => {
|
||||
const onOpen = vi.fn(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(document.querySelector<HTMLElement>('.app-shell')?.inert).toBe(false)
|
||||
screen.getByRole('button', { name: 'Destination' }).focus()
|
||||
})
|
||||
render(view(activities, onOpen))
|
||||
fireEvent.click(screen.getByRole('button', { name: /All project activity/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Channel Background task Needs attention' }))
|
||||
expect(onOpen).toHaveBeenCalledExactlyOnceWith('unloaded')
|
||||
expect(screen.getByRole('button', { name: 'Destination' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('updates live rows without refocusing and stays closed after becoming idle', () => {
|
||||
const { rerender } = render(view())
|
||||
fireEvent.click(screen.getByRole('button', { name: /All project activity/ }))
|
||||
const row = screen.getByRole('button', { name: 'Remote Choose target Waiting for your answer' })
|
||||
row.focus()
|
||||
rerender(view(activities.filter((activity) => activity.status !== 'running')))
|
||||
expect(row).toHaveFocus()
|
||||
expect(screen.queryByRole('region', { name: 'Running' })).not.toBeInTheDocument()
|
||||
rerender(view([]))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(document.querySelector<HTMLElement>('.app-shell')?.inert).toBe(false)
|
||||
rerender(view())
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('restores trigger focus when dismissed with the close button', () => {
|
||||
render(view())
|
||||
const trigger = screen.getByRole('button', { name: /All project activity/ })
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close activity' }))
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('still handles Escape when a background update removes the focused row', () => {
|
||||
const { rerender } = render(view())
|
||||
const trigger = screen.getByRole('button', { name: /All project activity/ })
|
||||
fireEvent.click(trigger)
|
||||
screen.getByRole('button', { name: 'Local Build Running' }).focus()
|
||||
rerender(view(activities.filter((activity) => activity.status !== 'running')))
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { ChevronRight, CircleAlert, LoaderCircle, X } from 'lucide-react'
|
||||
import { useEffect, useEffectEvent, useId, useRef, useState } from 'react'
|
||||
import { createPortal, flushSync } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ConversationActivity } from './conversation-activity'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import './project-activity.css'
|
||||
|
||||
export type ProjectActivityProps = {
|
||||
activities: ConversationActivity[]
|
||||
onOpenConversation: (id: string) => void
|
||||
}
|
||||
|
||||
export type ProjectActivityCountsProps = { running: number; attention: number }
|
||||
|
||||
export function ProjectActivityCounts({
|
||||
running,
|
||||
attention
|
||||
}: ProjectActivityCountsProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation('workspace')
|
||||
if (!running && !attention) return null
|
||||
return (
|
||||
<span className="project-activity__counts">
|
||||
{attention > 0 && (
|
||||
<span className="project-activity__attention">
|
||||
<CircleAlert aria-hidden="true" size={13} />
|
||||
{t('projectActivity.attentionCount', { count: attention })}
|
||||
</span>
|
||||
)}
|
||||
{' '}
|
||||
{running > 0 && (
|
||||
<span className="project-activity__running">
|
||||
<LoaderCircle aria-hidden="true" size={13} />
|
||||
{t('projectActivity.runningCount', { count: running })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityDialog({
|
||||
activities,
|
||||
onOpenConversation,
|
||||
onClose
|
||||
}: ProjectActivityProps & { onClose: () => void }): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
const titleId = useId()
|
||||
const handleKeyDown = useEffectEvent((event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onClose()
|
||||
} else {
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
}
|
||||
})
|
||||
useEffect(() => {
|
||||
const releaseFocus = activateModalFocus(
|
||||
() => dialogRef.current?.querySelector<HTMLButtonElement>('button') ?? null
|
||||
)
|
||||
const onKeyDown = (event: KeyboardEvent): void => handleKeyDown(event)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
releaseFocus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="project-activity__backdrop"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<section
|
||||
aria-labelledby={titleId}
|
||||
aria-modal="true"
|
||||
className="project-activity__dialog"
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
>
|
||||
<header className="project-activity__header">
|
||||
<h2 id={titleId}>{t('projectActivity.title')}</h2>
|
||||
<button
|
||||
aria-label={t('projectActivity.close')}
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
title={t('projectActivity.close')}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="project-activity__content">
|
||||
{(['attention', 'running'] as const).map((group) => {
|
||||
const rows = activities.filter((activity) =>
|
||||
group === 'running' ? activity.status === 'running' : activity.status !== 'running'
|
||||
)
|
||||
if (!rows.length) return null
|
||||
return (
|
||||
<section aria-labelledby={`${titleId}-${group}`} key={group}>
|
||||
<h3 id={`${titleId}-${group}`}>{t(`projectActivity.groups.${group}`)}</h3>
|
||||
<ul>
|
||||
{rows.map((activity) => (
|
||||
<li key={activity.conversationId}>
|
||||
<button
|
||||
className="project-activity__row"
|
||||
onClick={() => {
|
||||
// Release modal isolation and restore focus before navigation owns it.
|
||||
flushSync(onClose)
|
||||
onOpenConversation(activity.conversationId)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="project-activity__identity">
|
||||
<small>{activity.projectName}</small>{' '}
|
||||
<span>{activity.title}</span>
|
||||
</span>{' '}
|
||||
<span className={`project-activity__${group}`}>
|
||||
{t(`projectActivity.status.${activity.status}`)}
|
||||
</span>
|
||||
<ChevronRight aria-hidden="true" size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectActivity({
|
||||
activities,
|
||||
onOpenConversation
|
||||
}: ProjectActivityProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation('workspace')
|
||||
const [open, setOpen] = useState(false)
|
||||
if (!activities.length) {
|
||||
if (open) setOpen(false)
|
||||
return null
|
||||
}
|
||||
const running = activities.filter((activity) => activity.status === 'running').length
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
className="project-activity__summary"
|
||||
onClick={(event) => {
|
||||
event.currentTarget.focus()
|
||||
setOpen(true)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>{t('projectActivity.title')}</span>{' '}
|
||||
<ProjectActivityCounts attention={activities.length - running} running={running} />
|
||||
<ChevronRight aria-hidden="true" size={14} />
|
||||
</button>
|
||||
{open && (
|
||||
<ActivityDialog
|
||||
activities={activities}
|
||||
onClose={() => setOpen(false)}
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -75,6 +75,7 @@ const project: AssistantProject = {
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
await i18n.changeLanguage('zh-CN')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
@@ -308,6 +309,50 @@ function installRemoteApi(
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProjectSwitcher project activity integration', () => {
|
||||
it('keeps per-project title counts and menu ordering stable across recovery and Host states', async () => {
|
||||
const api = installRemoteApi({ connectionState: 'connecting' })
|
||||
const remote: AssistantProject = {
|
||||
...project, id: remoteProjectId, name: 'Remote activity',
|
||||
executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/srv/project' }
|
||||
}
|
||||
const idle = { ...remote, id: 'idle-project', name: 'Idle remote' }
|
||||
const props = {
|
||||
activeProjectId: project.id, projects: [project, remote, idle],
|
||||
remoteProjectsEnabled: true, onArchive: vi.fn(), onCreate: vi.fn(),
|
||||
onDelete: vi.fn(), onRemoteCommitted: vi.fn(), onSelect: vi.fn(),
|
||||
onSelectRoot: vi.fn(), onUpdate: vi.fn(),
|
||||
activityByProjectId: {
|
||||
[project.id]: { running: 1, attention: 0 },
|
||||
[remote.id]: { running: 2, attention: 3 }
|
||||
}
|
||||
}
|
||||
const { rerender } = render(<ProjectSwitcher {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
|
||||
const menu = screen.getByRole('menu', { name: '当前项目' })
|
||||
await within(menu).findByRole('group', { name: 'SSH 主机:Build host' })
|
||||
for (const stage of ['agent', 'failed', 'completed'] as const) {
|
||||
rerender(<ProjectSwitcher {...props} recoveryByProjectId={{
|
||||
[remote.id]: { projectId: remote.id, requestId: 'activity-recovery', stage,
|
||||
message: 'Host unreachable', retryable: true }
|
||||
}} />)
|
||||
act(() => api.emitConnectionStatus(stage === 'agent' ? 'connecting' : stage === 'failed' ? 'disconnected' : 'ready'))
|
||||
const rows = within(menu).getAllByRole('menuitemradio')
|
||||
expect(rows.map((row) => row.querySelector('b')?.textContent))
|
||||
.toEqual(['Local project', 'Remote activity', 'Idle remote'])
|
||||
expect(rows[0]!.querySelector('.project-switcher__project-heading'))
|
||||
.toHaveTextContent('Local project 1 个运行中')
|
||||
const heading = rows[1]!.querySelector('.project-switcher__project-heading')!
|
||||
expect(heading).toHaveTextContent('Remote activity3 个待处理 2 个运行中')
|
||||
expect(heading.querySelector('b')?.nextElementSibling).toHaveClass('project-activity__counts')
|
||||
expect(rows[2]!.querySelector('.project-activity__counts')).toBeNull()
|
||||
}
|
||||
fireEvent.click(within(menu).getByRole('menuitemradio', { name: /Remote activity/u }))
|
||||
expect(props.onSelect).toHaveBeenCalledExactlyOnceWith(remote.id)
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProjectSwitcher runtime fields', () => {
|
||||
it('creates an ordinary project with DeepSeek Harness', async () => {
|
||||
const { onCreate } = renderSwitcher()
|
||||
@@ -611,6 +656,112 @@ describe('ProjectSwitcher managed SSH projects', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expires completion feedback once per request across project switches and menu mounts', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
installRemoteApi()
|
||||
vi.useFakeTimers()
|
||||
const remote: AssistantProject = {
|
||||
...project,
|
||||
id: remoteProjectId,
|
||||
executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/srv/project' }
|
||||
}
|
||||
const props = {
|
||||
onArchive: vi.fn(), onCreate: vi.fn(), onDelete: vi.fn(),
|
||||
onRemoteCommitted: vi.fn(), onSelect: vi.fn(),
|
||||
onSelectRoot: vi.fn(), onUpdate: vi.fn(),
|
||||
projects: [project, remote], remoteProjectsEnabled: true
|
||||
}
|
||||
const completed: RemoteProjectRecoveryState = {
|
||||
projectId: remote.id,
|
||||
requestId: '00000000-0000-4000-8000-000000000301',
|
||||
stage: 'completed'
|
||||
}
|
||||
const view = (activeProjectId: string, state: RemoteProjectRecoveryState = completed) => (
|
||||
<ProjectSwitcher {...props} activeProjectId={activeProjectId}
|
||||
recoveryByProjectId={{ [remote.id]: { ...state } }} />
|
||||
)
|
||||
const { rerender, unmount } = render(view(remote.id))
|
||||
const trigger = screen.getByRole('button', { name: 'Current project' })
|
||||
expect(within(trigger).getByRole('status')).toHaveTextContent('Recovery completed')
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
|
||||
rerender(view(project.id))
|
||||
fireEvent.click(trigger)
|
||||
expect(within(screen.getByRole('menu')).getByRole('status'))
|
||||
.toHaveTextContent('Recovery completed')
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(999) })
|
||||
expect(screen.getByText('Recovery completed')).toBeInTheDocument()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1) })
|
||||
expect(screen.queryByText('Recovery completed')).not.toBeInTheDocument()
|
||||
fireEvent.click(trigger)
|
||||
rerender(view(remote.id))
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByText('Recovery completed')).not.toBeInTheDocument()
|
||||
expect(completed.stage).toBe('completed')
|
||||
|
||||
const next = { ...completed, requestId: '00000000-0000-4000-8000-000000000302' }
|
||||
rerender(view(remote.id, { ...next, stage: 'network' }))
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
|
||||
expect(within(trigger).getByRole('status')).toHaveAttribute('aria-busy', 'true')
|
||||
rerender(view(remote.id, next))
|
||||
expect(screen.getAllByText('Recovery completed')).toHaveLength(2)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
|
||||
expect(screen.queryByText('Recovery completed')).not.toBeInTheDocument()
|
||||
rerender(view(remote.id, { ...next, requestId: 'new-request' }))
|
||||
unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('expires background completions independently and cancels obsolete completion timers', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
installRemoteApi()
|
||||
vi.useFakeTimers()
|
||||
const remote: AssistantProject = {
|
||||
...project, id: remoteProjectId,
|
||||
executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/srv/project' }
|
||||
}
|
||||
const second = { ...remote, id: 'second-project', name: 'Second remote' }
|
||||
const onRetryRecovery = vi.fn(async () => undefined)
|
||||
const props = {
|
||||
activeProjectId: project.id,
|
||||
onArchive: vi.fn(), onCreate: vi.fn(), onDelete: vi.fn(),
|
||||
onRemoteCommitted: vi.fn(), onSelect: vi.fn(), onRetryRecovery,
|
||||
onSelectRoot: vi.fn(), onUpdate: vi.fn(),
|
||||
projects: [project, remote, second], remoteProjectsEnabled: true
|
||||
}
|
||||
const firstState: RemoteProjectRecoveryState = {
|
||||
projectId: remote.id, requestId: 'first-request', stage: 'completed'
|
||||
}
|
||||
const secondState: RemoteProjectRecoveryState = {
|
||||
projectId: second.id, requestId: 'second-request', stage: 'completed'
|
||||
}
|
||||
const { rerender } = render(<ProjectSwitcher {...props}
|
||||
recoveryByProjectId={{ [remote.id]: firstState }} />)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
|
||||
rerender(<ProjectSwitcher {...props} recoveryByProjectId={{
|
||||
[remote.id]: { ...firstState }, [second.id]: secondState
|
||||
}} />)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
|
||||
const menu = screen.getByRole('menu')
|
||||
expect(within(menu).getAllByText('Recovery completed')).toHaveLength(1)
|
||||
expect(within(menu).getByRole('menuitemradio', { name: /Second remote/u }))
|
||||
.toHaveTextContent('Recovery completed')
|
||||
rerender(<ProjectSwitcher {...props} recoveryByProjectId={{
|
||||
[remote.id]: firstState,
|
||||
[second.id]: { ...secondState, requestId: 'retry-request', stage: 'failed',
|
||||
message: 'Host unreachable', retryable: true }
|
||||
}} />)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
|
||||
expect(within(menu).getByRole('alert')).toHaveTextContent('Host unreachable')
|
||||
await act(async () => {
|
||||
fireEvent.click(within(menu).getByRole('menuitem', {
|
||||
name: 'Retry recovery for project Second remote'
|
||||
}))
|
||||
})
|
||||
expect(onRetryRecovery).toHaveBeenCalledWith(second.id)
|
||||
expect(screen.queryByText('Recovery completed')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an accessible failure and retries only the affected project', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
const remoteProject: AssistantProject = {
|
||||
|
||||
@@ -50,6 +50,8 @@ import {
|
||||
} from './ChannelProjectSettingsFields'
|
||||
import { getProjectDisplayText } from './project-display'
|
||||
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
|
||||
import { ProjectActivityCounts } from './ProjectActivity'
|
||||
import type { ConversationActivitySummary } from './conversation-activity'
|
||||
import { ProjectWorkModeFields } from './ProjectWorkModeFields'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import { displayErrorMessage } from './error-message'
|
||||
@@ -57,6 +59,7 @@ import { displayErrorMessage } from './error-message'
|
||||
type ProjectSwitcherProps = {
|
||||
projects: AssistantProject[]
|
||||
activeProjectId: string
|
||||
activityByProjectId?: ConversationActivitySummary['byProjectId']
|
||||
remoteProjectsEnabled?: boolean
|
||||
runtimeSettings?: RuntimeSettings
|
||||
onArchive: (projectId: string) => Promise<void>
|
||||
@@ -207,6 +210,7 @@ function isRemoteAbsolutePath(value: string): boolean {
|
||||
export function ProjectSwitcher({
|
||||
projects,
|
||||
activeProjectId,
|
||||
activityByProjectId = {},
|
||||
remoteProjectsEnabled = false,
|
||||
runtimeSettings,
|
||||
onArchive,
|
||||
@@ -281,6 +285,55 @@ export function ProjectSwitcher({
|
||||
const [projectMenuOpen, setProjectMenuOpen] = useState(false)
|
||||
const [retryingRecoveryProjectId, setRetryingRecoveryProjectId] =
|
||||
useState<string>()
|
||||
const [dismissedRecoveryByProjectId, setDismissedRecoveryByProjectId] =
|
||||
useState<Record<string, string>>({})
|
||||
const recoveryCompletionTimers = useRef(
|
||||
new Map<string, { requestId: string; timer: ReturnType<typeof setTimeout> }>()
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const timers = recoveryCompletionTimers.current
|
||||
for (const [projectId, entry] of timers) {
|
||||
const state = recoveryByProjectId[projectId]
|
||||
if (state?.stage !== 'completed' || state.requestId !== entry.requestId) {
|
||||
clearTimeout(entry.timer)
|
||||
timers.delete(projectId)
|
||||
}
|
||||
}
|
||||
for (const [projectId, state] of Object.entries(recoveryByProjectId)) {
|
||||
if (state.stage !== 'completed' || timers.has(projectId)) {
|
||||
continue
|
||||
}
|
||||
// Keep the request entry after expiry so menu mounts and snapshots cannot replay it.
|
||||
timers.set(projectId, {
|
||||
requestId: state.requestId,
|
||||
timer: setTimeout(() => {
|
||||
setDismissedRecoveryByProjectId((current) => ({
|
||||
...current,
|
||||
[projectId]: state.requestId
|
||||
}))
|
||||
}, 3_000)
|
||||
})
|
||||
}
|
||||
}, [recoveryByProjectId])
|
||||
|
||||
useEffect(() => {
|
||||
const timers = recoveryCompletionTimers.current
|
||||
return () => {
|
||||
for (const entry of timers.values()) {
|
||||
clearTimeout(entry.timer)
|
||||
}
|
||||
timers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const visibleRecoveryByProjectId = Object.fromEntries(
|
||||
Object.entries(recoveryByProjectId).filter(
|
||||
([projectId, state]) =>
|
||||
state.stage !== 'completed' ||
|
||||
dismissedRecoveryByProjectId[projectId] !== state.requestId
|
||||
)
|
||||
)
|
||||
const applySshHostsSnapshot = useCallback(
|
||||
(
|
||||
snapshot: SshHostsSnapshot,
|
||||
@@ -357,7 +410,7 @@ export function ProjectSwitcher({
|
||||
: undefined
|
||||
const activeProjectRecovery =
|
||||
activeProject?.executionSpace.kind === 'ssh'
|
||||
? recoveryByProjectId[activeProject.id]
|
||||
? visibleRecoveryByProjectId[activeProject.id]
|
||||
: undefined
|
||||
const settingsProject = projects.find(
|
||||
(project) => project.id === settingsProjectId
|
||||
@@ -1019,7 +1072,7 @@ export function ProjectSwitcher({
|
||||
const projectDisplay = getProjectDisplayText(project, t)
|
||||
const recovery =
|
||||
project.executionSpace.kind === 'ssh'
|
||||
? recoveryByProjectId[project.id]
|
||||
? visibleRecoveryByProjectId[project.id]
|
||||
: undefined
|
||||
const detail =
|
||||
project.kind === 'channel'
|
||||
@@ -1062,7 +1115,12 @@ export function ProjectSwitcher({
|
||||
>
|
||||
<ProjectIcon aria-hidden="true" size={16} />
|
||||
<span>
|
||||
<b>{projectDisplay.name}</b>
|
||||
<span className="project-switcher__project-heading">
|
||||
<b>{projectDisplay.name}</b>
|
||||
{activityByProjectId[project.id] && (
|
||||
<ProjectActivityCounts {...activityByProjectId[project.id]!} />
|
||||
)}
|
||||
</span>
|
||||
<small>{detail}</small>
|
||||
{recovery && <ProjectRecoveryStatus state={recovery} />}
|
||||
</span>
|
||||
@@ -1388,9 +1446,10 @@ export function ProjectSwitcher({
|
||||
onClick={() => closeDialog()}
|
||||
type="button"
|
||||
>
|
||||
<X size={14} />
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="project-create-card__body">
|
||||
{dialogMode === 'settings' &&
|
||||
settingsProject?.kind === 'channel' &&
|
||||
runtimeSettings ? (
|
||||
@@ -2019,6 +2078,7 @@ export function ProjectSwitcher({
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<div className="project-create-card__actions">
|
||||
{dialogMode === 'settings' &&
|
||||
settingsProject?.kind !== 'channel' &&
|
||||
|
||||
@@ -121,9 +121,9 @@ describe('WorkspacePrimitives', () => {
|
||||
expect(capabilitiesTabs).toMatch(/flex:\s*0 0 auto;/u)
|
||||
})
|
||||
|
||||
it('keeps whole-surface dialog scrollbars outside close-button hitboxes', () => {
|
||||
it('keeps dialog scrollbars outside close-button hitboxes', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/:is\(\s*\.project-create-card,\s*\.knowledge-citation-dialog__surface,\s*\.channel-qr-dialog,\s*\.document-parsing-diagnostic,\s*\.mcp-editor\s*\)\s*\{[^}]*scrollbar-gutter:\s*stable;/u
|
||||
/:is\(\s*\.project-create-card__body,\s*\.knowledge-citation-dialog__surface,\s*\.channel-qr-dialog,\s*\.document-parsing-diagnostic,\s*\.mcp-editor\s*\)\s*\{[^}]*scrollbar-gutter:\s*stable;/u
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { deriveConversationActivity } from './conversation-activity'
|
||||
|
||||
const projects = [{ id: 'local', name: 'Local' }, { id: 'remote', name: 'Remote' }]
|
||||
|
||||
describe('deriveConversationActivity', () => {
|
||||
it('counts each conversation once with question and approval ahead of task fallback', () => {
|
||||
const conversations = [
|
||||
{ id: 'question', title: 'Question', projectId: 'remote', messages: [
|
||||
{ state: 'streaming', approval: {} }, { pendingQuestions: [{}, {}] }
|
||||
] },
|
||||
{ id: 'approval', title: 'Approval', projectId: 'local', messages: [{ approval: {} }] },
|
||||
{ id: 'fallback', title: 'Fallback', projectId: 'remote', messages: [{ state: 'streaming' }] }
|
||||
]
|
||||
const tasks = conversations.flatMap((conversation) => [
|
||||
{ conversationId: conversation.id, title: 'Task', status: 'running' },
|
||||
{ conversationId: conversation.id, title: 'Task', status: 'waiting_approval' }
|
||||
])
|
||||
const summary = deriveConversationActivity(
|
||||
conversations, tasks, new Set(conversations.map(({ id }) => id)), projects, 'Unknown'
|
||||
)
|
||||
expect(summary.activities.map(({ status }) => status)).toEqual(['question', 'approval', 'attention'])
|
||||
expect(summary).toMatchObject({
|
||||
running: 0, attention: 3,
|
||||
byProjectId: { remote: { running: 0, attention: 2 }, local: { running: 0, attention: 1 } }
|
||||
})
|
||||
expect(deriveConversationActivity(conversations, [...tasks].reverse(), new Set(), projects, 'Unknown'))
|
||||
.toEqual(summary)
|
||||
})
|
||||
|
||||
it('includes background active IDs, streams, and tasks with unloaded conversation metadata', () => {
|
||||
const summary = deriveConversationActivity([
|
||||
{ id: 'active', title: 'Active', projectId: 'local', messages: [] },
|
||||
{ id: 'stream', title: 'Stream', projectId: 'remote', messages: [{ state: 'streaming' }] },
|
||||
{ id: 'known', title: 'Conversation title', messages: [] }
|
||||
], [
|
||||
{ conversationId: 'unloaded', projectId: 'remote', title: 'Task title', status: 'running' },
|
||||
{ conversationId: 'unloaded', projectId: 'remote', title: 'Task title', status: 'waiting_approval' },
|
||||
{ conversationId: 'known', projectId: 'local', title: 'Wrong title', status: 'running' },
|
||||
{ conversationId: 'missing-project', projectId: 'deleted', title: 'Missing project', status: 'running' }
|
||||
], new Set(['active', 'no-metadata']), projects, 'Unknown')
|
||||
expect(summary).toMatchObject({
|
||||
running: 4, attention: 1,
|
||||
byProjectId: {
|
||||
local: { running: 1, attention: 0 },
|
||||
remote: { running: 1, attention: 1 },
|
||||
deleted: { running: 1, attention: 0 }
|
||||
}
|
||||
})
|
||||
expect(summary.activities).toContainEqual({
|
||||
conversationId: 'unloaded', projectId: 'remote', title: 'Task title', projectName: 'Remote', status: 'attention'
|
||||
})
|
||||
expect(summary.activities).toContainEqual({
|
||||
conversationId: 'known', projectId: undefined, title: 'Conversation title', projectName: 'Unknown', status: 'running'
|
||||
})
|
||||
})
|
||||
|
||||
it('excludes idle, queued and terminal work and tasks without conversation IDs', () => {
|
||||
const summary = deriveConversationActivity([
|
||||
{ id: 'idle', title: 'Idle', messages: [{ state: 'complete', approval: null, pendingQuestions: [] }] }
|
||||
], [
|
||||
...['queued', 'completed', 'failed', 'interrupted', 'cancelled', 'paused'].map((status) => ({
|
||||
conversationId: status, title: status, status
|
||||
})),
|
||||
{ title: 'No conversation', status: 'running' }
|
||||
], new Set(), projects, 'Unknown')
|
||||
expect(summary).toEqual({ activities: [], byProjectId: {}, running: 0, attention: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
export type ConversationActivity = {
|
||||
conversationId: string
|
||||
projectId?: string
|
||||
title: string
|
||||
projectName: string
|
||||
status: 'running' | 'approval' | 'question' | 'attention'
|
||||
}
|
||||
|
||||
export type ConversationActivitySummary = {
|
||||
activities: ConversationActivity[]
|
||||
byProjectId: Record<string, { running: number; attention: number }>
|
||||
running: number
|
||||
attention: number
|
||||
}
|
||||
|
||||
export function deriveConversationActivity(
|
||||
conversations: readonly {
|
||||
id: string
|
||||
title: string
|
||||
projectId?: string
|
||||
messages: readonly { state?: string; approval?: unknown; pendingQuestions?: readonly unknown[] }[]
|
||||
}[],
|
||||
tasks: readonly {
|
||||
conversationId?: string
|
||||
projectId?: string
|
||||
title: string
|
||||
status: string
|
||||
}[],
|
||||
activeConversationIds: ReadonlySet<string>,
|
||||
projects: readonly { id: string; name: string }[],
|
||||
fallbackProjectName: string
|
||||
): ConversationActivitySummary {
|
||||
const projectNames = new Map(projects.map((project) => [project.id, project.name]))
|
||||
const metadata = new Map(conversations.map((conversation) => [conversation.id, conversation]))
|
||||
const rows = new Map<string, ConversationActivity>()
|
||||
const priority = { running: 0, attention: 1, approval: 2, question: 3 }
|
||||
|
||||
for (const conversation of conversations) {
|
||||
let status: ConversationActivity['status'] | undefined =
|
||||
activeConversationIds.has(conversation.id) ? 'running' : undefined
|
||||
for (const message of conversation.messages) {
|
||||
const candidate = message.pendingQuestions?.length
|
||||
? 'question'
|
||||
: message.approval
|
||||
? 'approval'
|
||||
: message.state === 'streaming'
|
||||
? 'running'
|
||||
: undefined
|
||||
if (candidate && (!status || priority[candidate] > priority[status])) {
|
||||
status = candidate
|
||||
}
|
||||
}
|
||||
if (status) {
|
||||
rows.set(conversation.id, {
|
||||
conversationId: conversation.id,
|
||||
projectId: conversation.projectId,
|
||||
title: conversation.title,
|
||||
projectName: projectNames.get(conversation.projectId ?? '') ?? fallbackProjectName,
|
||||
status
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!task.conversationId || (task.status !== 'running' && task.status !== 'waiting_approval')) {
|
||||
continue
|
||||
}
|
||||
// Tasks use waiting_approval for both approvals and questions.
|
||||
const status = task.status === 'waiting_approval' ? 'attention' : 'running'
|
||||
const existing = rows.get(task.conversationId)
|
||||
if (existing) {
|
||||
if (priority[status] > priority[existing.status]) existing.status = status
|
||||
continue
|
||||
}
|
||||
const conversation = metadata.get(task.conversationId)
|
||||
const projectId = conversation ? conversation.projectId : task.projectId
|
||||
rows.set(task.conversationId, {
|
||||
conversationId: task.conversationId,
|
||||
projectId,
|
||||
title: conversation ? conversation.title : task.title,
|
||||
projectName: projectNames.get(projectId ?? '') ?? fallbackProjectName,
|
||||
status
|
||||
})
|
||||
}
|
||||
|
||||
const summary: ConversationActivitySummary = {
|
||||
activities: [...rows.values()],
|
||||
byProjectId: {},
|
||||
running: 0,
|
||||
attention: 0
|
||||
}
|
||||
const counts = new Map<string, { running: number; attention: number }>()
|
||||
for (const activity of summary.activities) {
|
||||
const category = activity.status === 'running' ? 'running' : 'attention'
|
||||
summary[category] += 1
|
||||
if (activity.projectId !== undefined) {
|
||||
const project = counts.get(activity.projectId) ?? { running: 0, attention: 0 }
|
||||
project[category] += 1
|
||||
counts.set(activity.projectId, project)
|
||||
}
|
||||
}
|
||||
summary.byProjectId = Object.fromEntries(counts)
|
||||
return summary
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.external-knowledge { display: flex; flex-direction: column; gap: var(--space-4); padding: var(--space-4); min-width: 0; min-height: 0; overflow: auto; }
|
||||
.external-knowledge .external-knowledge { padding: 0; overflow: visible; }
|
||||
.external-knowledge__columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-6); }
|
||||
.external-knowledge__fields { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; }
|
||||
.external-knowledge__fields h3, .external-knowledge p, .external-knowledge-modal p { margin: 0; }
|
||||
.external-knowledge__fields > button { align-self: flex-start; }
|
||||
.external-knowledge__group { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; margin: 0; padding: var(--space-3) 0 0; border: 0; border-top: 1px solid var(--border-subtle); }
|
||||
.external-knowledge__group legend { padding: 0 var(--space-2) 0 0; font-size: var(--font-section-title); font-weight: 650; }
|
||||
.external-knowledge__help { color: var(--text-secondary); font-size: var(--font-caption); line-height: 1.5; }
|
||||
.external-knowledge__help summary { cursor: pointer; }
|
||||
.external-knowledge__help[open] summary { margin-bottom: var(--space-2); }
|
||||
.external-knowledge__password { display: flex; align-items: center; gap: var(--space-2); }
|
||||
.external-knowledge__password input { min-width: 0; flex: 1; }
|
||||
.external-knowledge__password .icon-button { flex: 0 0 auto; }
|
||||
.external-knowledge__instance-select > .model-button { width: 100%; }
|
||||
.external-knowledge__instance-select > .runtime-picker__menu { position: static; width: 100%; }
|
||||
.external-knowledge__instance-select > .runtime-picker__menu > button > small { white-space: normal; overflow-wrap: anywhere; }
|
||||
.external-knowledge__footer { position: sticky; bottom: calc(-1 * var(--space-4)); padding: var(--space-3) 0; background: var(--surface-raised); border-top: 1px solid var(--border-subtle); justify-content: flex-end; }
|
||||
.external-knowledge__footer > .scope-badge { margin-right: auto; }
|
||||
.external-knowledge__actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
|
||||
.external-knowledge__catalog { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.external-knowledge__target { display: flex; text-align: left; gap: var(--space-2); padding: var(--space-3); border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); color: var(--text-primary); }
|
||||
.external-knowledge__target[aria-pressed='true'] { background: var(--accent-subtle); border-color: var(--accent-selected); }
|
||||
.external-knowledge__target span { min-width: 0; }
|
||||
.external-knowledge__target small, .external-knowledge__instances small, .external-knowledge__results small { display: block; color: var(--text-secondary); overflow-wrap: anywhere; }
|
||||
.external-knowledge__identifier, .external-knowledge__results p { overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.external-knowledge__results li { padding: var(--space-3); border-bottom: 1px solid var(--border-default); }
|
||||
.external-knowledge__instances { list-style: none; padding: 0; margin: var(--space-3) 0 0; }
|
||||
.external-knowledge__instances li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-4); padding-block: var(--space-4); border-bottom: 1px solid var(--border-default); }
|
||||
.external-knowledge-modal { position: fixed; inset: 0; z-index: var(--z-dialog); display: grid; place-items: center; padding: var(--space-4); background: var(--overlay-backdrop); }
|
||||
.external-knowledge-modal__surface { width: min(560px, 100%); max-height: calc(100dvh - 2 * var(--space-4)); min-width: 0; display: flex; flex-direction: column; background: var(--surface-raised); border: 1px solid var(--border-default); border-radius: var(--radius-card); box-shadow: var(--shadow-dialog); overflow: hidden; }
|
||||
.external-knowledge-modal__surface > header { display: flex; justify-content: space-between; align-items: center; padding: var(--space-4); border-bottom: 1px solid var(--border-default); }
|
||||
.external-knowledge-modal__surface h2 { margin: 0; font-size: var(--font-section-title); }
|
||||
.external-knowledge-modal__surface > header > div { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
|
||||
.external-knowledge-modal__body { padding: var(--space-4); min-height: 0; overflow: auto; }
|
||||
.external-knowledge, .external-knowledge-modal__body { scroll-padding-block: var(--space-3) calc(var(--control-height) + 2 * var(--space-4)); }
|
||||
.external-knowledge__fieldset :is(input, select, textarea, summary) { scroll-margin-block: var(--space-3) calc(var(--control-height) + 2 * var(--space-4)); }
|
||||
.external-knowledge-modal__body .external-knowledge__columns { gap: var(--space-3); }
|
||||
.external-knowledge-modal__body .external-knowledge__fieldset { gap: var(--space-3); }
|
||||
.external-knowledge dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--space-2) var(--space-4); margin: 0; }
|
||||
.external-knowledge dt { color: var(--text-secondary); }
|
||||
.external-knowledge dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.external-knowledge > header h2 { margin: 0 0 var(--space-2); font-size: var(--font-section-title); }
|
||||
.external-knowledge > .page-tabs { flex: 0 0 auto; }
|
||||
.external-knowledge__fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
.external-knowledge__filters { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-3); flex: 0 0 auto; }
|
||||
.external-knowledge > header, .external-knowledge-modal__surface > header { flex: 0 0 auto; }
|
||||
.external-knowledge__target:hover:not(:disabled):not([aria-pressed='true']) { background: var(--surface-subtle); }
|
||||
.external-knowledge__target:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.external-knowledge__target:disabled { opacity: 0.6; }
|
||||
.external-knowledge__target strong, .external-knowledge__instances strong { overflow-wrap: anywhere; }
|
||||
@media (max-width: 959px) { .external-knowledge__columns { grid-template-columns: minmax(0, 1fr); } }
|
||||
@@ -2,9 +2,57 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { knowledge as chineseKnowledge } from '../zh-CN/knowledge'
|
||||
|
||||
export const knowledge = {
|
||||
external: {
|
||||
close: 'Close instance manager', deleteInstanceTitle: 'Delete instance “{{name}}”?',
|
||||
clearSearch: 'Clear search', noResultsHelp: 'Try a different name or clear the search to see available libraries.',
|
||||
emptyHelp: 'Check access on the remote service, or enter a known library ID manually.',
|
||||
sourceFilter: 'Library source filter', clearFilters: 'Clear filters', weightedScore: 'Weighted score',
|
||||
saveThenTest: 'Save the instance, then use Test connection in the instance list.',
|
||||
rebindConfirm: 'Save this binding with the newly selected remote library. Remote data will not change.',
|
||||
probe: { untested: 'Not tested', 'catalog-ready': 'Catalog available', healthy: 'Connected', 'list-restricted': 'Catalog restricted; enter a library ID manually', 'auth-failed': 'Authentication failed', unreachable: 'Service unreachable', failed: 'Connection failed' },
|
||||
credentials: { configured: 'Credential configured', missing: 'Credential missing', unavailable: 'Credential unavailable' },
|
||||
manage: 'Manage external instances', instances: 'External instances', addInstance: 'Add instance',
|
||||
type: 'Library type', local: 'Local', provider: 'Provider', instance: 'External instance',
|
||||
editInstance: 'Edit instance', authentication: 'Authentication', credentialAction: 'Saved credential', connectionHelp: 'How to test the connection',
|
||||
enabledHelp: 'Enabled bindings send retrieval queries to this service.',
|
||||
address: 'Service URL', credential: 'API Key', keep: 'Keep saved credential', replace: 'Replace credential', clear: 'Clear credential',
|
||||
credentialNote: 'Credentials are encrypted locally and are never returned after saving.', showKey: 'Show credential', hideKey: 'Hide credential',
|
||||
clearConfirm: 'Clearing the credential makes libraries using this instance unavailable for retrieval.',
|
||||
transportNote: 'This HTTP address sends credentials and queries without transport encryption.',
|
||||
transportConfirm: 'I understand and allow credentials to be sent to this address', enabled: 'Enable instance',
|
||||
testConnection: 'Test connection', saveInstance: 'Save instance', saved: 'Saved', removed: 'Removed from GoodBuddy',
|
||||
noInstances: 'No external instances configured', noInstancesHelp: 'Add your existing Dify, FastGPT, or RAGFlow service.',
|
||||
selectInstance: 'Select an external instance', remote: 'Remote library', remoteId: 'Remote library ID', remoteName: 'Remote name',
|
||||
manual: 'Enter library ID manually', browse: 'Choose from catalog', search: 'Search remote libraries', refresh: 'Refresh library catalog',
|
||||
loading: 'Loading libraries…', empty: 'No accessible libraries in this instance', noResults: 'No matching results',
|
||||
next: 'Next page', previous: 'Previous page', root: 'Back to root', folder: 'Open folder', alreadyBound: 'Already added',
|
||||
verify: 'Verify library', source: 'Library source', config: 'Retrieval settings', common: 'Common settings',
|
||||
resultLimit: 'Returned snippets', timeout: 'Request timeout (ms)', snippetLimit: 'Maximum characters per snippet',
|
||||
localOnly: 'Settings are saved only in GoodBuddy for subsequent retrievals through this binding. They do not change remote library settings.',
|
||||
queryNote: 'Only the current query is sent to the selected instance. Files and full conversations are not uploaded, and no GoodBuddy model is called. Retrieval may incur remote Embedding, Rerank usage and API records.',
|
||||
query: 'Test query', test: 'Test retrieval', testing: 'Testing…', tested: 'Retrieval succeeded with {{count}} snippets',
|
||||
untested: 'Enter a query and successfully test retrieval before saving. Zero results also allow saving. Saving runs the query again to verify the current settings.',
|
||||
add: 'Add library', saveConfig: 'Save retrieval settings', defaults: 'Restore defaults',
|
||||
datasetDefaults: 'Use dataset defaults', datasetDefaultsHelp: 'Each request uses the current remote settings. Remote changes will affect results.',
|
||||
searchMethod: 'Search method', topK: 'Provider Top K', thresholdEnabled: 'Enable score threshold', threshold: 'Similarity threshold',
|
||||
rerank: 'Enable Rerank', rerankMode: 'Reranking mode', rerankProvider: 'Reranking provider', rerankModel: 'Reranking model',
|
||||
keywordWeight: 'Keyword weight', vectorWeight: 'Vector weight', embeddingProvider: 'Embedding provider', embeddingModel: 'Embedding model',
|
||||
tokenLimit: 'Token limit', knnTopK: 'KNN Top K', rerankId: 'Remote reranking model ID', rerankCandidates: 'Rerank candidates',
|
||||
graph: 'Graph retrieval', graphHelp: 'Search existing remote graph content without creating or updating the graph.', graphUnknown: 'Completed remote graph not confirmed',
|
||||
compilation: 'Include Knowledge Compilation', compilationUnavailable: 'Knowledge Compilation configuration not confirmed for this library',
|
||||
overview: 'Overview', rebind: 'Change remote library', refreshDetail: 'Refresh remote details', remove: 'Remove from GoodBuddy',
|
||||
removeBindingConfirm: 'Only the local binding and retrieval settings are deleted. Remote libraries and documents are preserved. Historical conversation citations remain readable.',
|
||||
deleteInstance: 'Delete instance and local bindings', deleteInstanceConfirm: 'This instance is used by {{count}} libraries. Deletion also removes their local bindings. Remote data will not change.',
|
||||
cancelRequest: 'Cancel request', remoteData: 'Knowledge stays on the remote service. Snippets used in answers are saved locally with the conversation.',
|
||||
status: 'Status', credentialStatus: 'Credential status', boundCount: '{{count}} bindings', lastTest: 'Last test', never: 'Not retrieved yet',
|
||||
snapshot: 'Historical citation snapshot', documentId: 'Remote document ID', chunkId: 'Remote snippet ID', providerScore: 'Provider score',
|
||||
elapsed: 'Total {{ms}} ms', returned: '{{returned}} returned · {{kept}} retained', truncated: 'Results truncated',
|
||||
allSources: 'All sources', searchLibraries: 'Search libraries', selectTarget: 'Select a remote library',
|
||||
states: { ready: 'Remote retrieval ready', untested: 'Not tested', 'instance-disabled': 'Instance disabled', 'credential-error': 'Credential unavailable', 'remote-missing': 'Remote target missing', 'config-invalid': 'Settings need confirmation', 'temporarily-unavailable': 'Temporarily unavailable' }
|
||||
},
|
||||
page: {
|
||||
title: 'Knowledge Base',
|
||||
description: 'Manage files, folders, and web sources, then inspect their indexes and graph.'
|
||||
description: 'Manage local knowledge and retrieve from existing enterprise knowledge services.'
|
||||
},
|
||||
actions: {
|
||||
cancel: 'Cancel',
|
||||
|
||||
@@ -2,6 +2,20 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { workspace as chineseWorkspace } from '../zh-CN/workspace'
|
||||
|
||||
export const workspace = {
|
||||
projectActivity: {
|
||||
title: 'All project activity',
|
||||
close: 'Close project activity',
|
||||
unassigned: 'No project',
|
||||
attentionCount: '{{count}} need attention',
|
||||
runningCount: '{{count}} running',
|
||||
groups: { attention: 'Needs attention', running: 'Running' },
|
||||
status: {
|
||||
running: 'Running',
|
||||
approval: 'Waiting for approval',
|
||||
question: 'Waiting for your answer',
|
||||
attention: 'Needs attention'
|
||||
}
|
||||
},
|
||||
management: {
|
||||
gitWorkspace: 'Git Workspace', more: 'More actions for {{name}}', browse: 'Browse directory', parent: 'Parent directory',
|
||||
createFile: 'New file', createDirectory: 'New directory', rename: 'Rename', move: 'Move', delete: 'Delete', properties: 'Properties',
|
||||
@@ -506,6 +520,9 @@ export const workspace = {
|
||||
empty: 'The workspace is empty.'
|
||||
},
|
||||
question: {
|
||||
task: 'Task: {{title}}',
|
||||
pendingCount: '{{count}} pending questions. Answer them in order.',
|
||||
locate: 'Go to question form',
|
||||
title: 'OpenCode needs more information',
|
||||
otherAnswer: 'Other answer',
|
||||
answerPlaceholder: 'Enter your answer',
|
||||
|
||||
@@ -1,7 +1,55 @@
|
||||
export const knowledge = {
|
||||
external: {
|
||||
close: '关闭实例管理', deleteInstanceTitle: '删除实例“{{name}}”?',
|
||||
clearSearch: '清除搜索', noResultsHelp: '换一个名称搜索,或清除搜索查看可用知识库。',
|
||||
emptyHelp: '请检查远端服务的访问权限,或手工填写已知的知识库 ID。',
|
||||
sourceFilter: '知识库来源筛选', clearFilters: '清除筛选', weightedScore: '加权分数',
|
||||
saveThenTest: '保存实例后,可在实例列表中测试连接。',
|
||||
rebindConfirm: '将此绑定保存为新选择的远端知识库,远端数据不会改变。',
|
||||
probe: { untested: '未测试', 'catalog-ready': '目录可用', healthy: '连接正常', 'list-restricted': '目录受限,可手工填写知识库 ID', 'auth-failed': '认证失败', unreachable: '服务无法连接', failed: '连接失败' },
|
||||
credentials: { configured: '已配置凭据', missing: '缺少凭据', unavailable: '凭据不可用' },
|
||||
manage: '管理外部实例', instances: '外部实例', addInstance: '添加实例',
|
||||
type: '知识库类型', local: '本地', provider: 'Provider', instance: '外部实例',
|
||||
editInstance: '编辑实例', authentication: '身份认证', credentialAction: '已保存凭据', connectionHelp: '如何测试连接',
|
||||
enabledHelp: '启用后,关联知识库的检索查询将发送到此服务。',
|
||||
address: '服务地址', credential: 'API Key', keep: '保留已保存凭据', replace: '替换凭据', clear: '清除凭据',
|
||||
credentialNote: '凭据由本机加密保存,保存后不会回显。', showKey: '显示凭据', hideKey: '隐藏凭据',
|
||||
clearConfirm: '清除凭据后,使用此实例的知识库将无法检索。',
|
||||
transportNote: '此 HTTP 地址通过未加密连接传输凭据和查询。',
|
||||
transportConfirm: '我了解并允许向此地址发送凭据', enabled: '启用实例',
|
||||
testConnection: '测试连接', saveInstance: '保存实例', saved: '已保存', removed: '已从 GoodBuddy 移除',
|
||||
noInstances: '尚未配置外部实例', noInstancesHelp: '添加企业现有的 Dify、FastGPT 或 RAGFlow 服务。',
|
||||
selectInstance: '选择外部实例', remote: '远端知识库', remoteId: '远端知识库 ID', remoteName: '远端名称',
|
||||
manual: '手工填写知识库 ID', browse: '从目录选择', search: '搜索远端知识库', refresh: '刷新知识库列表',
|
||||
loading: '正在读取知识库…', empty: '当前实例没有可访问的知识库', noResults: '没有符合条件的结果',
|
||||
next: '下一页', previous: '上一页', root: '返回根目录', folder: '打开文件夹', alreadyBound: '已添加',
|
||||
verify: '验证知识库', source: '知识库来源', config: '检索配置', common: '通用配置',
|
||||
resultLimit: '返回片段数', timeout: '请求超时(毫秒)', snippetLimit: '单片段字符上限',
|
||||
localOnly: '参数仅保存在 GoodBuddy,用于此绑定的后续检索,不会修改远端知识库设置。',
|
||||
queryNote: '只向所选实例发送当前查询,不上传文件或完整会话,不调用 GoodBuddy 模型。检索可能产生远端 Embedding、Rerank 用量及 API 使用记录。',
|
||||
query: '测试查询', test: '测试检索', testing: '正在测试…', tested: '检索成功,返回 {{count}} 条片段',
|
||||
untested: '请填写查询并成功测试检索后保存。零条结果也可保存。保存时会再次发送查询,验证当前参数。',
|
||||
add: '添加知识库', saveConfig: '保存检索参数', defaults: '恢复默认',
|
||||
datasetDefaults: '使用知识库默认配置', datasetDefaultsHelp: '每次检索沿用远端当前设置,远端设置变化会影响结果。',
|
||||
searchMethod: '检索方式', topK: 'Provider Top K', thresholdEnabled: '启用分数阈值', threshold: '相似度阈值',
|
||||
rerank: '启用 Rerank', rerankMode: '重排方式', rerankProvider: '重排模型提供商', rerankModel: '重排模型',
|
||||
keywordWeight: '关键词权重', vectorWeight: '向量权重', embeddingProvider: 'Embedding 提供商', embeddingModel: 'Embedding 模型',
|
||||
tokenLimit: 'Token 上限', knnTopK: 'KNN Top K', rerankId: '远端重排模型 ID', rerankCandidates: '重排候选数量',
|
||||
graph: '图谱检索', graphHelp: '检索远端已生成的图谱,不会创建或更新图谱。', graphUnknown: '未确认远端图谱已完成',
|
||||
compilation: '包含 Knowledge Compilation', compilationUnavailable: '当前知识库未确认配置 Knowledge Compilation',
|
||||
overview: '概览', rebind: '更换远端知识库', refreshDetail: '刷新远端信息', remove: '从 GoodBuddy 移除',
|
||||
removeBindingConfirm: '仅删除本机绑定和检索配置,不删除远端知识库或文档。历史会话中的引用继续可读。',
|
||||
deleteInstance: '删除实例和本地绑定', deleteInstanceConfirm: '此实例被 {{count}} 个知识库使用。删除会同时移除本机绑定,远端数据不会改变。',
|
||||
cancelRequest: '取消请求', remoteData: '知识内容保留在远端;回答使用的片段随会话保存在本机。',
|
||||
status: '状态', credentialStatus: '凭据状态', boundCount: '{{count}} 个绑定', lastTest: '最近测试', never: '尚未检索',
|
||||
snapshot: '历史引用快照', documentId: '远端文档 ID', chunkId: '远端片段 ID', providerScore: 'Provider 分数',
|
||||
elapsed: '总耗时 {{ms}} ms', returned: '返回 {{returned}} 条 · 保留 {{kept}} 条', truncated: '结果已截断',
|
||||
allSources: '全部来源', searchLibraries: '搜索知识库', selectTarget: '选择远端知识库',
|
||||
states: { ready: '远端可检索', untested: '未测试', 'instance-disabled': '实例已停用', 'credential-error': '凭据不可用', 'remote-missing': '远端目标不存在', 'config-invalid': '配置需要确认', 'temporarily-unavailable': '暂时无法连接' }
|
||||
},
|
||||
page: {
|
||||
title: '知识库',
|
||||
description: '管理文件、目录和网页来源,并查看索引与图谱。'
|
||||
description: '管理本地知识,并检索企业现有知识平台。'
|
||||
},
|
||||
actions: {
|
||||
cancel: '取消',
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
export const workspace = {
|
||||
projectActivity: {
|
||||
title: '全项目活动',
|
||||
close: '关闭全项目活动',
|
||||
unassigned: '未归属项目',
|
||||
attentionCount: '{{count}} 个待处理',
|
||||
runningCount: '{{count}} 个运行中',
|
||||
groups: { attention: '待处理', running: '运行中' },
|
||||
status: {
|
||||
running: '运行中',
|
||||
approval: '等待审批',
|
||||
question: '等待你的回答',
|
||||
attention: '待处理'
|
||||
}
|
||||
},
|
||||
management: {
|
||||
gitWorkspace: 'Git 工作区', more: '{{name}} 的更多操作', browse: '浏览此目录', parent: '返回父目录',
|
||||
createFile: '新建文件', createDirectory: '新建目录', rename: '重命名', move: '移动', delete: '删除', properties: '属性',
|
||||
@@ -493,6 +507,9 @@ export const workspace = {
|
||||
empty: '工作区为空。'
|
||||
},
|
||||
question: {
|
||||
task: '任务:{{title}}',
|
||||
pendingCount: '还有 {{count}} 个待答问题,请依次回答。',
|
||||
locate: '前往问答表单',
|
||||
title: 'OpenCode 需要补充信息',
|
||||
otherAnswer: '其他回答',
|
||||
answerPlaceholder: '输入你的回答',
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
.project-activity__summary,
|
||||
.project-activity__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.project-activity__summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
flex-shrink: 0;
|
||||
row-gap: var(--space-1);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.project-activity__summary > span { grid-column: 1; }
|
||||
.project-activity__summary > span:first-child { color: var(--text-secondary); }
|
||||
.project-activity__summary > svg { grid-column: 2; grid-row: 1 / 3; }
|
||||
|
||||
.project-activity__summary > svg,
|
||||
.project-activity__row > svg {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.project-activity__summary:hover,
|
||||
.project-activity__row:hover {
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.project-activity__summary:active,
|
||||
.project-activity__row:active {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.project-activity__summary:focus-visible,
|
||||
.project-activity__dialog button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.project-activity__counts,
|
||||
.project-activity__counts > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.project-activity__counts {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.project-activity__attention { color: var(--warning); }
|
||||
.project-activity__running { color: var(--accent); }
|
||||
|
||||
.project-switcher__project-heading {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.project-switcher__project-heading > b { min-width: 0; }
|
||||
|
||||
.conversation-activity-indicator.conversation-activity-indicator--attention {
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--warning) 14%, transparent);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.project-activity__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-dialog);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-4);
|
||||
background: var(--overlay-backdrop);
|
||||
}
|
||||
|
||||
.project-activity__dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(100%, 520px);
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.project-activity__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.project-activity__header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-section-title);
|
||||
}
|
||||
|
||||
.project-activity__content {
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.project-activity__content h3 {
|
||||
margin: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.project-activity__content section + section { margin-top: var(--space-4); }
|
||||
.project-activity__content ul { margin: 0; padding: 0; list-style: none; }
|
||||
|
||||
.project-activity__identity {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-activity__identity small {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.project-activity__row > .project-activity__attention,
|
||||
.project-activity__row > .project-activity__running {
|
||||
max-width: 40%;
|
||||
font-size: var(--font-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.project-activity__summary,
|
||||
.project-activity__row { transition: none; }
|
||||
}
|
||||
@@ -2890,7 +2890,7 @@ textarea:focus-visible {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
flex-direction: column;
|
||||
@@ -2898,7 +2898,7 @@ textarea:focus-visible {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.project-create-card header {
|
||||
.project-create-card > header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
@@ -2909,13 +2909,29 @@ textarea:focus-visible {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.project-create-card__close {
|
||||
.project-create-card .project-create-card__close {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 auto;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.project-create-card__body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
padding: 3px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.project-create-card__body > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-create-card label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2962,6 +2978,8 @@ textarea:focus-visible {
|
||||
|
||||
.project-create-card__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 7px;
|
||||
}
|
||||
@@ -3787,7 +3805,7 @@ button > svg {
|
||||
}
|
||||
|
||||
:is(
|
||||
.project-create-card,
|
||||
.project-create-card__body,
|
||||
.knowledge-citation-dialog__surface,
|
||||
.channel-qr-dialog,
|
||||
.document-parsing-diagnostic,
|
||||
|
||||
Reference in New Issue
Block a user