mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 03:12:55 +00:00
Show which of an agent's resources stopped running, with a way to fix each
The agent page shows one notice in place of the sponsored-resources one: who added what runs with their access, and every attached tool, source or prompt that stopped, with the reason in plain words and what the reader can do. Reconnect signs the account in again in place, Run it with my access now asks first in the sponsor dialog naming who reaches the agent and is sent with the next save, Remove takes the item off the form, and when the reader can't fix it the notice says whom to ask. The workflow builder shows the same notice over the canvas for its node tools and sources, with take-over sent on the next save and Remove taking the item off every agent node.
This commit is contained in:
1 parent
2359e4546b
commit
1fed332c6a
19 files changed
+1376
-445
No files matched your search
@@ -39,13 +39,14 @@ const mocks = vi.hoisted(() => {
|
||||
>(() => jsonResponse({})),
|
||||
guardrailsProps: vi.fn(),
|
||||
detailsProps: vi.fn(),
|
||||
reconnect: vi.fn(),
|
||||
};
|
||||
});
|
||||
const { jsonResponse } = mocks;
|
||||
|
||||
vi.mock('../connectors/SignInAgainNotice', () => ({
|
||||
default: () => null,
|
||||
useSignInAgain: () => ({ reconnect: vi.fn(), modals: null }),
|
||||
useSignInAgain: () => ({ reconnect: mocks.reconnect, modals: null }),
|
||||
}));
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown) => selector(mockState),
|
||||
@@ -518,35 +519,92 @@ describe('NewAgent form', () => {
|
||||
expect(container.querySelector('[data-item="owners"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('sends a take-over of a stopped item with the next save', async () => {
|
||||
const stopped = {
|
||||
key: 'tool:t1',
|
||||
type: 'tool',
|
||||
id: 't1',
|
||||
name: 'Jira',
|
||||
user_id: 'bob',
|
||||
label: 'bob@example.com',
|
||||
state: 'inactive',
|
||||
reason: 'sponsor_cannot_edit_agent',
|
||||
active: false,
|
||||
can_confirm: true,
|
||||
};
|
||||
await renderEdit({ resource_sponsors: [stopped] });
|
||||
const stoppedTool = {
|
||||
key: 'tool:t1',
|
||||
type: 'tool',
|
||||
id: 't1',
|
||||
name: 'Jira',
|
||||
state: 'stopped',
|
||||
reason: 'sponsor_cannot_edit_agent',
|
||||
sponsor: { user_id: 'bob', label: 'bob@example.com' },
|
||||
can_confirm: true,
|
||||
};
|
||||
const withStoppedTool = (item: Record<string, unknown> = {}) => ({
|
||||
tools: ['t1'],
|
||||
tool_details: [{ id: 't1', name: 'jira', display_name: 'Jira' }],
|
||||
resource_states: [{ ...stoppedTool, ...item }],
|
||||
sponsor_audience: {
|
||||
teams: ['Sales'],
|
||||
api_key: false,
|
||||
public_link: false,
|
||||
webhook: false,
|
||||
},
|
||||
});
|
||||
|
||||
it('asks before taking over a stopped item and sends it with the next save', async () => {
|
||||
await renderEdit(withStoppedTool());
|
||||
const save = buttonByText('agents.form.buttons.save');
|
||||
expect(save.disabled).toBe(true);
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.sponsors.takeOver').click(),
|
||||
);
|
||||
// Nothing is taken over until the reader agrees in the dialog.
|
||||
expect(save.disabled).toBe(true);
|
||||
const confirm = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="confirm-sponsor"]',
|
||||
)!;
|
||||
expect(confirm.textContent).toBe('Jira');
|
||||
await act(async () => confirm.click());
|
||||
expect(container.textContent).toContain(
|
||||
'agents.form.sponsors.takeOverPending',
|
||||
);
|
||||
expect(save.disabled).toBe(false);
|
||||
await act(async () => save.click());
|
||||
const sent = mocks.updateAgent.mock.calls[0][1];
|
||||
expect(JSON.parse(sent.get('confirm_sponsor') as string)).toEqual([
|
||||
'tool:t1',
|
||||
]);
|
||||
// The saved agent is fetched again for its fresh sponsor details.
|
||||
// The saved agent is fetched again for its fresh details.
|
||||
expect(mocks.getAgent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('removes a stopped item from the agent', async () => {
|
||||
await renderEdit(withStoppedTool({ reason: 'deleted', sponsor: null }));
|
||||
expect(container.textContent).toContain(
|
||||
'agents.form.resourceStates.reason.deleted',
|
||||
);
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.resourceStates.remove').click(),
|
||||
);
|
||||
// Off the form, so off the notice too.
|
||||
expect(container.textContent).not.toContain(
|
||||
'agents.form.resourceStates.reason.deleted',
|
||||
);
|
||||
await act(async () => buttonByText('agents.form.buttons.save').click());
|
||||
const sent = mocks.updateAgent.mock.calls[0][1];
|
||||
expect(JSON.parse(sent.get('tools') as string)).toEqual([]);
|
||||
});
|
||||
|
||||
it('reconnects the account of a stopped tool in place', async () => {
|
||||
mocks.reconnect.mockClear();
|
||||
await renderEdit(
|
||||
withStoppedTool({
|
||||
reason: 'connection_needs_reconnect',
|
||||
sponsor: null,
|
||||
can_confirm: false,
|
||||
can_reconnect: true,
|
||||
connection: { id: 'c1', connector_key: 'telegram', name: 'Telegram' },
|
||||
}),
|
||||
);
|
||||
await act(async () =>
|
||||
buttonByText('settings.connectors.status.reconnect').click(),
|
||||
);
|
||||
expect(mocks.reconnect).toHaveBeenCalledWith(
|
||||
{ id: 'c1', connector_key: 'telegram' },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('explains an outdated confirmation and reloads the sponsors', async () => {
|
||||
mocks.updateAgent.mockImplementationOnce(() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -99,15 +99,18 @@ import { resetPreview, selectPreviewStatus } from './agentPreviewSlice';
|
||||
import AgentPageToolbar, { LastUsedMeta } from './components/AgentPageToolbar';
|
||||
import AgentPreviewSheet from './components/AgentPreviewSheet';
|
||||
import SectionShell from '../navigation/SectionShell';
|
||||
import SponsoredResourcesNotice from './components/SponsoredResourcesNotice';
|
||||
import ResourceStatusNotice, {
|
||||
type NamedResource,
|
||||
} from './components/ResourceStatusNotice';
|
||||
import {
|
||||
confirmTakeOver,
|
||||
readSponsorRefusal,
|
||||
saveWithSponsorConsent,
|
||||
sponsorNotAllowedMessage,
|
||||
withAttachedToolRows,
|
||||
} from './sponsorConsent';
|
||||
import { useSponsorPrompt } from './useSponsorPrompt';
|
||||
import { Agent, ResourceSponsor, ToolSummary } from './types';
|
||||
import { Agent, ResourceState, ToolSummary } from './types';
|
||||
import WorkflowBuilder from './workflow/WorkflowBuilder';
|
||||
|
||||
import type { Model } from '../models/types';
|
||||
@@ -185,8 +188,13 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
{ connection: Connection; mcpToolId?: string }[]
|
||||
>([]);
|
||||
const [toolsReloadKey, setToolsReloadKey] = useState(0);
|
||||
// Bumped after a reconnect so the agent's run state is read again.
|
||||
const [detailsReloadKey, setDetailsReloadKey] = useState(0);
|
||||
const signInAgain = useSignInAgain({
|
||||
onConnected: () => setToolsReloadKey((key) => key + 1),
|
||||
onConnected: () => {
|
||||
setToolsReloadKey((key) => key + 1);
|
||||
setDetailsReloadKey((key) => key + 1);
|
||||
},
|
||||
});
|
||||
const [availableModels, setAvailableModels] = useState<Model[]>([]);
|
||||
const [isSourcePopupOpen, setIsSourcePopupOpen] = useState(false);
|
||||
@@ -317,7 +325,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
// Name of a tool/source/prompt that runs with an editor's access, from the
|
||||
// same owner-agnostic details the pickers show.
|
||||
const resolveSponsoredName = useCallback(
|
||||
(sponsor: ResourceSponsor): string => {
|
||||
(sponsor: NamedResource): string => {
|
||||
if (sponsor.name) return sponsor.name;
|
||||
if (sponsor.type === 'source') return resolveSourceLabel(sponsor.id);
|
||||
if (sponsor.type === 'prompt') {
|
||||
@@ -448,7 +456,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Fresh sponsor details after a save, without touching unsaved fields.
|
||||
// Fresh sponsor details and run state after a save or a reconnect,
|
||||
// without touching unsaved fields.
|
||||
const refreshSponsors = useCallback(
|
||||
async (id?: string) => {
|
||||
if (!id) return;
|
||||
@@ -456,13 +465,14 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
const response = await userService.getAgent(id, token);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
const sponsors = data.resource_sponsors ?? [];
|
||||
setAgent((prev) => ({ ...prev, resource_sponsors: sponsors }));
|
||||
const details: Partial<Agent> = {
|
||||
resource_sponsors: data.resource_sponsors ?? [],
|
||||
resource_states: data.resource_states ?? [],
|
||||
sponsor_audience: data.sponsor_audience,
|
||||
};
|
||||
setAgent((prev) => ({ ...prev, ...details }));
|
||||
if (initialAgentRef.current)
|
||||
initialAgentRef.current = {
|
||||
...initialAgentRef.current,
|
||||
resource_sponsors: sponsors,
|
||||
};
|
||||
initialAgentRef.current = { ...initialAgentRef.current, ...details };
|
||||
} catch {
|
||||
// The notice keeps what it showed.
|
||||
}
|
||||
@@ -470,6 +480,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
[token],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailsReloadKey > 0) void refreshSponsors(agent.id);
|
||||
// Only a reconnect asks for this; agent.id is read when it does.
|
||||
}, [detailsReloadKey, refreshSponsors]);
|
||||
|
||||
/** The message for a refused save. */
|
||||
const saveFailureMessage = async (
|
||||
response: Response,
|
||||
@@ -516,10 +531,80 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTakeover = (key: string) =>
|
||||
setTakeovers((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
const undoTakeover = (key: string) =>
|
||||
setTakeovers((prev) => prev.filter((k) => k !== key));
|
||||
|
||||
// Stopped items still on the agent in the form: one removed here (not yet
|
||||
// saved) leaves the notice.
|
||||
const stoppedResources = useMemo(() => {
|
||||
const toolIds = new Set(
|
||||
selectedTools.map((tool) => String(tool?.id).toLowerCase()),
|
||||
);
|
||||
const sourceIds = new Set(
|
||||
Array.from(selectedSourceIds, (id) => id.toLowerCase()),
|
||||
);
|
||||
const promptId = String(agent.prompt_id || '').toLowerCase();
|
||||
return (agent.resource_states ?? []).filter(
|
||||
(item) =>
|
||||
item.state === 'stopped' &&
|
||||
(item.type === 'tool'
|
||||
? toolIds.has(item.id)
|
||||
: item.type === 'source'
|
||||
? sourceIds.has(item.id)
|
||||
: promptId === item.id),
|
||||
);
|
||||
}, [
|
||||
agent.resource_states,
|
||||
agent.prompt_id,
|
||||
selectedSourceIds,
|
||||
selectedTools,
|
||||
]);
|
||||
|
||||
/** Take a stopped item off the agent; the next save stores it. */
|
||||
const removeResource = (item: ResourceState) => {
|
||||
if (item.type === 'tool')
|
||||
setSelectedTools((prev) =>
|
||||
prev.filter((tool) => String(tool?.id).toLowerCase() !== item.id),
|
||||
);
|
||||
else if (item.type === 'source')
|
||||
setSelectedSourceIds(
|
||||
(prev) =>
|
||||
new Set(
|
||||
Array.from(prev).filter((id) => id.toLowerCase() !== item.id),
|
||||
),
|
||||
);
|
||||
else setAgent((prev) => ({ ...prev, prompt_id: 'default' }));
|
||||
undoTakeover(item.key);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ask before a stopped item runs with the caller's access, naming who
|
||||
* reaches it through the agent; on yes the next save confirms it.
|
||||
*/
|
||||
const takeOverResource = async (item: ResourceState) => {
|
||||
const agreed = await confirmTakeOver(
|
||||
sponsorPrompt.ask,
|
||||
item,
|
||||
resolveSponsoredName(item),
|
||||
agent.sponsor_audience,
|
||||
);
|
||||
if (agreed)
|
||||
setTakeovers((prev) =>
|
||||
prev.includes(item.key) ? prev : [...prev, item.key],
|
||||
);
|
||||
};
|
||||
|
||||
/** Sign a stopped tool's connection in again, in place where possible. */
|
||||
const reconnectResource = (item: ResourceState) => {
|
||||
const connection = item.connection;
|
||||
if (!connection?.id || !connection.connector_key) return;
|
||||
const isMcp =
|
||||
rawUserTools.find((tool) => tool.id === item.id)?.name === 'mcp_tool';
|
||||
signInAgain.reconnect(
|
||||
{ id: connection.id, connector_key: connection.connector_key },
|
||||
isMcp ? item.id : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
const formData = new FormData();
|
||||
@@ -1397,11 +1482,15 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
{t('agents.form.buttons.add')}
|
||||
</Button>
|
||||
</div>
|
||||
<SponsoredResourcesNotice
|
||||
<ResourceStatusNotice
|
||||
agent={agent}
|
||||
stopped={stoppedResources}
|
||||
resolveName={resolveSponsoredName}
|
||||
takeovers={takeovers}
|
||||
onToggleTakeover={toggleTakeover}
|
||||
onTakeOver={(item) => void takeOverResource(item)}
|
||||
onUndoTakeover={undoTakeover}
|
||||
onRemove={removeResource}
|
||||
onReconnect={reconnectResource}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import type { Agent, ResourceSponsor, ResourceState } from '../types';
|
||||
import ResourceStatusNotice from './ResourceStatusNotice';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const baseAgent: Agent = {
|
||||
name: 'A',
|
||||
description: 'd',
|
||||
image: '',
|
||||
source: '',
|
||||
chunks: '6',
|
||||
retriever: '',
|
||||
prompt_id: 'default',
|
||||
tools: [],
|
||||
agent_type: 'classic',
|
||||
status: 'published',
|
||||
};
|
||||
|
||||
const editorAgent: Agent = {
|
||||
...baseAgent,
|
||||
access: 'editor',
|
||||
allowed_actions: ['edit'],
|
||||
};
|
||||
|
||||
const sponsor = (over: Partial<ResourceSponsor>): ResourceSponsor => ({
|
||||
type: 'tool',
|
||||
id: 't1',
|
||||
user_id: 'bob',
|
||||
label: 'bob@example.com',
|
||||
active: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
const stoppedItem = (over: Partial<ResourceState>): ResourceState => ({
|
||||
key: 'tool:t1',
|
||||
type: 'tool',
|
||||
id: 't1',
|
||||
state: 'stopped',
|
||||
reason: 'deleted',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('ResourceStatusNotice', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const handlers = () => ({
|
||||
onTakeOver: vi.fn(),
|
||||
onUndoTakeover: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
onReconnect: vi.fn(),
|
||||
});
|
||||
|
||||
const render = async (
|
||||
agent: Agent,
|
||||
stopped: ResourceState[] = [],
|
||||
takeovers: string[] = [],
|
||||
) => {
|
||||
const spies = handlers();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ResourceStatusNotice
|
||||
agent={agent}
|
||||
stopped={stopped}
|
||||
resolveName={(item) => item.name || `name-${item.id}`}
|
||||
takeovers={takeovers}
|
||||
{...spies}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return spies;
|
||||
};
|
||||
|
||||
const buttons = () => Array.from(container.querySelectorAll('button'));
|
||||
const buttonWith = (text: string) =>
|
||||
buttons().filter((b) => b.textContent?.includes(text));
|
||||
const alerts = () =>
|
||||
Array.from(container.querySelectorAll('[data-slot="alert"]'));
|
||||
|
||||
it('renders nothing for the owner when everything runs', async () => {
|
||||
await render(baseAgent);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('tells an editor their own items run with their access', async () => {
|
||||
await render(editorAgent);
|
||||
expect(alerts()).toHaveLength(1);
|
||||
expect(container.textContent).toContain('agents.form.sponsors.attachNote');
|
||||
expect(container.textContent).not.toContain('publicLinkNote');
|
||||
});
|
||||
|
||||
it('adds the public-link warning when the agent has a link', async () => {
|
||||
await render({ ...editorAgent, shared: true });
|
||||
expect(container.textContent).toContain(
|
||||
'agents.form.sponsors.publicLinkNote',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the attach note out where the page asks it to', async () => {
|
||||
const spies = handlers();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ResourceStatusNotice
|
||||
agent={editorAgent}
|
||||
stopped={[]}
|
||||
resolveName={(item) => item.id}
|
||||
showAttachNote={false}
|
||||
{...spies}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('does not show the attach note to a viewer', async () => {
|
||||
await render({ ...baseAgent, access: 'viewer', allowed_actions: ['use'] });
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('joins names for the app language and does not escape them', async () => {
|
||||
await i18n.use(initReactI18next).init({
|
||||
lng: 'jp',
|
||||
resources: {
|
||||
jp: {
|
||||
translation: {
|
||||
agents: {
|
||||
form: { sponsors: { addedBy: '{{person}}: {{names}}' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
await render({
|
||||
...baseAgent,
|
||||
resource_sponsors: [
|
||||
sponsor({ id: 'docs/a' }),
|
||||
sponsor({ id: 'docs/b' }),
|
||||
],
|
||||
});
|
||||
const expected = new Intl.ListFormat('ja', {
|
||||
type: 'conjunction',
|
||||
}).format(['name-docs/a', 'name-docs/b']);
|
||||
expect(container.textContent).toBe(`bob@example.com: ${expected}`);
|
||||
} finally {
|
||||
await i18n.changeLanguage('en');
|
||||
}
|
||||
});
|
||||
|
||||
it('lists running sponsored items apart from stopped ones, in one notice', async () => {
|
||||
await render(
|
||||
{
|
||||
...baseAgent,
|
||||
resource_sponsors: [
|
||||
sponsor({ id: 't1' }),
|
||||
// Stopped sponsorships come through resource_states instead.
|
||||
sponsor({ id: 's1', type: 'source', active: false }),
|
||||
],
|
||||
},
|
||||
[stoppedItem({ key: 'source:s1', type: 'source', id: 's1' })],
|
||||
);
|
||||
const [running, stopped] = alerts();
|
||||
expect(alerts()).toHaveLength(2);
|
||||
expect(running.getAttribute('role')).toBe('note');
|
||||
expect(running.textContent).toContain('agents.form.sponsors.addedBy');
|
||||
expect(stopped.getAttribute('data-variant')).toBe('warning');
|
||||
expect(stopped.textContent).toContain('agents.form.resourceStates.title');
|
||||
expect(stopped.querySelectorAll('li')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['deleted', 'reason.deleted'],
|
||||
['sponsor_cannot_edit_agent', 'reason.sponsorCannotEditAgent'],
|
||||
['sponsor_cannot_edit_resource', 'reason.sponsorCannotEditItem'],
|
||||
['connection_needs_reconnect', 'reason.connectionNeedsReconnect'],
|
||||
['connection_removed', 'reason.connectionRemoved'],
|
||||
['connector_disabled', 'reason.connectorDisabled'],
|
||||
] as const)('says why an item stopped: %s', async (reason, key) => {
|
||||
await render(baseAgent, [stoppedItem({ reason })]);
|
||||
expect(container.textContent).toContain(
|
||||
`agents.form.resourceStates.${key}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('tells the owner they lost access, and an editor that the owner did', async () => {
|
||||
const item = stoppedItem({ reason: 'owner_lost_access' });
|
||||
await render(baseAgent, [item]);
|
||||
expect(container.textContent).toContain('reason.ownerLostAccessYou');
|
||||
await render(editorAgent, [item]);
|
||||
expect(container.textContent).not.toContain('reason.ownerLostAccessYou');
|
||||
expect(container.textContent).toContain('reason.ownerLostAccess');
|
||||
});
|
||||
|
||||
it('says whom to ask when the reader cannot fix it', async () => {
|
||||
await render(baseAgent, [
|
||||
stoppedItem({
|
||||
key: 'source:s1',
|
||||
type: 'source',
|
||||
id: 's1',
|
||||
reason: 'owner_lost_access',
|
||||
contact: { user_id: 'carol', label: 'carol@example.com' },
|
||||
}),
|
||||
stoppedItem({ key: 'tool:t2', id: 't2', reason: 'connector_disabled' }),
|
||||
stoppedItem({ key: 'tool:t3', id: 't3', reason: 'deleted' }),
|
||||
]);
|
||||
const [shareAgain, admin, deleted] = Array.from(
|
||||
container.querySelectorAll('li'),
|
||||
);
|
||||
expect(shareAgain.textContent).toContain(
|
||||
'agents.form.resourceStates.ask.shareAgain',
|
||||
);
|
||||
expect(admin.textContent).toContain('agents.form.resourceStates.ask.admin');
|
||||
expect(deleted.textContent).not.toContain('agents.form.resourceStates.ask');
|
||||
});
|
||||
|
||||
it('notes that a stopped prompt falls back to the default', async () => {
|
||||
await render(baseAgent, [
|
||||
stoppedItem({ key: 'prompt:p1', type: 'prompt', id: 'p1' }),
|
||||
]);
|
||||
expect(container.textContent).toContain(
|
||||
'agents.form.resourceStates.promptFallback',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers Reconnect only when the reader owns the connection', async () => {
|
||||
const connection = { id: 'c1', connector_key: 'telegram', name: 'Tg' };
|
||||
const spies = await render(baseAgent, [
|
||||
stoppedItem({
|
||||
reason: 'connection_needs_reconnect',
|
||||
connection,
|
||||
can_reconnect: true,
|
||||
}),
|
||||
stoppedItem({
|
||||
key: 'tool:t2',
|
||||
id: 't2',
|
||||
reason: 'connection_needs_reconnect',
|
||||
connection,
|
||||
can_reconnect: false,
|
||||
contact: { user_id: 'bob', label: 'bob@example.com' },
|
||||
}),
|
||||
]);
|
||||
const reconnect = buttonWith('settings.connectors.status.reconnect');
|
||||
expect(reconnect).toHaveLength(1);
|
||||
await act(async () => reconnect[0].click());
|
||||
expect(spies.onReconnect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'tool:t1' }),
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
'agents.form.resourceStates.ask.signInAgain',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a take-over only for items the reader may confirm', async () => {
|
||||
const spies = await render(editorAgent, [
|
||||
stoppedItem({ reason: 'sponsor_cannot_edit_agent', can_confirm: true }),
|
||||
stoppedItem({
|
||||
key: 'tool:t2',
|
||||
id: 't2',
|
||||
reason: 'sponsor_cannot_edit_agent',
|
||||
can_confirm: false,
|
||||
}),
|
||||
]);
|
||||
const takeOver = buttonWith('agents.form.sponsors.takeOver');
|
||||
expect(takeOver).toHaveLength(1);
|
||||
await act(async () => takeOver[0].click());
|
||||
expect(spies.onTakeOver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'tool:t1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a pending take-over with an undo', async () => {
|
||||
const spies = await render(
|
||||
editorAgent,
|
||||
[stoppedItem({ reason: 'owner_lost_access', can_confirm: true })],
|
||||
['tool:t1'],
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
'agents.form.sponsors.takeOverPending',
|
||||
);
|
||||
await act(async () =>
|
||||
buttonWith('agents.form.sponsors.undoTakeOver')[0].click(),
|
||||
);
|
||||
expect(spies.onUndoTakeover).toHaveBeenCalledWith('tool:t1');
|
||||
});
|
||||
|
||||
it('removes an item', async () => {
|
||||
const spies = await render(baseAgent, [stoppedItem({})]);
|
||||
const [remove] = buttonWith('agents.form.resourceStates.remove');
|
||||
expect(remove.getAttribute('aria-label')).toBe(
|
||||
'agents.form.resourceStates.removeLabel',
|
||||
);
|
||||
await act(async () => remove.click());
|
||||
expect(spies.onRemove).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'tool:t1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { Info, TriangleAlert, UserRound } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { can, isOwner } from '../../utils/accessUtils';
|
||||
import { intlLocale } from '../../utils/dateTimeUtils';
|
||||
import type {
|
||||
Agent,
|
||||
ResourceSponsor,
|
||||
ResourceState,
|
||||
ResourceStateReason,
|
||||
} from '../types';
|
||||
|
||||
/** What the notice needs to name an item. */
|
||||
export type NamedResource = Pick<ResourceState, 'type' | 'id' | 'name'>;
|
||||
|
||||
type ResourceStatusNoticeProps = {
|
||||
/** The agent the page edits: the reader's access, its link, its sponsors. */
|
||||
agent: Agent;
|
||||
/** Stopped items still attached in the form (`resource_states`). */
|
||||
stopped: ResourceState[];
|
||||
/** Display name of a tool, source or prompt. */
|
||||
resolveName: (item: NamedResource) => string;
|
||||
/** Keys of stopped items the reader chose to run with their access on save. */
|
||||
takeovers?: string[];
|
||||
/** Ask the reader to run one item with their access. */
|
||||
onTakeOver?: (item: ResourceState) => void;
|
||||
/** Drop a pending take-over. */
|
||||
onUndoTakeover?: (key: string) => void;
|
||||
/** Take one item off the agent (or the workflow's nodes). */
|
||||
onRemove?: (item: ResourceState) => void;
|
||||
/** Sign the item's connection in again. */
|
||||
onReconnect?: (item: ResourceState) => void;
|
||||
/** Tell an editor that what they add runs with their access (the agent form). */
|
||||
showAttachNote?: boolean;
|
||||
};
|
||||
|
||||
/** The message key that says why an item stopped. */
|
||||
function reasonKey(reason: ResourceStateReason | null, ownerReads: boolean) {
|
||||
switch (reason) {
|
||||
case 'deleted':
|
||||
return 'agents.form.resourceStates.reason.deleted';
|
||||
case 'owner_lost_access':
|
||||
return ownerReads
|
||||
? 'agents.form.resourceStates.reason.ownerLostAccessYou'
|
||||
: 'agents.form.resourceStates.reason.ownerLostAccess';
|
||||
case 'sponsor_cannot_edit_agent':
|
||||
return 'agents.form.resourceStates.reason.sponsorCannotEditAgent';
|
||||
case 'sponsor_cannot_edit_resource':
|
||||
return 'agents.form.resourceStates.reason.sponsorCannotEditItem';
|
||||
case 'connection_needs_reconnect':
|
||||
return 'agents.form.resourceStates.reason.connectionNeedsReconnect';
|
||||
case 'connection_removed':
|
||||
return 'agents.form.resourceStates.reason.connectionRemoved';
|
||||
case 'connector_disabled':
|
||||
return 'agents.form.resourceStates.reason.connectorDisabled';
|
||||
default:
|
||||
return 'agents.form.resourceStates.reason.unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/** Whom to ask, as the key that says so; null when the reader can act. */
|
||||
function askKey(item: ResourceState): string | null {
|
||||
if (item.reason === 'connector_disabled')
|
||||
return 'agents.form.resourceStates.ask.admin';
|
||||
if (!item.contact) return null;
|
||||
if (item.reason === 'owner_lost_access')
|
||||
return 'agents.form.resourceStates.ask.shareAgain';
|
||||
if (item.reason === 'connection_needs_reconnect')
|
||||
return 'agents.form.resourceStates.ask.signInAgain';
|
||||
if (item.reason === 'connection_removed')
|
||||
return 'agents.form.resourceStates.ask.connectAgain';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Items grouped by the person who added them, in first-seen order. */
|
||||
function groupByPerson(sponsors: ResourceSponsor[]) {
|
||||
const groups = new Map<string, ResourceSponsor[]>();
|
||||
for (const sponsor of sponsors) {
|
||||
const key = sponsor.label || sponsor.user_id;
|
||||
groups.set(key, [...(groups.get(key) ?? []), sponsor]);
|
||||
}
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* One notice for what runs on the agent with someone else's access and what
|
||||
* stopped running.
|
||||
*
|
||||
* An editor sees up front that what they attach runs with their access for
|
||||
* everyone who uses the agent, and everyone who may edit it sees who added
|
||||
* what (`resource_sponsors`). Each attached tool, source or prompt that no
|
||||
* longer runs (`resource_states`) is listed with the reason in plain words and
|
||||
* what the reader can do: sign its account in again, run it with their own
|
||||
* access, take it off, or whom to ask.
|
||||
*/
|
||||
export default function ResourceStatusNotice({
|
||||
agent,
|
||||
stopped,
|
||||
resolveName,
|
||||
takeovers = [],
|
||||
onTakeOver,
|
||||
onUndoTakeover,
|
||||
onRemove,
|
||||
onReconnect,
|
||||
showAttachNote: attachNoteWanted = true,
|
||||
}: ResourceStatusNoticeProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const ownerReads = isOwner(agent);
|
||||
const showAttachNote = attachNoteWanted && !ownerReads && can(agent, 'edit');
|
||||
const running = groupByPerson(
|
||||
(agent.resource_sponsors ?? []).filter((s) => s.active),
|
||||
);
|
||||
|
||||
if (!showAttachNote && running.length === 0 && stopped.length === 0)
|
||||
return null;
|
||||
|
||||
const listFormat = new Intl.ListFormat(intlLocale(i18n.language), {
|
||||
type: 'conjunction',
|
||||
});
|
||||
const plain = { interpolation: { escapeValue: false } };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:col-span-2">
|
||||
{showAttachNote && (
|
||||
<Alert role="note">
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
{t('agents.form.sponsors.attachNote')}
|
||||
{agent.shared ? ` ${t('agents.form.sponsors.publicLinkNote')}` : ''}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{running.length > 0 && (
|
||||
<Alert role="note">
|
||||
<UserRound />
|
||||
<AlertDescription>
|
||||
{running.map((items) => (
|
||||
<p key={items[0].key || `${items[0].type}:${items[0].id}`}>
|
||||
{t('agents.form.sponsors.addedBy', {
|
||||
...plain,
|
||||
person: items[0].label || items[0].user_id,
|
||||
names: listFormat.format(items.map(resolveName)),
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{stopped.length > 0 && (
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>{t('agents.form.resourceStates.title')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{stopped.map((item) => {
|
||||
const name = resolveName(item);
|
||||
const service =
|
||||
item.connection?.name ||
|
||||
t('agents.form.resourceStates.serviceFallback');
|
||||
const ask = askKey(item);
|
||||
const pending = takeovers.includes(item.key);
|
||||
return (
|
||||
<li key={item.key} className="flex flex-col gap-1.5">
|
||||
<p>
|
||||
{t(reasonKey(item.reason, ownerReads), {
|
||||
...plain,
|
||||
name,
|
||||
service,
|
||||
person: item.sponsor?.label || item.sponsor?.user_id,
|
||||
})}
|
||||
{ask
|
||||
? ` ${t(ask, { ...plain, person: item.contact?.label })}`
|
||||
: ''}
|
||||
{item.type === 'prompt'
|
||||
? ` ${t('agents.form.resourceStates.promptFallback')}`
|
||||
: ''}
|
||||
</p>
|
||||
{pending ? (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span>
|
||||
{t('agents.form.sponsors.takeOverPending', {
|
||||
...plain,
|
||||
name,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="inline"
|
||||
onClick={() => onUndoTakeover?.(item.key)}
|
||||
>
|
||||
{t('agents.form.sponsors.undoTakeOver')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.can_reconnect && onReconnect && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
aria-label={t(
|
||||
'agents.form.resourceStates.reconnectLabel',
|
||||
{ ...plain, name },
|
||||
)}
|
||||
onClick={() => onReconnect(item)}
|
||||
>
|
||||
{t('settings.connectors.status.reconnect')}
|
||||
</Button>
|
||||
)}
|
||||
{item.can_confirm && onTakeOver && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => onTakeOver(item)}
|
||||
>
|
||||
{t('agents.form.sponsors.takeOver', {
|
||||
...plain,
|
||||
name,
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
aria-label={t(
|
||||
'agents.form.resourceStates.removeLabel',
|
||||
{ ...plain, name },
|
||||
)}
|
||||
onClick={() => onRemove(item)}
|
||||
>
|
||||
{t('agents.form.resourceStates.remove')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import type { Agent, ResourceSponsor } from '../types';
|
||||
import SponsoredResourcesNotice from './SponsoredResourcesNotice';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const baseAgent: Agent = {
|
||||
name: 'A',
|
||||
description: 'd',
|
||||
image: '',
|
||||
source: '',
|
||||
chunks: '6',
|
||||
retriever: '',
|
||||
prompt_id: 'default',
|
||||
tools: [],
|
||||
agent_type: 'classic',
|
||||
status: 'published',
|
||||
};
|
||||
|
||||
const sponsor = (over: Partial<ResourceSponsor>): ResourceSponsor => ({
|
||||
type: 'tool',
|
||||
id: 't1',
|
||||
user_id: 'bob',
|
||||
label: 'bob@example.com',
|
||||
active: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('SponsoredResourcesNotice', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (
|
||||
agent: Agent,
|
||||
takeovers: string[] = [],
|
||||
onToggleTakeover = vi.fn(),
|
||||
) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SponsoredResourcesNotice
|
||||
agent={agent}
|
||||
resolveName={(s) => `name-${s.id}`}
|
||||
takeovers={takeovers}
|
||||
onToggleTakeover={onToggleTakeover}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return onToggleTakeover;
|
||||
};
|
||||
|
||||
const buttons = () => Array.from(container.querySelectorAll('button'));
|
||||
|
||||
const alerts = () =>
|
||||
Array.from(container.querySelectorAll('[data-slot="alert"]'));
|
||||
|
||||
it('renders nothing for the owner with no sponsored items', async () => {
|
||||
await render(baseAgent);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('tells an editor their own items run with their access', async () => {
|
||||
await render({ ...baseAgent, access: 'editor', allowed_actions: ['edit'] });
|
||||
expect(alerts()).toHaveLength(1);
|
||||
expect(container.textContent).toContain('agents.form.sponsors.attachNote');
|
||||
expect(container.textContent).not.toContain('publicLinkNote');
|
||||
});
|
||||
|
||||
it('adds the public-link warning when the agent has a link', async () => {
|
||||
await render({
|
||||
...baseAgent,
|
||||
shared: true,
|
||||
access: 'editor',
|
||||
allowed_actions: ['edit'],
|
||||
});
|
||||
expect(container.textContent).toContain(
|
||||
'agents.form.sponsors.publicLinkNote',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show the attach note to a viewer', async () => {
|
||||
await render({ ...baseAgent, access: 'viewer', allowed_actions: ['use'] });
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('joins names for the app language and does not escape them', async () => {
|
||||
await i18n.use(initReactI18next).init({
|
||||
lng: 'jp',
|
||||
resources: {
|
||||
jp: {
|
||||
translation: {
|
||||
agents: {
|
||||
form: { sponsors: { addedBy: '{{person}}: {{names}}' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
await render({
|
||||
...baseAgent,
|
||||
resource_sponsors: [
|
||||
sponsor({ id: 'docs/a' }),
|
||||
sponsor({ id: 'docs/b' }),
|
||||
],
|
||||
});
|
||||
const expected = new Intl.ListFormat('ja', {
|
||||
type: 'conjunction',
|
||||
}).format(['name-docs/a', 'name-docs/b']);
|
||||
expect(container.textContent).toBe(`bob@example.com: ${expected}`);
|
||||
} finally {
|
||||
await i18n.changeLanguage('en');
|
||||
}
|
||||
});
|
||||
|
||||
it('splits running and no-longer-running items', async () => {
|
||||
await render({
|
||||
...baseAgent,
|
||||
resource_sponsors: [
|
||||
sponsor({ id: 't1' }),
|
||||
sponsor({ id: 's1', type: 'source', active: false }),
|
||||
],
|
||||
});
|
||||
const [running, stopped] = alerts();
|
||||
expect(running.getAttribute('role')).toBe('note');
|
||||
expect(running.textContent).toContain('agents.form.sponsors.addedBy');
|
||||
expect(stopped.getAttribute('data-variant')).toBe('warning');
|
||||
expect(stopped.textContent).toContain('agents.form.sponsors.unavailable');
|
||||
});
|
||||
|
||||
it('says why each item stopped', async () => {
|
||||
await render({
|
||||
...baseAgent,
|
||||
resource_sponsors: [
|
||||
sponsor({
|
||||
id: 't1',
|
||||
active: false,
|
||||
state: 'inactive',
|
||||
reason: 'sponsor_cannot_edit_agent',
|
||||
}),
|
||||
sponsor({
|
||||
id: 's1',
|
||||
type: 'source',
|
||||
active: false,
|
||||
state: 'inactive',
|
||||
reason: 'sponsor_cannot_edit_resource',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const text = container.textContent ?? '';
|
||||
expect(text).toContain('agents.form.sponsors.stoppedCannotEditAgent');
|
||||
expect(text).toContain('agents.form.sponsors.stoppedCannotEditItem');
|
||||
expect(text).not.toContain('agents.form.sponsors.unavailable');
|
||||
});
|
||||
|
||||
it('offers a take-over only for items the reader may confirm', async () => {
|
||||
const toggle = await render({
|
||||
...baseAgent,
|
||||
access: 'editor',
|
||||
allowed_actions: ['edit'],
|
||||
resource_sponsors: [
|
||||
sponsor({ key: 'tool:t1', id: 't1', active: false, can_confirm: true }),
|
||||
sponsor({
|
||||
key: 'tool:t2',
|
||||
id: 't2',
|
||||
active: false,
|
||||
can_confirm: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const takeOver = buttons().filter((b) =>
|
||||
b.textContent?.includes('agents.form.sponsors.takeOver'),
|
||||
);
|
||||
expect(takeOver).toHaveLength(1);
|
||||
await act(async () => takeOver[0].click());
|
||||
expect(toggle).toHaveBeenCalledWith('tool:t1');
|
||||
});
|
||||
|
||||
it('shows a pending take-over with an undo', async () => {
|
||||
const toggle = await render(
|
||||
{
|
||||
...baseAgent,
|
||||
access: 'editor',
|
||||
allowed_actions: ['edit'],
|
||||
resource_sponsors: [
|
||||
sponsor({
|
||||
key: 'tool:t1',
|
||||
id: 't1',
|
||||
active: false,
|
||||
can_confirm: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
['tool:t1'],
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
'agents.form.sponsors.takeOverPending',
|
||||
);
|
||||
const undo = buttons().find((b) =>
|
||||
b.textContent?.includes('agents.form.sponsors.undoTakeOver'),
|
||||
)!;
|
||||
await act(async () => undo.click());
|
||||
expect(toggle).toHaveBeenCalledWith('tool:t1');
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Info, TriangleAlert, UserRound } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { can, isOwner } from '../../utils/accessUtils';
|
||||
import { intlLocale } from '../../utils/dateTimeUtils';
|
||||
import type { Agent, ResourceSponsor } from '../types';
|
||||
|
||||
type SponsoredResourcesNoticeProps = {
|
||||
agent: Agent;
|
||||
/** Display name of a sponsored tool, source or prompt. */
|
||||
resolveName: (sponsor: ResourceSponsor) => string;
|
||||
/** Keys of stopped items the reader chose to run with their access on save. */
|
||||
takeovers?: string[];
|
||||
/** Turn a take-over on or off for one item's key. */
|
||||
onToggleTakeover?: (key: string) => void;
|
||||
};
|
||||
|
||||
/** Why an item stopped, as the message key that says so. */
|
||||
function stoppedMessageKey(sponsor: ResourceSponsor): string {
|
||||
if (sponsor.reason === 'sponsor_cannot_edit_agent')
|
||||
return 'agents.form.sponsors.stoppedCannotEditAgent';
|
||||
if (sponsor.reason === 'sponsor_cannot_edit_resource')
|
||||
return 'agents.form.sponsors.stoppedCannotEditItem';
|
||||
return 'agents.form.sponsors.unavailable';
|
||||
}
|
||||
|
||||
/** Items grouped by the person who added them and ``extra``, in first-seen order. */
|
||||
function groupByPerson(
|
||||
sponsors: ResourceSponsor[],
|
||||
extra: (sponsor: ResourceSponsor) => string = () => '',
|
||||
) {
|
||||
const groups = new Map<string, ResourceSponsor[]>();
|
||||
for (const sponsor of sponsors) {
|
||||
const key = `${sponsor.label || sponsor.user_id}\u0000${extra(sponsor)}`;
|
||||
groups.set(key, [...(groups.get(key) ?? []), sponsor]);
|
||||
}
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
|
||||
const sponsorKey = (sponsor: ResourceSponsor) =>
|
||||
sponsor.key || `${sponsor.type}:${sponsor.id}`;
|
||||
|
||||
/**
|
||||
* Tools, sources and prompts on the agent that run with an editor's access
|
||||
* rather than the owner's (`resource_sponsors` from GET /api/get_agent).
|
||||
*
|
||||
* An editor sees up front that what they attach runs with their access for
|
||||
* everyone who uses the agent. Everyone who may edit the agent sees who added
|
||||
* what, and which items stopped and why. An item the reader may sponsor
|
||||
* (`can_confirm`) can be taken over: it then runs with the reader's access
|
||||
* from their next save.
|
||||
*/
|
||||
export default function SponsoredResourcesNotice({
|
||||
agent,
|
||||
resolveName,
|
||||
takeovers = [],
|
||||
onToggleTakeover,
|
||||
}: SponsoredResourcesNoticeProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const sponsors = agent.resource_sponsors ?? [];
|
||||
const showAttachNote = !isOwner(agent) && can(agent, 'edit');
|
||||
const active = groupByPerson(sponsors.filter((s) => s.active));
|
||||
const inactiveItems = sponsors.filter((s) => !s.active);
|
||||
const inactive = groupByPerson(inactiveItems, stoppedMessageKey);
|
||||
const claimable = onToggleTakeover
|
||||
? inactiveItems.filter((s) => s.can_confirm)
|
||||
: [];
|
||||
|
||||
if (!showAttachNote && sponsors.length === 0) return null;
|
||||
|
||||
const listFormat = new Intl.ListFormat(intlLocale(i18n.language), {
|
||||
type: 'conjunction',
|
||||
});
|
||||
const names = (items: ResourceSponsor[]) =>
|
||||
listFormat.format(items.map((item) => resolveName(item)));
|
||||
const person = (items: ResourceSponsor[]) =>
|
||||
items[0].label || items[0].user_id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:col-span-2">
|
||||
{showAttachNote && (
|
||||
<Alert role="note">
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
{t('agents.form.sponsors.attachNote')}
|
||||
{agent.shared ? ` ${t('agents.form.sponsors.publicLinkNote')}` : ''}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{active.length > 0 && (
|
||||
<Alert role="note">
|
||||
<UserRound />
|
||||
<AlertDescription>
|
||||
{active.map((items) => (
|
||||
<p key={sponsorKey(items[0])}>
|
||||
{t('agents.form.sponsors.addedBy', {
|
||||
interpolation: { escapeValue: false },
|
||||
person: person(items),
|
||||
names: names(items),
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{inactive.length > 0 && (
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertDescription>
|
||||
<div className="flex flex-col gap-2">
|
||||
{inactive.map((items) => (
|
||||
<p key={sponsorKey(items[0])}>
|
||||
{t(stoppedMessageKey(items[0]), {
|
||||
interpolation: { escapeValue: false },
|
||||
person: person(items),
|
||||
names: names(items),
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
{claimable.map((item) => {
|
||||
const key = sponsorKey(item);
|
||||
const name = resolveName(item);
|
||||
return takeovers.includes(key) ? (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1"
|
||||
>
|
||||
<span>
|
||||
{t('agents.form.sponsors.takeOverPending', {
|
||||
interpolation: { escapeValue: false },
|
||||
name,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="inline"
|
||||
onClick={() => onToggleTakeover?.(key)}
|
||||
>
|
||||
{t('agents.form.sponsors.undoTakeOver')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div key={key}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => onToggleTakeover?.(key)}
|
||||
>
|
||||
{t('agents.form.sponsors.takeOver', {
|
||||
interpolation: { escapeValue: false },
|
||||
name,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { MultiSelectPopoverItem } from '../components/MultiSelectPopover';
|
||||
import {
|
||||
confirmTakeOver,
|
||||
NO_AUDIENCE,
|
||||
readSponsorRefusal,
|
||||
saveWithSponsorConsent,
|
||||
withAttachedOptions,
|
||||
@@ -179,3 +181,39 @@ describe('withAttachedOptions', () => {
|
||||
expect(options).toEqual([{ value: 'x1', label: 'x1' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmTakeOver', () => {
|
||||
const item = { key: 'tool:t1', type: 'tool' as const, id: 't1' };
|
||||
const audience = {
|
||||
teams: ['Sales'],
|
||||
api_key: true,
|
||||
public_link: false,
|
||||
webhook: false,
|
||||
};
|
||||
|
||||
it('shows the item and who reaches it, and agrees when confirmed', async () => {
|
||||
const ask = vi.fn(() => Promise.resolve(['tool:t1']));
|
||||
await expect(confirmTakeOver(ask, item, 'Jira', audience)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
expect(ask).toHaveBeenCalledWith({
|
||||
resources: [{ ...item, name: 'Jira' }],
|
||||
audience,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not agree when the caller cancels', async () => {
|
||||
const ask = vi.fn(() => Promise.resolve(null));
|
||||
await expect(confirmTakeOver(ask, item, 'Jira', audience)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('asks with an empty audience when the read sent none', async () => {
|
||||
const ask = vi.fn(() => Promise.resolve(['tool:t1']));
|
||||
await confirmTakeOver(ask, item, 'Jira', undefined);
|
||||
expect(ask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ audience: NO_AUDIENCE }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,14 @@ export type SponsorAudience = {
|
||||
webhook: boolean;
|
||||
};
|
||||
|
||||
/** An audience with nobody in it, for a read that sent none. */
|
||||
export const NO_AUDIENCE: SponsorAudience = {
|
||||
teams: [],
|
||||
api_key: false,
|
||||
public_link: false,
|
||||
webhook: false,
|
||||
};
|
||||
|
||||
/** What a save asks the caller to agree to before it goes ahead. */
|
||||
export type SponsorConfirmation = {
|
||||
resources: SponsorResource[];
|
||||
@@ -181,3 +189,29 @@ export function withAttachedToolRows(
|
||||
}
|
||||
return extra.length ? [...items, ...extra] : items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the caller to run one stopped item with their access, naming who
|
||||
* reaches it through the agent (the read's `sponsor_audience`).
|
||||
*
|
||||
* Args:
|
||||
* ask: Shows the confirmation; resolves to the agreed keys, or null.
|
||||
* item: The stopped item.
|
||||
* name: Its display name.
|
||||
* audience: Who reaches the agent's resources.
|
||||
*
|
||||
* Returns:
|
||||
* Whether they agreed; the next save then sends its key.
|
||||
*/
|
||||
export async function confirmTakeOver(
|
||||
ask: (confirmation: SponsorConfirmation) => Promise<string[] | null>,
|
||||
item: Pick<SponsorResource, 'key' | 'type' | 'id'>,
|
||||
name: string,
|
||||
audience: SponsorAudience | undefined,
|
||||
): Promise<boolean> {
|
||||
const agreed = await ask({
|
||||
resources: [{ key: item.key, type: item.type, id: item.id, name }],
|
||||
audience: audience ?? NO_AUDIENCE,
|
||||
});
|
||||
return Boolean(agreed?.includes(item.key));
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SponsorAudience } from '../sponsorConsent';
|
||||
import type { AccessFields } from '../../utils/accessUtils';
|
||||
|
||||
export type ToolSummary = {
|
||||
@@ -30,6 +31,48 @@ export type ResourceSponsor = {
|
||||
can_confirm?: boolean;
|
||||
};
|
||||
|
||||
/** Why an attached item doesn't run (`ResourceState.reason`). */
|
||||
export type ResourceStateReason =
|
||||
| 'deleted'
|
||||
| 'owner_lost_access'
|
||||
| ResourceSponsorReason
|
||||
| 'connection_needs_reconnect'
|
||||
| 'connection_removed'
|
||||
| 'connector_disabled';
|
||||
|
||||
/** A person the page names: their email when on file, else their user id. */
|
||||
export type ResourcePerson = { user_id: string; label: string };
|
||||
|
||||
/**
|
||||
* Whether an attached tool, source or prompt runs (`resource_states` on the
|
||||
* agent and workflow reads, for people who may edit them).
|
||||
*/
|
||||
export type ResourceState = {
|
||||
/** `"<type>:<id>"`, the value `confirm_sponsor` takes. */
|
||||
key: string;
|
||||
type: 'tool' | 'source' | 'prompt';
|
||||
id: string;
|
||||
/** Null when the reader may not see it. */
|
||||
name?: string | null;
|
||||
state: 'active' | 'stopped';
|
||||
/** Null while it runs. */
|
||||
reason: ResourceStateReason | null;
|
||||
/** Who it ran with the access of, when someone else added it. */
|
||||
sponsor?: ResourcePerson | null;
|
||||
/** Someone other than the reader who can fix it. */
|
||||
contact?: ResourcePerson | null;
|
||||
/** The service, for a connection reason. */
|
||||
connection?: {
|
||||
id: string | null;
|
||||
connector_key: string | null;
|
||||
name: string | null;
|
||||
} | null;
|
||||
/** The reader may run it with their access by confirming on a save. */
|
||||
can_confirm?: boolean;
|
||||
/** The reader owns the connection that needs signing in again. */
|
||||
can_reconnect?: boolean;
|
||||
};
|
||||
|
||||
export type Agent = {
|
||||
id?: string;
|
||||
name: string;
|
||||
@@ -68,6 +111,12 @@ export type Agent = {
|
||||
// Resources the owner can't use that run as the editor who attached them
|
||||
// (GET /api/get_agent, callers who may view the config).
|
||||
resource_sponsors?: ResourceSponsor[];
|
||||
// Whether each attached tool, source and prompt runs, and why not
|
||||
// (GET /api/get_agent, callers who may edit the agent).
|
||||
resource_states?: ResourceState[];
|
||||
// Who reaches the agent's resources, sent when the caller may take one
|
||||
// over, so the take-over confirmation can say who it extends to.
|
||||
sponsor_audience?: SponsorAudience;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
last_used_at?: string;
|
||||
|
||||
@@ -50,15 +50,21 @@ import { ActionMenu } from '@/components/ui/dropdown-menu';
|
||||
import AgentPageHeader from '../AgentPageHeader';
|
||||
import AgentPreviewSheet from '../components/AgentPreviewSheet';
|
||||
import {
|
||||
confirmTakeOver,
|
||||
readSponsorRefusal,
|
||||
saveWithSponsorConsent,
|
||||
sponsorNotAllowedMessage,
|
||||
} from '../sponsorConsent';
|
||||
import { useSponsorPrompt } from '../useSponsorPrompt';
|
||||
import ResourceStatusNotice, {
|
||||
type NamedResource,
|
||||
} from '../components/ResourceStatusNotice';
|
||||
import { useSignInAgain } from '../../connectors/SignInAgainNotice';
|
||||
import WorkflowDetailsSheet, {
|
||||
type WorkflowDetailsSave,
|
||||
} from './components/WorkflowDetailsSheet';
|
||||
import { Agent } from '../types';
|
||||
import type { SponsorAudience } from '../sponsorConsent';
|
||||
import { Agent, ResourceSponsor, ResourceState } from '../types';
|
||||
import { ConditionCase, WorkflowNode } from '../types/workflow';
|
||||
import {
|
||||
createDefaultCodeConfig,
|
||||
@@ -91,6 +97,11 @@ import ConditionPanel from './panels/ConditionPanel';
|
||||
import NotePanel from './panels/NotePanel';
|
||||
import StatePanel from './panels/StatePanel';
|
||||
import WorkflowPreview from './WorkflowPreview';
|
||||
import {
|
||||
nodeResourceIds,
|
||||
stoppedNodeResources,
|
||||
withoutNodeResource,
|
||||
} from './nodeResources';
|
||||
import {
|
||||
type AgentNodeConfig,
|
||||
findFreePosition,
|
||||
@@ -234,6 +245,22 @@ function WorkflowBuilderInner() {
|
||||
// Asks before a node resource the owner can't use runs with the
|
||||
// caller's access; the save that asked gets the retried save's result.
|
||||
const sponsorPrompt = useSponsorPrompt();
|
||||
// What runs on the nodes with an editor's access and what stopped, from
|
||||
// the workflow read (people who may edit it).
|
||||
const [workflowResources, setWorkflowResources] = useState<{
|
||||
sponsors: ResourceSponsor[];
|
||||
states: ResourceState[];
|
||||
audience?: SponsorAudience;
|
||||
}>({ sponsors: [], states: [] });
|
||||
// Keys of stopped node resources the caller agreed to run with their
|
||||
// access; sent as ``confirm_sponsor`` with the next save.
|
||||
const [takeovers, setTakeovers] = useState<string[]>([]);
|
||||
const [resourceNoticeOpen, setResourceNoticeOpen] = useState(true);
|
||||
// Bumped after a reconnect so the run state is read again.
|
||||
const [resourcesReloadKey, setResourcesReloadKey] = useState(0);
|
||||
const signInAgain = useSignInAgain({
|
||||
onConnected: () => setResourcesReloadKey((key) => key + 1),
|
||||
});
|
||||
const [errorContext, setErrorContext] = useState<'preview' | 'publish'>(
|
||||
'publish',
|
||||
);
|
||||
@@ -928,6 +955,42 @@ function WorkflowBuilderInner() {
|
||||
loadAgentDetails();
|
||||
}, [agentId, token]);
|
||||
|
||||
const applyResourceDetails = useCallback(
|
||||
(data: {
|
||||
resource_sponsors?: ResourceSponsor[];
|
||||
resource_states?: ResourceState[];
|
||||
sponsor_audience?: SponsorAudience;
|
||||
}) =>
|
||||
setWorkflowResources({
|
||||
sponsors: data.resource_sponsors ?? [],
|
||||
states: data.resource_states ?? [],
|
||||
audience: data.sponsor_audience,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Fresh sponsor details and run state after a save or a reconnect,
|
||||
// without touching the canvas.
|
||||
const refreshResourceDetails = useCallback(
|
||||
async (id: string | null) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const response = await userService.getWorkflow(id, token);
|
||||
if (!response.ok) return;
|
||||
const responseData = await response.json();
|
||||
applyResourceDetails(responseData.data ?? {});
|
||||
} catch {
|
||||
// The notice keeps what it showed.
|
||||
}
|
||||
},
|
||||
[applyResourceDetails, token],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (resourcesReloadKey > 0) void refreshResourceDetails(workflowId);
|
||||
// Only a reconnect asks for this; the workflow id is read when it does.
|
||||
}, [resourcesReloadKey, refreshResourceDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadWorkflow = async () => {
|
||||
if (!workflowId) return;
|
||||
@@ -941,6 +1004,7 @@ function WorkflowBuilderInner() {
|
||||
edges: apiEdges,
|
||||
ref_details: refDetails,
|
||||
} = responseData.data;
|
||||
applyResourceDetails(responseData.data);
|
||||
setNodeRefNames({
|
||||
tools: (refDetails?.tools ?? []).map(
|
||||
(tool: { id: string; name?: string; display_name?: string }) => ({
|
||||
@@ -1029,7 +1093,13 @@ function WorkflowBuilderInner() {
|
||||
}
|
||||
};
|
||||
loadWorkflow();
|
||||
}, [workflowId, reactFlowInstance, token, clearHistory]);
|
||||
}, [
|
||||
workflowId,
|
||||
reactFlowInstance,
|
||||
token,
|
||||
clearHistory,
|
||||
applyResourceDetails,
|
||||
]);
|
||||
|
||||
const validateWorkflow = useCallback((): string[] => {
|
||||
const errors: string[] = [];
|
||||
@@ -1340,7 +1410,8 @@ function WorkflowBuilderInner() {
|
||||
const hasSavableChanges =
|
||||
canManageAgent && savedWorkflowSignature !== null
|
||||
? workflowPayloadSignature !== savedWorkflowSignature ||
|
||||
imageFile !== null
|
||||
imageFile !== null ||
|
||||
takeovers.length > 0
|
||||
: false;
|
||||
|
||||
const persistWorkflow = useCallback(
|
||||
@@ -1373,11 +1444,15 @@ function WorkflowBuilderInner() {
|
||||
token,
|
||||
),
|
||||
sponsorPrompt.ask,
|
||||
takeovers,
|
||||
);
|
||||
if (!updateResponse) return 'cancelled';
|
||||
if (!updateResponse.ok) {
|
||||
const refusal = await readSponsorRefusal(updateResponse);
|
||||
if (refusal?.kind === 'unexpected') {
|
||||
// Someone changed the workflow since the caller chose.
|
||||
setTakeovers([]);
|
||||
void refreshResourceDetails(workflowId);
|
||||
throw new Error(t('agents.form.sponsors.confirmationOutdated'));
|
||||
}
|
||||
if (refusal?.kind === 'notAllowed') {
|
||||
@@ -1430,6 +1505,8 @@ function WorkflowBuilderInner() {
|
||||
}
|
||||
setImageFile(null);
|
||||
setSavedWorkflowSignature(JSON.stringify(workflowPayload));
|
||||
setTakeovers([]);
|
||||
void refreshResourceDetails(workflowId);
|
||||
if (navigateAfterSuccess) {
|
||||
navigateBackToAgents();
|
||||
}
|
||||
@@ -1553,6 +1630,8 @@ function WorkflowBuilderInner() {
|
||||
t,
|
||||
i18n.language,
|
||||
sponsorPrompt.ask,
|
||||
takeovers,
|
||||
refreshResourceDetails,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1602,6 +1681,80 @@ function WorkflowBuilderInner() {
|
||||
void persistWorkflow(false);
|
||||
}, [isPrimaryActionDisabled, persistWorkflow]);
|
||||
|
||||
// Stopped node resources still on the canvas.
|
||||
const stoppedResources = useMemo(
|
||||
() =>
|
||||
stoppedNodeResources(workflowResources.states, nodeResourceIds(nodes)),
|
||||
[workflowResources.states, nodes],
|
||||
);
|
||||
|
||||
const resolveResourceName = useCallback(
|
||||
(item: NamedResource): string => {
|
||||
if (item.name) return item.name;
|
||||
const known = (
|
||||
item.type === 'tool' ? nodeRefNames.tools : nodeRefNames.sources
|
||||
).find((entry) => entry.id.toLowerCase() === item.id)?.label;
|
||||
return known || t('agents.form.sponsors.unknownItem');
|
||||
},
|
||||
[nodeRefNames, t],
|
||||
);
|
||||
|
||||
/** Take a stopped tool or source off every agent node; saving stores it. */
|
||||
const removeResource = useCallback(
|
||||
(item: ResourceState) => {
|
||||
takeSnapshot();
|
||||
setNodes((prev) => withoutNodeResource(prev, item));
|
||||
setTakeovers((prev) => prev.filter((k) => k !== item.key));
|
||||
},
|
||||
[takeSnapshot],
|
||||
);
|
||||
|
||||
/**
|
||||
* Ask before a stopped node resource runs with the caller's access,
|
||||
* naming who reaches it through the workflow; on yes the next save
|
||||
* confirms it.
|
||||
*/
|
||||
const takeOverResource = useCallback(
|
||||
async (item: ResourceState) => {
|
||||
const agreed = await confirmTakeOver(
|
||||
sponsorPrompt.ask,
|
||||
item,
|
||||
resolveResourceName(item),
|
||||
workflowResources.audience,
|
||||
);
|
||||
if (agreed)
|
||||
setTakeovers((prev) =>
|
||||
prev.includes(item.key) ? prev : [...prev, item.key],
|
||||
);
|
||||
},
|
||||
[resolveResourceName, sponsorPrompt.ask, workflowResources.audience],
|
||||
);
|
||||
|
||||
/** Sign a stopped node tool's connection in again, in place where possible. */
|
||||
const reconnectResource = useCallback(
|
||||
(item: ResourceState) => {
|
||||
const connection = item.connection;
|
||||
if (!connection?.id || !connection.connector_key) return;
|
||||
const isMcp =
|
||||
availableTools.find((tool) => tool.id === item.id)?.name === 'mcp_tool';
|
||||
signInAgain.reconnect(
|
||||
{ id: connection.id, connector_key: connection.connector_key },
|
||||
isMcp ? item.id : undefined,
|
||||
);
|
||||
},
|
||||
[availableTools, signInAgain],
|
||||
);
|
||||
|
||||
const resourceNoticeAgent = useMemo<Agent>(
|
||||
() => ({ ...currentAgent, resource_sponsors: workflowResources.sponsors }),
|
||||
[currentAgent, workflowResources.sponsors],
|
||||
);
|
||||
const showResourceNotice =
|
||||
canManageAgent &&
|
||||
resourceNoticeOpen &&
|
||||
(stoppedResources.length > 0 ||
|
||||
workflowResources.sponsors.some((sponsor) => sponsor.active));
|
||||
|
||||
const agentForDetails = useMemo<Agent>(
|
||||
() => ({
|
||||
...createEmptyWorkflowAgent(),
|
||||
@@ -1855,6 +2008,34 @@ function WorkflowBuilderInner() {
|
||||
ref={reactFlowWrapper}
|
||||
className="bg-muted relative min-w-0 flex-1"
|
||||
>
|
||||
{showResourceNotice && (
|
||||
<div className="bg-card absolute top-4 left-4 z-20 max-h-[60%] w-full max-w-md overflow-y-auto rounded-xl shadow-md">
|
||||
<div className="relative p-3 pr-10">
|
||||
<ResourceStatusNotice
|
||||
agent={resourceNoticeAgent}
|
||||
stopped={stoppedResources}
|
||||
resolveName={resolveResourceName}
|
||||
takeovers={takeovers}
|
||||
showAttachNote={false}
|
||||
onTakeOver={(item) => void takeOverResource(item)}
|
||||
onUndoTakeover={(key) =>
|
||||
setTakeovers((prev) => prev.filter((k) => k !== key))
|
||||
}
|
||||
onRemove={removeResource}
|
||||
onReconnect={reconnectResource}
|
||||
/>
|
||||
<div className="absolute top-2.5 right-2.5">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setResourceNoticeOpen(false)}
|
||||
label={t('agents.close')}
|
||||
icon={X}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<WorkflowModelsContext.Provider value={modelNames}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
@@ -2004,6 +2185,7 @@ function WorkflowBuilderInner() {
|
||||
/>
|
||||
</AgentPreviewSheet>
|
||||
{sponsorPrompt.modal}
|
||||
{signInAgain.modals}
|
||||
<ConfirmationModal
|
||||
message={
|
||||
workflowName
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Node } from 'reactflow';
|
||||
|
||||
import type { ResourceState } from '../types';
|
||||
import {
|
||||
nodeResourceIds,
|
||||
stoppedNodeResources,
|
||||
withoutNodeResource,
|
||||
} from './nodeResources';
|
||||
|
||||
const agentNode = (id: string, config: Record<string, unknown>): Node => ({
|
||||
id,
|
||||
type: 'agent',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { title: id, config },
|
||||
});
|
||||
|
||||
const nodes: Node[] = [
|
||||
{ id: 'start', type: 'start', position: { x: 0, y: 0 }, data: {} },
|
||||
agentNode('a1', { tools: ['T1', 't2'], sources: ['s1'] }),
|
||||
agentNode('a2', { tools: ['t1'], sources: [] }),
|
||||
];
|
||||
|
||||
const state = (over: Partial<ResourceState>): ResourceState => ({
|
||||
key: 'tool:t1',
|
||||
type: 'tool',
|
||||
id: 't1',
|
||||
state: 'stopped',
|
||||
reason: 'deleted',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('nodeResources', () => {
|
||||
it('collects every agent node tool and source, lowercased', () => {
|
||||
const ids = nodeResourceIds(nodes);
|
||||
expect([...ids.tool].sort()).toEqual(['t1', 't2']);
|
||||
expect([...ids.source]).toEqual(['s1']);
|
||||
});
|
||||
|
||||
it('keeps only stopped items still on the canvas', () => {
|
||||
const states = [
|
||||
state({}),
|
||||
state({ key: 'tool:t2', id: 't2', state: 'active', reason: null }),
|
||||
state({ key: 'source:gone', type: 'source', id: 'gone' }),
|
||||
];
|
||||
expect(
|
||||
stoppedNodeResources(states, nodeResourceIds(nodes)).map((s) => s.key),
|
||||
).toEqual(['tool:t1']);
|
||||
});
|
||||
|
||||
it('takes a tool off every agent node and leaves the rest alone', () => {
|
||||
const next = withoutNodeResource(nodes, { type: 'tool', id: 't1' });
|
||||
expect(next[1].data.config.tools).toEqual(['t2']);
|
||||
expect(next[2].data.config.tools).toEqual([]);
|
||||
expect(next[0]).toBe(nodes[0]);
|
||||
expect(next[1].data.config.sources).toEqual(['s1']);
|
||||
expect(nodeResourceIds(next).tool.has('t1')).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves nodes untouched for a prompt', () => {
|
||||
expect(withoutNodeResource(nodes, { type: 'prompt', id: 'p' })).toBe(nodes);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Node } from 'reactflow';
|
||||
|
||||
import type { ResourceState } from '../types';
|
||||
|
||||
/** Tool and source ids a workflow's agent nodes reference, lowercased. */
|
||||
export type NodeResourceIds = { tool: Set<string>; source: Set<string> };
|
||||
|
||||
const KEYS = { tool: 'tools', source: 'sources' } as const;
|
||||
|
||||
function idsOf(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map(String);
|
||||
return value ? [String(value)] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool and source ids the agent nodes reference now.
|
||||
*
|
||||
* Args:
|
||||
* nodes: The canvas nodes (an agent node keeps them in `data.config`).
|
||||
*/
|
||||
export function nodeResourceIds(nodes: Node[]): NodeResourceIds {
|
||||
const ids: NodeResourceIds = { tool: new Set(), source: new Set() };
|
||||
for (const node of nodes) {
|
||||
if (node.type !== 'agent') continue;
|
||||
const config = (node.data?.config ?? {}) as Record<string, unknown>;
|
||||
for (const type of ['tool', 'source'] as const)
|
||||
idsOf(config[KEYS[type]]).forEach((id) =>
|
||||
ids[type].add(id.toLowerCase()),
|
||||
);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stopped node resources still on the canvas, so one removed here (not yet
|
||||
* saved) leaves the notice.
|
||||
*
|
||||
* Args:
|
||||
* states: `resource_states` from the workflow read.
|
||||
* ids: What the nodes reference now.
|
||||
*/
|
||||
export function stoppedNodeResources(
|
||||
states: ResourceState[],
|
||||
ids: NodeResourceIds,
|
||||
): ResourceState[] {
|
||||
return states.filter(
|
||||
(item) =>
|
||||
item.state === 'stopped' &&
|
||||
item.type !== 'prompt' &&
|
||||
ids[item.type].has(item.id.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The nodes with one tool or source taken off every agent node.
|
||||
*
|
||||
* Args:
|
||||
* nodes: The canvas nodes.
|
||||
* item: The tool or source to take off.
|
||||
*/
|
||||
export function withoutNodeResource(
|
||||
nodes: Node[],
|
||||
item: Pick<ResourceState, 'type' | 'id'>,
|
||||
): Node[] {
|
||||
if (item.type === 'prompt') return nodes;
|
||||
const key = KEYS[item.type];
|
||||
const id = item.id.toLowerCase();
|
||||
return nodes.map((node) => {
|
||||
const config = node.data?.config;
|
||||
if (node.type !== 'agent' || !config || config[key] === undefined)
|
||||
return node;
|
||||
const kept = idsOf(config[key]).filter((ref) => ref.toLowerCase() !== id);
|
||||
if (kept.length === idsOf(config[key]).length) return node;
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
config: {
|
||||
...config,
|
||||
[key]: Array.isArray(config[key]) ? kept : (kept[0] ?? ''),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -2368,16 +2368,38 @@
|
||||
"attachNote": "Tools, Quellen und Prompts, die du hinzufügst und die der Eigentümer nicht nutzen kann, laufen für alle, die diesen Agenten nutzen, mit deinem Zugriff. Du musst sie besitzen oder bearbeiten können und wirst um Bestätigung gebeten.",
|
||||
"publicLinkNote": "Dieser Agent hat einen öffentlichen Link, daher kann jeder mit dem Link Antworten daraus erhalten.",
|
||||
"addedBy": "Hinzugefügt von {{person}}: {{names}}",
|
||||
"unavailable": "Nicht aktiv: {{names}}. {{person}} hat sie hinzugefügt und kann sie nicht mehr über diesen Agenten teilen. Entferne sie oder wähle welche, die der Eigentümer nutzen kann.",
|
||||
"unknownItem": "Unbenanntes Element",
|
||||
"notAllowed": "Du kannst {{names}} diesem Agenten nicht hinzufügen. Der Eigentümer kann sie nicht nutzen, und du kannst nur teilen, was du besitzt oder bearbeiten kannst.",
|
||||
"stoppedCannotEditAgent": "Nicht aktiv: {{names}}. {{person}} hat sie hinzugefügt und kann diesen Agenten nicht mehr bearbeiten. Entferne sie oder wähle welche, die der Eigentümer nutzen kann.",
|
||||
"stoppedCannotEditItem": "Nicht aktiv: {{names}}. {{person}} hat sie hinzugefügt, kann sie aber nicht mehr bearbeiten, und nur wer ein Element besitzt oder bearbeiten kann, kann es über einen Agenten teilen. Entferne sie oder wähle welche, die der Eigentümer nutzen kann.",
|
||||
"takeOver": "{{name}} mit meinem Zugriff ausführen",
|
||||
"takeOverPending": "{{name}} läuft mit deinem Zugriff, sobald du speicherst.",
|
||||
"undoTakeOver": "Rückgängig",
|
||||
"confirmationOutdated": "Dieser Agent hat sich geändert, seit du bestätigt hast, was mit deinem Zugriff läuft. Prüfe die Hinweise und speichere erneut."
|
||||
},
|
||||
"resourceStates": {
|
||||
"title": "Einige Tools, Quellen oder Prompts laufen nicht",
|
||||
"reason": {
|
||||
"deleted": "{{name}} wurde gelöscht.",
|
||||
"ownerLostAccessYou": "Du kannst {{name}} nicht mehr verwenden.",
|
||||
"ownerLostAccess": "Der Eigentümer des Agenten kann {{name}} nicht mehr verwenden.",
|
||||
"sponsorCannotEditAgent": "{{name}} lief mit dem Zugriff von {{person}}, und diese Person kann den Agenten nicht mehr bearbeiten.",
|
||||
"sponsorCannotEditItem": "{{name}} lief mit dem Zugriff von {{person}}, und diese Person kann es nicht mehr bearbeiten.",
|
||||
"connectionNeedsReconnect": "Für {{name}} muss das {{service}}-Konto erneut angemeldet werden.",
|
||||
"connectionRemoved": "Das {{service}}-Konto, das {{name}} verwendet hat, wurde entfernt.",
|
||||
"connectorDisabled": "Ein Admin hat {{service}} deaktiviert, daher kann {{name}} nicht laufen.",
|
||||
"unknown": "{{name}} läuft nicht."
|
||||
},
|
||||
"ask": {
|
||||
"shareAgain": "Bitte {{person}}, es erneut zu teilen.",
|
||||
"signInAgain": "Bitte {{person}}, sich erneut anzumelden.",
|
||||
"connectAgain": "Bitte {{person}}, das Konto erneut zu verbinden.",
|
||||
"admin": "Bitte einen Admin, es wieder zu aktivieren."
|
||||
},
|
||||
"promptFallback": "Bis dahin verwendet der Agent den Standard-Prompt.",
|
||||
"serviceFallback": "Dienst",
|
||||
"remove": "Entfernen",
|
||||
"removeLabel": "{{name}} entfernen",
|
||||
"reconnectLabel": "{{name}} neu verbinden"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
"title": "Mit deinem Zugriff ausführen?",
|
||||
"description": "Der Eigentümer des Agenten kann diese nicht nutzen. Wenn du sie hinzufügst, laufen sie für alle, die diesen Agenten nutzen, mit deinem Zugriff.",
|
||||
|
||||
@@ -2386,16 +2386,38 @@
|
||||
"attachNote": "Tools, sources and prompts you add that the owner can't use run with your access for everyone who uses this agent. You need to own them or be able to edit them, and you'll be asked to confirm.",
|
||||
"publicLinkNote": "This agent has a public link, so anyone with the link can get answers from them.",
|
||||
"addedBy": "Added by {{person}}: {{names}}",
|
||||
"unavailable": "Not running: {{names}}. {{person}} added them and can no longer share them through this agent. Remove them or choose ones the owner can use.",
|
||||
"unknownItem": "Unnamed item",
|
||||
"notAllowed": "You can't add {{names}} to this agent. The owner can't use them, and you can only share what you own or can edit.",
|
||||
"stoppedCannotEditAgent": "Not running: {{names}}. {{person}} added them and can no longer edit this agent. Remove them or choose ones the owner can use.",
|
||||
"stoppedCannotEditItem": "Not running: {{names}}. {{person}} added them but can no longer edit them, and only people who own or can edit an item can share it through an agent. Remove them or choose ones the owner can use.",
|
||||
"takeOver": "Run {{name}} with my access",
|
||||
"takeOverPending": "{{name}} will run with your access when you save.",
|
||||
"undoTakeOver": "Undo",
|
||||
"confirmationOutdated": "This agent changed since you confirmed what runs with your access. Check the notices and save again."
|
||||
},
|
||||
"resourceStates": {
|
||||
"title": "Some tools, sources or prompts aren't running",
|
||||
"reason": {
|
||||
"deleted": "{{name}} was deleted.",
|
||||
"ownerLostAccessYou": "You can no longer use {{name}}.",
|
||||
"ownerLostAccess": "The agent's owner can no longer use {{name}}.",
|
||||
"sponsorCannotEditAgent": "{{name}} ran with {{person}}'s access, and they can no longer edit this agent.",
|
||||
"sponsorCannotEditItem": "{{name}} ran with {{person}}'s access, and they can no longer edit it.",
|
||||
"connectionNeedsReconnect": "{{name}} needs its {{service}} account signed in again.",
|
||||
"connectionRemoved": "The {{service}} account {{name}} used was removed.",
|
||||
"connectorDisabled": "An admin turned off {{service}}, so {{name}} can't run.",
|
||||
"unknown": "{{name}} isn't running."
|
||||
},
|
||||
"ask": {
|
||||
"shareAgain": "Ask {{person}} to share it again.",
|
||||
"signInAgain": "Ask {{person}} to sign in again.",
|
||||
"connectAgain": "Ask {{person}} to connect the account again.",
|
||||
"admin": "Ask an admin to turn it back on."
|
||||
},
|
||||
"promptFallback": "The agent uses the default prompt until then.",
|
||||
"serviceFallback": "the service",
|
||||
"remove": "Remove",
|
||||
"removeLabel": "Remove {{name}}",
|
||||
"reconnectLabel": "Reconnect {{name}}"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
"title": "Run with your access?",
|
||||
"description": "The agent's owner can't use these. If you add them, they run with your access for everyone who uses this agent.",
|
||||
|
||||
@@ -2368,16 +2368,38 @@
|
||||
"attachNote": "Las herramientas, fuentes y prompts que añadas y que el propietario no pueda usar se ejecutan con tu acceso para todos los que usen este agente. Debes ser su propietario o poder editarlos, y se te pedirá que lo confirmes.",
|
||||
"publicLinkNote": "Este agente tiene un enlace público, así que cualquiera con el enlace puede obtener respuestas de ellos.",
|
||||
"addedBy": "Añadido por {{person}}: {{names}}",
|
||||
"unavailable": "Sin ejecutarse: {{names}}. {{person}} los añadió y ya no puede compartirlos a través de este agente. Quítalos o elige otros que el propietario pueda usar.",
|
||||
"unknownItem": "Elemento sin nombre",
|
||||
"notAllowed": "No puedes añadir {{names}} a este agente. El propietario no puede usarlos y solo puedes compartir lo que es tuyo o puedes editar.",
|
||||
"stoppedCannotEditAgent": "Sin ejecutarse: {{names}}. {{person}} los añadió y ya no puede editar este agente. Quítalos o elige otros que el propietario pueda usar.",
|
||||
"stoppedCannotEditItem": "Sin ejecutarse: {{names}}. {{person}} los añadió pero ya no puede editarlos, y solo quien es propietario de un elemento o puede editarlo puede compartirlo a través de un agente. Quítalos o elige otros que el propietario pueda usar.",
|
||||
"takeOver": "Ejecutar {{name}} con mi acceso",
|
||||
"takeOverPending": "{{name}} se ejecutará con tu acceso cuando guardes.",
|
||||
"undoTakeOver": "Deshacer",
|
||||
"confirmationOutdated": "Este agente cambió desde que confirmaste lo que se ejecuta con tu acceso. Revisa los avisos y vuelve a guardar."
|
||||
},
|
||||
"resourceStates": {
|
||||
"title": "Algunas herramientas, fuentes o prompts no se están ejecutando",
|
||||
"reason": {
|
||||
"deleted": "{{name}} se eliminó.",
|
||||
"ownerLostAccessYou": "Ya no puedes usar {{name}}.",
|
||||
"ownerLostAccess": "El propietario del agente ya no puede usar {{name}}.",
|
||||
"sponsorCannotEditAgent": "{{name}} se ejecutaba con el acceso de {{person}}, que ya no puede editar este agente.",
|
||||
"sponsorCannotEditItem": "{{name}} se ejecutaba con el acceso de {{person}}, que ya no puede editarlo.",
|
||||
"connectionNeedsReconnect": "{{name}} necesita que su cuenta de {{service}} vuelva a iniciar sesión.",
|
||||
"connectionRemoved": "Se eliminó la cuenta de {{service}} que usaba {{name}}.",
|
||||
"connectorDisabled": "Un administrador desactivó {{service}}, así que {{name}} no puede ejecutarse.",
|
||||
"unknown": "{{name}} no se está ejecutando."
|
||||
},
|
||||
"ask": {
|
||||
"shareAgain": "Pide a {{person}} que lo vuelva a compartir.",
|
||||
"signInAgain": "Pide a {{person}} que vuelva a iniciar sesión.",
|
||||
"connectAgain": "Pide a {{person}} que vuelva a conectar la cuenta.",
|
||||
"admin": "Pide a un administrador que lo vuelva a activar."
|
||||
},
|
||||
"promptFallback": "Mientras tanto, el agente usa el prompt predeterminado.",
|
||||
"serviceFallback": "el servicio",
|
||||
"remove": "Quitar",
|
||||
"removeLabel": "Quitar {{name}}",
|
||||
"reconnectLabel": "Reconectar {{name}}"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
"title": "¿Ejecutar con tu acceso?",
|
||||
"description": "El propietario del agente no puede usarlos. Si los añades, se ejecutan con tu acceso para todos los que usen este agente.",
|
||||
|
||||
@@ -2353,16 +2353,38 @@
|
||||
"attachNote": "オーナーが使用できないツール、ソース、プロンプトを追加すると、このエージェントを使うすべての人に対してあなたのアクセス権で実行されます。自分が所有しているか編集できるものに限られ、追加時に確認を求められます。",
|
||||
"publicLinkNote": "このエージェントには公開リンクがあるため、リンクを知っている人は誰でもそれらから回答を得られます。",
|
||||
"addedBy": "{{person}} が追加: {{names}}",
|
||||
"unavailable": "実行されていません: {{names}}。追加した {{person}} は、このエージェントを通じてそれらを共有できなくなりました。削除するか、オーナーが使用できるものを選んでください。",
|
||||
"unknownItem": "名前のない項目",
|
||||
"notAllowed": "{{names}} をこのエージェントに追加できません。オーナーはそれらを使用できず、共有できるのは自分が所有しているか編集できるものだけです。",
|
||||
"stoppedCannotEditAgent": "実行されていません: {{names}}。追加した {{person}} は、このエージェントを編集できなくなりました。削除するか、オーナーが使用できるものを選んでください。",
|
||||
"stoppedCannotEditItem": "実行されていません: {{names}}。{{person}} が追加しましたが、それらを編集できなくなりました。エージェントを通じて共有できるのは、項目を所有しているか編集できる人だけです。削除するか、オーナーが使用できるものを選んでください。",
|
||||
"takeOver": "{{name}} を自分のアクセス権で実行",
|
||||
"takeOverPending": "保存すると、{{name}} はあなたのアクセス権で実行されます。",
|
||||
"undoTakeOver": "元に戻す",
|
||||
"confirmationOutdated": "あなたのアクセス権で実行するものを確認した後に、このエージェントが変更されました。通知を確認して、もう一度保存してください。"
|
||||
},
|
||||
"resourceStates": {
|
||||
"title": "一部のツール、ソース、プロンプトが実行されていません",
|
||||
"reason": {
|
||||
"deleted": "{{name}} は削除されました。",
|
||||
"ownerLostAccessYou": "{{name}} を使用できなくなりました。",
|
||||
"ownerLostAccess": "エージェントの所有者は {{name}} を使用できなくなりました。",
|
||||
"sponsorCannotEditAgent": "{{name}} は {{person}} のアクセス権で実行されていましたが、この人はこのエージェントを編集できなくなりました。",
|
||||
"sponsorCannotEditItem": "{{name}} は {{person}} のアクセス権で実行されていましたが、この人はそれを編集できなくなりました。",
|
||||
"connectionNeedsReconnect": "{{name}} の {{service}} アカウントに再度サインインする必要があります。",
|
||||
"connectionRemoved": "{{name}} が使っていた {{service}} アカウントは削除されました。",
|
||||
"connectorDisabled": "管理者が {{service}} をオフにしたため、{{name}} は実行できません。",
|
||||
"unknown": "{{name}} は実行されていません。"
|
||||
},
|
||||
"ask": {
|
||||
"shareAgain": "{{person}} に再度共有するよう依頼してください。",
|
||||
"signInAgain": "{{person}} に再度サインインするよう依頼してください。",
|
||||
"connectAgain": "{{person}} にアカウントを再接続するよう依頼してください。",
|
||||
"admin": "管理者にオンに戻すよう依頼してください。"
|
||||
},
|
||||
"promptFallback": "それまでエージェントはデフォルトのプロンプトを使用します。",
|
||||
"serviceFallback": "サービス",
|
||||
"remove": "削除",
|
||||
"removeLabel": "{{name}} を削除",
|
||||
"reconnectLabel": "{{name}} を再接続"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
"title": "あなたのアクセス権で実行しますか?",
|
||||
"description": "エージェントのオーナーはこれらを使用できません。追加すると、このエージェントを使うすべての人に対してあなたのアクセス権で実行されます。",
|
||||
|
||||
@@ -2460,16 +2460,38 @@
|
||||
"attachNote": "Инструменты, источники и промпты, которые вы добавляете и которые владелец не может использовать, работают с вашим доступом для всех, кто пользуется этим агентом. Вы должны быть их владельцем или иметь право их редактировать, и вас попросят это подтвердить.",
|
||||
"publicLinkNote": "У этого агента есть публичная ссылка, поэтому любой, у кого она есть, может получать ответы на их основе.",
|
||||
"addedBy": "Добавил(а) {{person}}: {{names}}",
|
||||
"unavailable": "Не работают: {{names}}. {{person}} добавил(а) их и больше не может делиться ими через этого агента. Удалите их или выберите те, которые может использовать владелец.",
|
||||
"unknownItem": "Элемент без названия",
|
||||
"notAllowed": "Вы не можете добавить {{names}} к этому агенту. Владелец не может их использовать, а делиться можно только тем, чем вы владеете или что можете редактировать.",
|
||||
"stoppedCannotEditAgent": "Не работают: {{names}}. {{person}} добавил(а) их и больше не может редактировать этого агента. Удалите их или выберите те, которые может использовать владелец.",
|
||||
"stoppedCannotEditItem": "Не работают: {{names}}. {{person}} добавил(а) их, но больше не может их редактировать, а делиться элементом через агента может только его владелец или тот, кто может его редактировать. Удалите их или выберите те, которые может использовать владелец.",
|
||||
"takeOver": "Запускать {{name}} с моим доступом",
|
||||
"takeOverPending": "{{name}} будет работать с вашим доступом после сохранения.",
|
||||
"undoTakeOver": "Отменить",
|
||||
"confirmationOutdated": "Агент изменился после того, как вы подтвердили, что работает с вашим доступом. Проверьте уведомления и сохраните снова."
|
||||
},
|
||||
"resourceStates": {
|
||||
"title": "Некоторые инструменты, источники или промпты не работают",
|
||||
"reason": {
|
||||
"deleted": "{{name}} удалён.",
|
||||
"ownerLostAccessYou": "Вы больше не можете использовать {{name}}.",
|
||||
"ownerLostAccess": "Владелец агента больше не может использовать {{name}}.",
|
||||
"sponsorCannotEditAgent": "{{name}} работал с доступом {{person}}, а этот человек больше не может редактировать агента.",
|
||||
"sponsorCannotEditItem": "{{name}} работал с доступом {{person}}, а этот человек больше не может его редактировать.",
|
||||
"connectionNeedsReconnect": "Для {{name}} нужно снова войти в аккаунт {{service}}.",
|
||||
"connectionRemoved": "Аккаунт {{service}}, который использовал {{name}}, удалён.",
|
||||
"connectorDisabled": "Администратор отключил {{service}}, поэтому {{name}} не может работать.",
|
||||
"unknown": "{{name}} не работает."
|
||||
},
|
||||
"ask": {
|
||||
"shareAgain": "Попросите {{person}} снова поделиться им.",
|
||||
"signInAgain": "Попросите {{person}} снова войти в аккаунт.",
|
||||
"connectAgain": "Попросите {{person}} снова подключить аккаунт.",
|
||||
"admin": "Попросите администратора снова включить его."
|
||||
},
|
||||
"promptFallback": "До тех пор агент использует промпт по умолчанию.",
|
||||
"serviceFallback": "сервис",
|
||||
"remove": "Убрать",
|
||||
"removeLabel": "Убрать {{name}}",
|
||||
"reconnectLabel": "Переподключить {{name}}"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
"title": "Запускать с вашим доступом?",
|
||||
"description": "Владелец агента не может их использовать. Если вы их добавите, они будут работать с вашим доступом для всех, кто пользуется этим агентом.",
|
||||
|
||||
@@ -2353,16 +2353,38 @@
|
||||
"attachNote": "你新增的、擁有者無法使用的工具、來源和提示詞,會以你的存取權限為所有使用此智慧代理的人執行。你必須擁有它們或能夠編輯它們,並且需要確認。",
|
||||
"publicLinkNote": "此智慧代理有公開連結,任何擁有該連結的人都可以從中取得回答。",
|
||||
"addedBy": "由 {{person}} 新增:{{names}}",
|
||||
"unavailable": "未執行:{{names}}。新增它們的 {{person}} 已無法再透過此智慧代理分享它們。請移除它們,或選擇擁有者可以使用的項目。",
|
||||
"unknownItem": "未命名項目",
|
||||
"notAllowed": "無法將 {{names}} 新增到此智慧代理。擁有者無法使用它們,而你只能分享自己擁有或可以編輯的項目。",
|
||||
"stoppedCannotEditAgent": "未執行:{{names}}。新增它們的 {{person}} 已無法再編輯此智慧代理。請移除它們,或選擇擁有者可以使用的項目。",
|
||||
"stoppedCannotEditItem": "未執行:{{names}}。{{person}} 新增了它們,但已無法再編輯它們,而只有擁有或可以編輯某個項目的人才能透過智慧代理分享它。請移除它們,或選擇擁有者可以使用的項目。",
|
||||
"takeOver": "以我的存取權限執行 {{name}}",
|
||||
"takeOverPending": "儲存後,{{name}} 將以你的存取權限執行。",
|
||||
"undoTakeOver": "復原",
|
||||
"confirmationOutdated": "在你確認以你的存取權限執行的內容之後,此智慧代理已變更。請查看提示並重新儲存。"
|
||||
},
|
||||
"resourceStates": {
|
||||
"title": "部分工具、來源或提示詞未在執行",
|
||||
"reason": {
|
||||
"deleted": "{{name}} 已被刪除。",
|
||||
"ownerLostAccessYou": "你已無法使用 {{name}}。",
|
||||
"ownerLostAccess": "代理的擁有者已無法使用 {{name}}。",
|
||||
"sponsorCannotEditAgent": "{{name}} 先前以 {{person}} 的存取權限執行,而對方已無法編輯此代理。",
|
||||
"sponsorCannotEditItem": "{{name}} 先前以 {{person}} 的存取權限執行,而對方已無法編輯它。",
|
||||
"connectionNeedsReconnect": "{{name}} 的 {{service}} 帳戶需要重新登入。",
|
||||
"connectionRemoved": "{{name}} 使用的 {{service}} 帳戶已被移除。",
|
||||
"connectorDisabled": "管理員已關閉 {{service}},因此 {{name}} 無法執行。",
|
||||
"unknown": "{{name}} 未在執行。"
|
||||
},
|
||||
"ask": {
|
||||
"shareAgain": "請 {{person}} 重新分享。",
|
||||
"signInAgain": "請 {{person}} 重新登入。",
|
||||
"connectAgain": "請 {{person}} 重新連接該帳戶。",
|
||||
"admin": "請管理員重新開啟。"
|
||||
},
|
||||
"promptFallback": "在此之前,代理會使用預設提示詞。",
|
||||
"serviceFallback": "該服務",
|
||||
"remove": "移除",
|
||||
"removeLabel": "移除 {{name}}",
|
||||
"reconnectLabel": "重新連接 {{name}}"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
"title": "以你的存取權限執行?",
|
||||
"description": "智慧代理的擁有者無法使用這些項目。如果新增它們,它們會以你的存取權限為所有使用此智慧代理的人執行。",
|
||||
|
||||
@@ -2353,16 +2353,38 @@
|
||||
"attachNote": "你添加的、所有者无法使用的工具、来源和提示词,会以你的访问权限为所有使用此智能体的人运行。你必须拥有它们或能够编辑它们,并且需要确认。",
|
||||
"publicLinkNote": "此智能体有公开链接,任何拥有该链接的人都可以从中获得回答。",
|
||||
"addedBy": "由 {{person}} 添加:{{names}}",
|
||||
"unavailable": "未运行:{{names}}。添加它们的 {{person}} 已无法再通过此智能体共享它们。请移除它们,或选择所有者可以使用的项目。",
|
||||
"unknownItem": "未命名项目",
|
||||
"notAllowed": "无法将 {{names}} 添加到此智能体。所有者无法使用它们,而你只能共享自己拥有或可以编辑的项目。",
|
||||
"stoppedCannotEditAgent": "未运行:{{names}}。添加它们的 {{person}} 已无法再编辑此智能体。请移除它们,或选择所有者可以使用的项目。",
|
||||
"stoppedCannotEditItem": "未运行:{{names}}。{{person}} 添加了它们,但已无法再编辑它们,而只有拥有或可以编辑某个项目的人才能通过智能体共享它。请移除它们,或选择所有者可以使用的项目。",
|
||||
"takeOver": "以我的访问权限运行 {{name}}",
|
||||
"takeOverPending": "保存后,{{name}} 将以你的访问权限运行。",
|
||||
"undoTakeOver": "撤销",
|
||||
"confirmationOutdated": "在你确认以你的访问权限运行的内容之后,此智能体已更改。请查看提示并重新保存。"
|
||||
},
|
||||
"resourceStates": {
|
||||
"title": "部分工具、来源或提示词未在运行",
|
||||
"reason": {
|
||||
"deleted": "{{name}} 已被删除。",
|
||||
"ownerLostAccessYou": "你已无法使用 {{name}}。",
|
||||
"ownerLostAccess": "智能体的所有者已无法使用 {{name}}。",
|
||||
"sponsorCannotEditAgent": "{{name}} 此前以 {{person}} 的访问权限运行,而其已无法编辑此智能体。",
|
||||
"sponsorCannotEditItem": "{{name}} 此前以 {{person}} 的访问权限运行,而其已无法编辑它。",
|
||||
"connectionNeedsReconnect": "{{name}} 的 {{service}} 账户需要重新登录。",
|
||||
"connectionRemoved": "{{name}} 使用的 {{service}} 账户已被移除。",
|
||||
"connectorDisabled": "管理员已关闭 {{service}},因此 {{name}} 无法运行。",
|
||||
"unknown": "{{name}} 未在运行。"
|
||||
},
|
||||
"ask": {
|
||||
"shareAgain": "请让 {{person}} 重新共享。",
|
||||
"signInAgain": "请让 {{person}} 重新登录。",
|
||||
"connectAgain": "请让 {{person}} 重新连接该账户。",
|
||||
"admin": "请让管理员重新开启。"
|
||||
},
|
||||
"promptFallback": "在此之前,智能体使用默认提示词。",
|
||||
"serviceFallback": "该服务",
|
||||
"remove": "移除",
|
||||
"removeLabel": "移除 {{name}}",
|
||||
"reconnectLabel": "重新连接 {{name}}"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
"title": "以你的访问权限运行?",
|
||||
"description": "智能体的所有者无法使用这些项目。如果添加它们,它们会以你的访问权限为所有使用此智能体的人运行。",
|
||||
|
||||
Reference in new issue
Block a user