mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-03 13:11:50 +00:00
Let editors take over stopped resources and remove the owner's workflow tools
The sponsored-resources notice now says why each item stopped (the person can no longer edit the agent, or can no longer edit the item) and offers "Run it with my access" for items the reader may sponsor; the next save sends that confirmation. Saves go through one confirmation round trip shared by the agent form and the workflow builder, so the details sheet closes on a confirmed save and stays open without an error when the editor declines. An outdated confirmation shows a translated message and reloads the sponsor details. The tool picker forgets a removed tool's row once the removal is saved, and workflow node pickers keep a remove-only option for the owner's private tools and sources.
This commit is contained in:
1 parent
4d001f6a20
commit
ff3f367f94
18 files changed
+958
-166
No files matched your search
@@ -104,15 +104,22 @@ vi.mock('../components/MultiSelectPopover', () => ({
|
||||
MultiSelectPopover: ({
|
||||
trigger,
|
||||
items,
|
||||
onToggle,
|
||||
}: {
|
||||
trigger: React.ReactNode;
|
||||
items: MultiSelectPopoverItem[];
|
||||
onToggle: (id: string) => void;
|
||||
}) => (
|
||||
<div data-testid="picker">
|
||||
{trigger}
|
||||
{items.map((item) => (
|
||||
<div key={item.id} data-group={item.group}>
|
||||
<div key={item.id} data-group={item.group} data-item={item.id}>
|
||||
{item.descriptionNode}
|
||||
<button
|
||||
type="button"
|
||||
data-toggle={item.id}
|
||||
onClick={() => onToggle(item.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -484,6 +491,86 @@ describe('NewAgent form', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
const rename = async (value: string) => {
|
||||
const name = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="agents.form.placeholders.agentName"]',
|
||||
)!;
|
||||
await act(async () => setNativeValue(name, value));
|
||||
};
|
||||
|
||||
it('drops the remove-only row once the removal is saved', async () => {
|
||||
mocks.tools = [{ id: 'mine', name: 'api_tool', display_name: 'My API' }];
|
||||
await renderEdit({
|
||||
tools: ['owners'],
|
||||
tool_details: [
|
||||
{ id: 'owners', name: 'jira', display_name: 'Owner Jira' },
|
||||
],
|
||||
});
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-toggle="owners"]')!
|
||||
.click(),
|
||||
);
|
||||
// Still listed until saved, so it can be put back.
|
||||
expect(container.querySelector('[data-item="owners"]')).not.toBeNull();
|
||||
await act(async () => buttonByText('agents.form.buttons.save').click());
|
||||
expect(mocks.updateAgent).toHaveBeenCalledTimes(1);
|
||||
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 save = buttonByText('agents.form.buttons.save');
|
||||
expect(save.disabled).toBe(true);
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.sponsors.takeOver').click(),
|
||||
);
|
||||
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.
|
||||
expect(mocks.getAgent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('explains an outdated confirmation and reloads the sponsors', async () => {
|
||||
mocks.updateAgent.mockImplementationOnce(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
code: 'sponsor_confirmation_unexpected',
|
||||
message: 'raw english',
|
||||
unexpected: ['tool:t1'],
|
||||
}),
|
||||
{ status: 400 },
|
||||
),
|
||||
),
|
||||
);
|
||||
await renderEdit({});
|
||||
await rename('Renamed');
|
||||
await act(async () => buttonByText('agents.form.buttons.save').click());
|
||||
expect(container.textContent).toContain(
|
||||
'agents.form.sponsors.confirmationOutdated',
|
||||
);
|
||||
expect(container.textContent).not.toContain('raw english');
|
||||
expect(mocks.getAgent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('labels every picker with a floating label', async () => {
|
||||
await render();
|
||||
const labels = Array.from(
|
||||
|
||||
@@ -99,14 +99,14 @@ import { resetPreview, selectPreviewStatus } from './agentPreviewSlice';
|
||||
import AgentPageToolbar, { LastUsedMeta } from './components/AgentPageToolbar';
|
||||
import AgentPreviewSheet from './components/AgentPreviewSheet';
|
||||
import SectionShell from '../navigation/SectionShell';
|
||||
import SponsorConfirmModal from './components/SponsorConfirmModal';
|
||||
import SponsoredResourcesNotice from './components/SponsoredResourcesNotice';
|
||||
import {
|
||||
readSponsorRefusal,
|
||||
type SponsorConfirmation,
|
||||
saveWithSponsorConsent,
|
||||
sponsorNotAllowedMessage,
|
||||
withAttachedToolRows,
|
||||
} from './sponsorConsent';
|
||||
import { useSponsorPrompt } from './useSponsorPrompt';
|
||||
import { Agent, ResourceSponsor, ToolSummary } from './types';
|
||||
import WorkflowBuilder from './workflow/WorkflowBuilder';
|
||||
|
||||
@@ -201,12 +201,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
// Tools on the agent when it loaded: the owner's private ones don't come
|
||||
// back in the caller's own tool list, so the picker adds a row for each.
|
||||
const [attachedTools, setAttachedTools] = useState<ToolSummary[]>([]);
|
||||
// A save the server refused until the caller agrees that what they added
|
||||
// runs with their access; ``retry`` repeats it with ``confirm_sponsor``.
|
||||
const [sponsorRequest, setSponsorRequest] = useState<{
|
||||
confirmation: SponsorConfirmation;
|
||||
retry: (keys: string[]) => void;
|
||||
} | null>(null);
|
||||
// Asks before a save makes what the caller added run with their access.
|
||||
const sponsorPrompt = useSponsorPrompt();
|
||||
// Keys of stopped items the caller chose to keep running with their
|
||||
// access; sent as ``confirm_sponsor`` with the next save.
|
||||
const [takeovers, setTakeovers] = useState<string[]>([]);
|
||||
const [selectedModelIds, setSelectedModelIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
@@ -449,35 +448,80 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The message for a refused save, or null when the refusal only asks the
|
||||
* caller to confirm sponsoring what they added (the dialog then opens and
|
||||
* ``retry`` repeats the save with their confirmation).
|
||||
*/
|
||||
// Fresh sponsor details after a save, without touching unsaved fields.
|
||||
const refreshSponsors = useCallback(
|
||||
async (id?: string) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
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 }));
|
||||
if (initialAgentRef.current)
|
||||
initialAgentRef.current = {
|
||||
...initialAgentRef.current,
|
||||
resource_sponsors: sponsors,
|
||||
};
|
||||
} catch {
|
||||
// The notice keeps what it showed.
|
||||
}
|
||||
},
|
||||
[token],
|
||||
);
|
||||
|
||||
/** The message for a refused save. */
|
||||
const saveFailureMessage = async (
|
||||
response: Response,
|
||||
fallback: string,
|
||||
retry: (keys: string[]) => void,
|
||||
): Promise<string | null> => {
|
||||
): Promise<string> => {
|
||||
const refusal = await readSponsorRefusal(response);
|
||||
if (refusal?.kind === 'confirm') {
|
||||
setSponsorRequest({ confirmation: refusal.confirmation, retry });
|
||||
return null;
|
||||
}
|
||||
if (refusal?.kind === 'notAllowed')
|
||||
return sponsorNotAllowedMessage(t, i18n.language, refusal.resources);
|
||||
if (refusal?.kind === 'unexpected') {
|
||||
// Someone changed the agent since the caller chose; show them now.
|
||||
setTakeovers([]);
|
||||
void refreshSponsors(agent.id);
|
||||
return t('agents.form.sponsors.confirmationOutdated');
|
||||
}
|
||||
return extractApiError(response, fallback);
|
||||
};
|
||||
|
||||
const appendSponsorConfirmation = (
|
||||
formData: FormData,
|
||||
confirmSponsor?: string[],
|
||||
) => {
|
||||
if (confirmSponsor && confirmSponsor.length > 0)
|
||||
formData.append('confirm_sponsor', JSON.stringify(confirmSponsor));
|
||||
/**
|
||||
* Create or update the agent, asking whenever the server wants the
|
||||
* caller's confirmation to run what they added with their access.
|
||||
* Resolves to null when they decline.
|
||||
*/
|
||||
const sendAgent = (formData: FormData) =>
|
||||
saveWithSponsorConsent(
|
||||
(confirm) => {
|
||||
if (confirm.length > 0)
|
||||
formData.set('confirm_sponsor', JSON.stringify(confirm));
|
||||
else formData.delete('confirm_sponsor');
|
||||
return effectiveMode === 'new'
|
||||
? userService.createAgent(formData, token)
|
||||
: userService.updateAgent(agent.id || '', formData, token);
|
||||
},
|
||||
sponsorPrompt.ask,
|
||||
takeovers,
|
||||
);
|
||||
|
||||
/** Bookkeeping after an update the server accepted. */
|
||||
const afterSaved = (id?: string) => {
|
||||
// What is on the agent now is what the tool picker keeps rows for.
|
||||
setAttachedTools(selectedTools);
|
||||
if (effectiveMode !== 'new') {
|
||||
setTakeovers([]);
|
||||
void refreshSponsors(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDraft = async (confirmSponsor?: string[]) => {
|
||||
const toggleTakeover = (key: string) =>
|
||||
setTakeovers((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('name', agent.name);
|
||||
formData.append('description', agent.description);
|
||||
@@ -535,25 +579,23 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
if (effectiveMode === 'new' && validatedFolderId) {
|
||||
formData.append('folder_id', validatedFolderId);
|
||||
}
|
||||
appendSponsorConfirmation(formData, confirmSponsor);
|
||||
|
||||
try {
|
||||
setDraftLoading(true);
|
||||
setSubmitError(null);
|
||||
const response =
|
||||
effectiveMode === 'new'
|
||||
? await userService.createAgent(formData, token)
|
||||
: await userService.updateAgent(agent.id || '', formData, token);
|
||||
const response = await sendAgent(formData);
|
||||
if (!response) return;
|
||||
if (!response.ok) {
|
||||
const message = await saveFailureMessage(
|
||||
response,
|
||||
t('agents.form.errors.saveDraftFailed'),
|
||||
(keys) => handleSaveDraft(keys),
|
||||
setSubmitError(
|
||||
await saveFailureMessage(
|
||||
response,
|
||||
t('agents.form.errors.saveDraftFailed'),
|
||||
),
|
||||
);
|
||||
if (message) setSubmitError(message);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
afterSaved(data.id || agent.id);
|
||||
|
||||
const updatedAgent = {
|
||||
...agent,
|
||||
@@ -571,7 +613,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async (confirmSponsor?: string[]) => {
|
||||
const handlePublish = async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('name', agent.name);
|
||||
formData.append('description', agent.description);
|
||||
@@ -629,22 +671,19 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
if (effectiveMode === 'new' && validatedFolderId) {
|
||||
formData.append('folder_id', validatedFolderId);
|
||||
}
|
||||
appendSponsorConfirmation(formData, confirmSponsor);
|
||||
|
||||
try {
|
||||
setPublishLoading(true);
|
||||
setSubmitError(null);
|
||||
const response =
|
||||
effectiveMode === 'new'
|
||||
? await userService.createAgent(formData, token)
|
||||
: await userService.updateAgent(agent.id || '', formData, token);
|
||||
const response = await sendAgent(formData);
|
||||
if (!response) return;
|
||||
if (!response.ok) {
|
||||
const message = await saveFailureMessage(
|
||||
response,
|
||||
t('agents.form.errors.publishFailed'),
|
||||
(keys) => handlePublish(keys),
|
||||
setSubmitError(
|
||||
await saveFailureMessage(
|
||||
response,
|
||||
t('agents.form.errors.publishFailed'),
|
||||
),
|
||||
);
|
||||
if (message) setSubmitError(message);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
@@ -658,6 +697,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
};
|
||||
setAgent(updatedAgent);
|
||||
initialAgentRef.current = updatedAgent;
|
||||
afterSaved(updatedAgent.id);
|
||||
// The saved agent is what the preview talks to; start its chat over.
|
||||
dispatch(resetPreview());
|
||||
|
||||
@@ -995,9 +1035,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
const isChanged =
|
||||
!isEqual(agent, initialAgentRef.current) ||
|
||||
imageFile !== null ||
|
||||
jsonSchemaText !== initialJsonSchemaText;
|
||||
jsonSchemaText !== initialJsonSchemaText ||
|
||||
takeovers.length > 0;
|
||||
setHasChanges(isChanged);
|
||||
}, [agent, dispatch, effectiveMode, imageFile, jsonSchemaText]);
|
||||
}, [agent, dispatch, effectiveMode, imageFile, jsonSchemaText, takeovers]);
|
||||
|
||||
const isPublished = agent.status === 'published';
|
||||
// What the caller's role allows on this agent (`allowed_actions` from the
|
||||
@@ -1067,7 +1108,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
shape="pill"
|
||||
disabled={isDraftBlocked()}
|
||||
loading={draftLoading}
|
||||
onClick={() => handleSaveDraft()}
|
||||
onClick={handleSaveDraft}
|
||||
>
|
||||
{t('agents.form.buttons.saveDraft')}
|
||||
</Button>
|
||||
@@ -1091,7 +1132,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
shape="pill"
|
||||
disabled={!isPublishable() || !hasChanges}
|
||||
loading={publishLoading}
|
||||
onClick={() => handlePublish()}
|
||||
onClick={handlePublish}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
{modeConfig[effectiveMode].buttonText}
|
||||
@@ -1359,6 +1400,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<SponsoredResourcesNotice
|
||||
agent={agent}
|
||||
resolveName={resolveSponsoredName}
|
||||
takeovers={takeovers}
|
||||
onToggleTakeover={toggleTakeover}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -1700,15 +1743,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<SponsorConfirmModal
|
||||
confirmation={sponsorRequest?.confirmation ?? null}
|
||||
onCancel={() => setSponsorRequest(null)}
|
||||
onConfirm={(keys) => {
|
||||
const retry = sponsorRequest?.retry;
|
||||
setSponsorRequest(null);
|
||||
retry?.(keys);
|
||||
}}
|
||||
/>
|
||||
{sponsorPrompt.modal}
|
||||
<ConfirmationModal
|
||||
message={t('agents.deleteConfirmation')}
|
||||
modalState={deleteConfirmation}
|
||||
@@ -1825,7 +1860,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
shape="pill"
|
||||
disabled={!isPublishable()}
|
||||
loading={publishLoading}
|
||||
onClick={() => handlePublish()}
|
||||
onClick={handlePublish}
|
||||
>
|
||||
{t('agents.form.buttons.publish')}
|
||||
</Button>
|
||||
|
||||
@@ -46,17 +46,26 @@ describe('SponsoredResourcesNotice', () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (agent: Agent) => {
|
||||
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"]'));
|
||||
|
||||
@@ -133,4 +142,79 @@ describe('SponsoredResourcesNotice', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -11,35 +12,62 @@ 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;
|
||||
};
|
||||
|
||||
/** Sponsored items grouped by the person who added them, in first-seen order. */
|
||||
function groupByPerson(sponsors: ResourceSponsor[]) {
|
||||
/** 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;
|
||||
const key = `${sponsor.label || sponsor.user_id}\u0000${extra(sponsor)}`;
|
||||
groups.set(key, [...(groups.get(key) ?? []), sponsor]);
|
||||
}
|
||||
return Array.from(groups.entries());
|
||||
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 view the config sees who
|
||||
* added what, and which items no longer run because that person lost access.
|
||||
* 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 inactive = 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;
|
||||
|
||||
@@ -48,6 +76,8 @@ export default function SponsoredResourcesNotice({
|
||||
});
|
||||
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">
|
||||
@@ -64,11 +94,11 @@ export default function SponsoredResourcesNotice({
|
||||
<Alert role="note">
|
||||
<UserRound />
|
||||
<AlertDescription>
|
||||
{active.map(([person, items]) => (
|
||||
<p key={person}>
|
||||
{active.map((items) => (
|
||||
<p key={sponsorKey(items[0])}>
|
||||
{t('agents.form.sponsors.addedBy', {
|
||||
interpolation: { escapeValue: false },
|
||||
person,
|
||||
person: person(items),
|
||||
names: names(items),
|
||||
})}
|
||||
</p>
|
||||
@@ -80,15 +110,57 @@ export default function SponsoredResourcesNotice({
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertDescription>
|
||||
{inactive.map(([person, items]) => (
|
||||
<p key={person}>
|
||||
{t('agents.form.sponsors.unavailable', {
|
||||
interpolation: { escapeValue: false },
|
||||
person,
|
||||
names: names(items),
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { MultiSelectPopoverItem } from '../components/MultiSelectPopover';
|
||||
import { readSponsorRefusal, withAttachedToolRows } from './sponsorConsent';
|
||||
import {
|
||||
readSponsorRefusal,
|
||||
saveWithSponsorConsent,
|
||||
withAttachedOptions,
|
||||
withAttachedToolRows,
|
||||
} from './sponsorConsent';
|
||||
|
||||
const json = (status: number, body: unknown) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
@@ -83,3 +88,94 @@ describe('withAttachedToolRows', () => {
|
||||
expect(items.map((item) => item.id)).toEqual(['mine', 'owners']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readSponsorRefusal: stale confirmations', () => {
|
||||
it('reads a confirmation that names something the save no longer needs', async () => {
|
||||
const response = json(400, {
|
||||
code: 'sponsor_confirmation_unexpected',
|
||||
unexpected: ['tool:gone'],
|
||||
});
|
||||
expect(await readSponsorRefusal(response)).toEqual({ kind: 'unexpected' });
|
||||
});
|
||||
|
||||
it('ignores an ordinary 400', async () => {
|
||||
expect(await readSponsorRefusal(json(400, { message: 'bad' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveWithSponsorConsent', () => {
|
||||
const confirmation = {
|
||||
resources: [
|
||||
{ key: 'tool:t1', type: 'tool' as const, id: 't1', name: 'Jira' },
|
||||
],
|
||||
audience: { teams: [], api_key: false, public_link: false, webhook: false },
|
||||
};
|
||||
const refused = () =>
|
||||
json(409, {
|
||||
code: 'sponsor_confirmation_required',
|
||||
...confirmation,
|
||||
});
|
||||
|
||||
it('sends once when nothing needs confirming', async () => {
|
||||
const send = vi.fn(async () => json(200, { success: true }));
|
||||
const ask = vi.fn();
|
||||
const response = await saveWithSponsorConsent(send, ask, ['tool:x']);
|
||||
expect(response?.status).toBe(200);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send).toHaveBeenCalledWith(['tool:x']);
|
||||
expect(ask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks, then resends with the agreed keys and any it already had', async () => {
|
||||
const send = vi
|
||||
.fn<(keys: string[]) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(refused())
|
||||
.mockResolvedValueOnce(json(200, { success: true }));
|
||||
const ask = vi.fn(async () => ['tool:t1']);
|
||||
const response = await saveWithSponsorConsent(send, ask, ['source:s1']);
|
||||
expect(ask).toHaveBeenCalledWith(confirmation);
|
||||
expect(send.mock.calls.map(([keys]) => keys)).toEqual([
|
||||
['source:s1'],
|
||||
['source:s1', 'tool:t1'],
|
||||
]);
|
||||
expect(response?.status).toBe(200);
|
||||
});
|
||||
|
||||
it('returns null when the caller declines, without resending', async () => {
|
||||
const send = vi.fn(async () => refused());
|
||||
const response = await saveWithSponsorConsent(send, async () => null);
|
||||
expect(response).toBeNull();
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// A workflow save answers in the workflow routes' shape; the round trip
|
||||
// is the same.
|
||||
it('passes other failures straight back', async () => {
|
||||
const failure = json(400, { success: false, error: 'Workflow invalid' });
|
||||
const send = vi.fn(async () => failure);
|
||||
expect(await saveWithSponsorConsent(send, vi.fn())).toBe(failure);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAttachedOptions', () => {
|
||||
it('adds a labelled option for a selected id the caller cannot list', () => {
|
||||
const options = withAttachedOptions(
|
||||
[{ value: 'mine', label: 'Mine' }],
|
||||
[
|
||||
{ id: 'owners', label: 'Owner tool' },
|
||||
{ id: 'unselected', label: 'Elsewhere' },
|
||||
],
|
||||
['mine', 'owners'],
|
||||
(name) => `${name} (added)`,
|
||||
);
|
||||
expect(options).toEqual([
|
||||
{ value: 'mine', label: 'Mine' },
|
||||
{ value: 'owners', label: 'Owner tool (added)' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the id when no name is known', () => {
|
||||
const options = withAttachedOptions([], [], ['x1'], (name) => name);
|
||||
expect(options).toEqual([{ value: 'x1', label: 'x1' }]);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,9 @@ export type SponsorConfirmation = {
|
||||
|
||||
export type SponsorRefusal =
|
||||
| { kind: 'confirm'; confirmation: SponsorConfirmation }
|
||||
| { kind: 'notAllowed'; resources: SponsorResource[] };
|
||||
| { kind: 'notAllowed'; resources: SponsorResource[] }
|
||||
/** The save confirmed something it no longer needs (the agent changed). */
|
||||
| { kind: 'unexpected' };
|
||||
|
||||
/**
|
||||
* The sponsor refusal a failed agent or workflow save carries, if any.
|
||||
@@ -41,13 +43,14 @@ export type SponsorRefusal =
|
||||
* A save that would run a resource the owner can't use with the caller's
|
||||
* access answers 409 `sponsor_confirmation_required` until it is retried
|
||||
* with `confirm_sponsor`; one the caller may not sponsor at all answers 403
|
||||
* `sponsor_not_allowed`. Reads a clone, so the caller can still read the
|
||||
* body for any other error.
|
||||
* `sponsor_not_allowed`; one whose confirmation names something it no
|
||||
* longer needs answers 400 `sponsor_confirmation_unexpected`. Reads a
|
||||
* clone, so the caller can still read the body for any other error.
|
||||
*/
|
||||
export async function readSponsorRefusal(
|
||||
response: Response,
|
||||
): Promise<SponsorRefusal | null> {
|
||||
if (response.status !== 409 && response.status !== 403) return null;
|
||||
if (![400, 403, 409].includes(response.status)) return null;
|
||||
if (typeof response.clone !== 'function') return null;
|
||||
let body: {
|
||||
code?: string;
|
||||
@@ -71,9 +74,68 @@ export async function readSponsorRefusal(
|
||||
if (body?.code === 'sponsor_not_allowed') {
|
||||
return { kind: 'notAllowed', resources: body.resources ?? [] };
|
||||
}
|
||||
if (body?.code === 'sponsor_confirmation_unexpected') {
|
||||
return { kind: 'unexpected' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an agent or workflow save, asking the caller whenever the server
|
||||
* wants their confirmation to sponsor something, and resending with it.
|
||||
*
|
||||
* Args:
|
||||
* send: Sends the save with these `confirm_sponsor` keys.
|
||||
* ask: Shows the confirmation; resolves to the agreed keys, or null.
|
||||
* confirmed: Keys already agreed (items the caller chose to take over).
|
||||
*
|
||||
* Returns:
|
||||
* The last response, or null when the caller declined.
|
||||
*/
|
||||
export async function saveWithSponsorConsent(
|
||||
send: (confirm: string[]) => Promise<Response>,
|
||||
ask: (confirmation: SponsorConfirmation) => Promise<string[] | null>,
|
||||
confirmed: string[] = [],
|
||||
): Promise<Response | null> {
|
||||
let keys = Array.from(new Set(confirmed));
|
||||
let response = await send(keys);
|
||||
// The agent can change between rounds; three asks is plenty.
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
const refusal = await readSponsorRefusal(response);
|
||||
if (refusal?.kind !== 'confirm') return response;
|
||||
const agreed = await ask(refusal.confirmation);
|
||||
if (!agreed) return null;
|
||||
keys = Array.from(new Set([...keys, ...agreed]));
|
||||
response = await send(keys);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* A MultiSelect's options plus one for each selected id the caller can't
|
||||
* list (the owner's private tool or source on a workflow node), so it can
|
||||
* be removed. Selecting only removes it; nothing opens.
|
||||
*
|
||||
* Args:
|
||||
* options: The caller's own options.
|
||||
* attached: Known names of referenced resources, by id.
|
||||
* selected: The ids selected now.
|
||||
* format: Turns a name into the option's label.
|
||||
*/
|
||||
export function withAttachedOptions(
|
||||
options: { value: string; label: string }[],
|
||||
attached: { id: string; label: string }[],
|
||||
selected: string[],
|
||||
format: (name: string) => string,
|
||||
): { value: string; label: string }[] {
|
||||
const listed = new Set(options.map((option) => option.value));
|
||||
const names = new Map(attached.map((item) => [item.id, item.label]));
|
||||
const extra = selected
|
||||
.filter((id, index) => !listed.has(id) && selected.indexOf(id) === index)
|
||||
.map((id) => ({ value: id, label: format(names.get(id) || id) }));
|
||||
return extra.length ? [...options, ...extra] : options;
|
||||
}
|
||||
|
||||
/** The error for a save that attached resources the caller may not sponsor. */
|
||||
export function sponsorNotAllowedMessage(
|
||||
t: TFunction,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useMediaQuery: () => ({ isMobile: false, isDesktop: true }),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { saveWithSponsorConsent } from './sponsorConsent';
|
||||
import { useSponsorPrompt } from './useSponsorPrompt';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const json = (status: number, body: unknown) =>
|
||||
new Response(JSON.stringify(body), { status });
|
||||
|
||||
const refused = () =>
|
||||
json(409, {
|
||||
code: 'sponsor_confirmation_required',
|
||||
resources: [{ key: 'tool:t1', type: 'tool', id: 't1', name: 'Jira' }],
|
||||
audience: { teams: ['Ops'], api_key: false, public_link: false },
|
||||
});
|
||||
|
||||
/** A workflow save as the builder does it: one call, one outcome. */
|
||||
function Harness({
|
||||
send,
|
||||
onOutcome,
|
||||
}: {
|
||||
send: (keys: string[]) => Promise<Response>;
|
||||
onOutcome: (outcome: string) => void;
|
||||
}) {
|
||||
const prompt = useSponsorPrompt();
|
||||
const save = async () => {
|
||||
const response = await saveWithSponsorConsent(send, prompt.ask);
|
||||
onOutcome(!response ? 'cancelled' : response.ok ? 'saved' : 'failed');
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<button type="button" data-testid="save" onClick={() => void save()} />
|
||||
{prompt.modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useSponsorPrompt with a workflow save', () => {
|
||||
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 start = async (send: (keys: string[]) => Promise<Response>) => {
|
||||
const onOutcome = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(<Harness send={send} onOutcome={onOutcome} />);
|
||||
});
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-testid="save"]')!
|
||||
.click(),
|
||||
);
|
||||
return onOutcome;
|
||||
};
|
||||
|
||||
const dialogButton = (label: string) =>
|
||||
Array.from(document.querySelectorAll('button')).find(
|
||||
(b) => b.textContent === label,
|
||||
) as HTMLButtonElement;
|
||||
|
||||
it('resolves the original save with the confirmed retry', async () => {
|
||||
const send = vi
|
||||
.fn<(keys: string[]) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(refused())
|
||||
.mockResolvedValueOnce(json(200, { success: true }));
|
||||
const onOutcome = await start(send);
|
||||
// Waiting on the caller: the save hasn't ended yet.
|
||||
expect(onOutcome).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain('Jira');
|
||||
await act(async () =>
|
||||
dialogButton('agents.form.sponsorConfirm.confirm').click(),
|
||||
);
|
||||
expect(send.mock.calls.map(([keys]) => keys)).toEqual([[], ['tool:t1']]);
|
||||
expect(onOutcome).toHaveBeenCalledWith('saved');
|
||||
expect(document.querySelector('[data-slot="modal-content"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('ends as cancelled, not failed, when the caller declines', async () => {
|
||||
const send = vi.fn(async () => refused());
|
||||
const onOutcome = await start(send);
|
||||
await act(async () => dialogButton('cancel').click());
|
||||
expect(onOutcome).toHaveBeenCalledWith('cancelled');
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import SponsorConfirmModal from './components/SponsorConfirmModal';
|
||||
import type { SponsorConfirmation } from './sponsorConsent';
|
||||
|
||||
/**
|
||||
* The sponsor confirmation dialog as a promise.
|
||||
*
|
||||
* `ask` opens the dialog for one refused save and resolves to the keys the
|
||||
* caller agreed to, or null when they cancel; render `modal` once. Pass
|
||||
* `ask` to `saveWithSponsorConsent`, so the code that started the save gets
|
||||
* the retried save's result.
|
||||
*/
|
||||
export function useSponsorPrompt() {
|
||||
const [pending, setPending] = useState<{
|
||||
confirmation: SponsorConfirmation;
|
||||
resolve: (keys: string[] | null) => void;
|
||||
} | null>(null);
|
||||
|
||||
const ask = useCallback(
|
||||
(confirmation: SponsorConfirmation) =>
|
||||
new Promise<string[] | null>((resolve) =>
|
||||
setPending({ confirmation, resolve }),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const settle = (keys: string[] | null) => {
|
||||
pending?.resolve(keys);
|
||||
setPending(null);
|
||||
};
|
||||
|
||||
const modal = (
|
||||
<SponsorConfirmModal
|
||||
confirmation={pending?.confirmation ?? null}
|
||||
onCancel={() => settle(null)}
|
||||
onConfirm={(keys) => settle(keys)}
|
||||
/>
|
||||
);
|
||||
|
||||
return { ask, modal };
|
||||
}
|
||||
@@ -49,12 +49,12 @@ import { agentEditPath, agentsListPath } from '../paths';
|
||||
import { ActionMenu } from '@/components/ui/dropdown-menu';
|
||||
import AgentPageHeader from '../AgentPageHeader';
|
||||
import AgentPreviewSheet from '../components/AgentPreviewSheet';
|
||||
import SponsorConfirmModal from '../components/SponsorConfirmModal';
|
||||
import {
|
||||
readSponsorRefusal,
|
||||
type SponsorConfirmation,
|
||||
saveWithSponsorConsent,
|
||||
sponsorNotAllowedMessage,
|
||||
} from '../sponsorConsent';
|
||||
import { useSponsorPrompt } from '../useSponsorPrompt';
|
||||
import WorkflowDetailsSheet, {
|
||||
type WorkflowDetailsSave,
|
||||
} from './components/WorkflowDetailsSheet';
|
||||
@@ -101,11 +101,14 @@ import {
|
||||
validateJsonSchemaConfig,
|
||||
} from './workflowHelpers';
|
||||
import { selectWorkflowPreviewStatus } from './workflowPreviewSlice';
|
||||
import { canAddToolToOwn } from '../../utils/toolUtils';
|
||||
import { canAddToolToOwn, getToolDisplayName } from '../../utils/toolUtils';
|
||||
|
||||
import type { Model } from '../../models/types';
|
||||
|
||||
const PRIMARY_ACTION_SPINNER_DELAY_MS = 180;
|
||||
|
||||
/** How a save ended; `cancelled` when the caller declined to sponsor. */
|
||||
type WorkflowSaveOutcome = 'saved' | 'failed' | 'cancelled';
|
||||
// A node added from the palette while one is selected lands this far right
|
||||
// of it (then moves down until it covers nothing).
|
||||
const ADD_BESIDE_GAP_X = 80;
|
||||
@@ -228,12 +231,9 @@ function WorkflowBuilderInner() {
|
||||
const [showPrimaryActionSpinner, setShowPrimaryActionSpinner] =
|
||||
useState(false);
|
||||
const [publishErrors, setPublishErrors] = useState<string[]>([]);
|
||||
// A save refused until the caller agrees that a node resource the owner
|
||||
// can't use runs with their access; ``retry`` saves again with that.
|
||||
const [sponsorRequest, setSponsorRequest] = useState<{
|
||||
confirmation: SponsorConfirmation;
|
||||
retry: (keys: string[]) => void;
|
||||
} | null>(null);
|
||||
// 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();
|
||||
const [errorContext, setErrorContext] = useState<'preview' | 'publish'>(
|
||||
'publish',
|
||||
);
|
||||
@@ -261,6 +261,13 @@ function WorkflowBuilderInner() {
|
||||
);
|
||||
const [defaultAgentModelId, setDefaultAgentModelId] = useState('');
|
||||
const [availableTools, setAvailableTools] = useState<UserTool[]>([]);
|
||||
// Names of every tool and source the saved graph references, whoever owns
|
||||
// them, so node pickers keep a remove-only option for the owner's private
|
||||
// ones (GET /api/workflows/<id> ``ref_details``).
|
||||
const [nodeRefNames, setNodeRefNames] = useState<{
|
||||
tools: { id: string; label: string }[];
|
||||
sources: { id: string; label: string }[];
|
||||
}>({ tools: [], sources: [] });
|
||||
const sourceOptions = useMemo(
|
||||
() =>
|
||||
(sourceDocs ?? [])
|
||||
@@ -932,7 +939,22 @@ function WorkflowBuilderInner() {
|
||||
workflow,
|
||||
nodes: apiNodes,
|
||||
edges: apiEdges,
|
||||
ref_details: refDetails,
|
||||
} = responseData.data;
|
||||
setNodeRefNames({
|
||||
tools: (refDetails?.tools ?? []).map(
|
||||
(tool: { id: string; name?: string; display_name?: string }) => ({
|
||||
id: tool.id,
|
||||
label: getToolDisplayName(tool),
|
||||
}),
|
||||
),
|
||||
sources: (refDetails?.sources ?? []).map(
|
||||
(source: { id: string; name: string | null }) => ({
|
||||
id: source.id,
|
||||
label: source.name || '',
|
||||
}),
|
||||
),
|
||||
});
|
||||
const nextWorkflowName = workflow.name;
|
||||
const nextWorkflowDescription = workflow.description || '';
|
||||
const mappedNodes = apiNodes.map((n: WorkflowNode) => {
|
||||
@@ -1322,17 +1344,14 @@ function WorkflowBuilderInner() {
|
||||
: false;
|
||||
|
||||
const persistWorkflow = useCallback(
|
||||
async (
|
||||
navigateAfterSuccess: boolean,
|
||||
confirmSponsor?: string[],
|
||||
): Promise<boolean> => {
|
||||
async (navigateAfterSuccess: boolean): Promise<WorkflowSaveOutcome> => {
|
||||
setPublishErrors([]);
|
||||
setErrorContext('publish');
|
||||
|
||||
const validationErrors = validateWorkflow();
|
||||
if (validationErrors.length > 0) {
|
||||
setPublishErrors(validationErrors);
|
||||
return false;
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
@@ -1342,25 +1361,24 @@ function WorkflowBuilderInner() {
|
||||
|
||||
let savedWorkflowId = workflowId;
|
||||
if (workflowId) {
|
||||
const updateResponse = await userService.updateWorkflow(
|
||||
workflowId,
|
||||
confirmSponsor && confirmSponsor.length > 0
|
||||
? { ...workflowPayload, confirm_sponsor: confirmSponsor }
|
||||
: workflowPayload,
|
||||
token,
|
||||
// A node tool or source the owner can't use would run with the
|
||||
// caller's access: ask first, then save again with their answer.
|
||||
const updateResponse = await saveWithSponsorConsent(
|
||||
(confirm) =>
|
||||
userService.updateWorkflow(
|
||||
workflowId,
|
||||
confirm.length > 0
|
||||
? { ...workflowPayload, confirm_sponsor: confirm }
|
||||
: workflowPayload,
|
||||
token,
|
||||
),
|
||||
sponsorPrompt.ask,
|
||||
);
|
||||
if (!updateResponse) return 'cancelled';
|
||||
if (!updateResponse.ok) {
|
||||
// A node tool or source the owner can't use would run with the
|
||||
// caller's access: ask first, then save again with their answer.
|
||||
const refusal = await readSponsorRefusal(updateResponse);
|
||||
if (refusal?.kind === 'confirm') {
|
||||
setSponsorRequest({
|
||||
confirmation: refusal.confirmation,
|
||||
retry: (keys) => {
|
||||
void persistWorkflow(navigateAfterSuccess, keys);
|
||||
},
|
||||
});
|
||||
return false;
|
||||
if (refusal?.kind === 'unexpected') {
|
||||
throw new Error(t('agents.form.sponsors.confirmationOutdated'));
|
||||
}
|
||||
if (refusal?.kind === 'notAllowed') {
|
||||
throw new Error(
|
||||
@@ -1415,7 +1433,7 @@ function WorkflowBuilderInner() {
|
||||
if (navigateAfterSuccess) {
|
||||
navigateBackToAgents();
|
||||
}
|
||||
return true;
|
||||
return 'saved';
|
||||
}
|
||||
|
||||
const createResponse = await userService.createWorkflow(
|
||||
@@ -1427,7 +1445,7 @@ function WorkflowBuilderInner() {
|
||||
const backendErrors = errorData.errors || [];
|
||||
if (backendErrors.length > 0) {
|
||||
setPublishErrors(backendErrors);
|
||||
return false;
|
||||
return 'failed';
|
||||
}
|
||||
throw new Error(
|
||||
errorData.message ||
|
||||
@@ -1491,7 +1509,7 @@ function WorkflowBuilderInner() {
|
||||
if (navigateAfterSuccess) {
|
||||
navigateBackToAgents();
|
||||
}
|
||||
return true;
|
||||
return 'saved';
|
||||
} catch (error) {
|
||||
if (createdWorkflowId) {
|
||||
try {
|
||||
@@ -1515,7 +1533,7 @@ function WorkflowBuilderInner() {
|
||||
? error.message
|
||||
: t('agents.workflow.builder.saveFailed'),
|
||||
]);
|
||||
return false;
|
||||
return 'failed';
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
@@ -1534,6 +1552,7 @@ function WorkflowBuilderInner() {
|
||||
navigateBackToAgents,
|
||||
t,
|
||||
i18n.language,
|
||||
sponsorPrompt.ask,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1560,10 +1579,11 @@ function WorkflowBuilderInner() {
|
||||
setDetailsSaveRequested(false);
|
||||
setDetailsSaving(true);
|
||||
setDetailsSaveFailed(false);
|
||||
void persistWorkflow(false).then((ok) => {
|
||||
void persistWorkflow(false).then((outcome) => {
|
||||
setDetailsSaving(false);
|
||||
if (ok) setShowDetails(false);
|
||||
else setDetailsSaveFailed(true);
|
||||
if (outcome === 'saved') setShowDetails(false);
|
||||
// Declining the sponsor confirmation leaves the sheet open, no error.
|
||||
else if (outcome === 'failed') setDetailsSaveFailed(true);
|
||||
});
|
||||
}, [detailsSaveRequested, persistWorkflow]);
|
||||
|
||||
@@ -1884,6 +1904,8 @@ function WorkflowBuilderInner() {
|
||||
availableModels={availableModels}
|
||||
availableTools={availableTools}
|
||||
sourceOptions={sourceOptions}
|
||||
attachedTools={nodeRefNames.tools}
|
||||
attachedSources={nodeRefNames.sources}
|
||||
documentOptions={selectedAgentDocumentOptions}
|
||||
jsonSchemaText={selectedAgentJsonSchemaText}
|
||||
jsonSchemaError={selectedAgentJsonSchemaError}
|
||||
@@ -1981,15 +2003,7 @@ function WorkflowBuilderInner() {
|
||||
}}
|
||||
/>
|
||||
</AgentPreviewSheet>
|
||||
<SponsorConfirmModal
|
||||
confirmation={sponsorRequest?.confirmation ?? null}
|
||||
onCancel={() => setSponsorRequest(null)}
|
||||
onConfirm={(keys) => {
|
||||
const retry = sponsorRequest?.retry;
|
||||
setSponsorRequest(null);
|
||||
retry?.(keys);
|
||||
}}
|
||||
/>
|
||||
{sponsorPrompt.modal}
|
||||
<ConfirmationModal
|
||||
message={
|
||||
workflowName
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { Node } from 'reactflow';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && 'name' in opts ? `${key}:${opts.name}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const pickers = vi.hoisted(
|
||||
() => [] as { options: { value: string; label: string }[] }[],
|
||||
);
|
||||
vi.mock('@/components/ui/multi-select', () => ({
|
||||
MultiSelect: (props: {
|
||||
options: { value: string; label: string }[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}) => {
|
||||
pickers.push(props);
|
||||
return (
|
||||
<div data-testid="multi-select">
|
||||
{props.options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
data-option={option.value}
|
||||
onClick={() =>
|
||||
props.onChange(props.selected.filter((v) => v !== option.value))
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
vi.mock('../components/PromptTextArea', () => ({
|
||||
default: () => null,
|
||||
extractUpstreamVariables: () => [],
|
||||
}));
|
||||
vi.mock('../components/NodeDocumentsControl', () => ({ default: () => null }));
|
||||
|
||||
import AgentPanel from './AgentPanel';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('AgentPanel attached resources', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
pickers.length = 0;
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const node = {
|
||||
id: 'a1',
|
||||
type: 'agent',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { config: { tools: ['mine', 'owners'], sources: ['owner-src'] } },
|
||||
} as Node;
|
||||
|
||||
const render = async (onUpdate = vi.fn()) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AgentPanel
|
||||
node={node}
|
||||
onUpdate={onUpdate}
|
||||
nodes={[node]}
|
||||
edges={[]}
|
||||
availableModels={[]}
|
||||
availableTools={[
|
||||
{ id: 'mine', name: 'api_tool', displayName: 'My API' },
|
||||
]}
|
||||
sourceOptions={[{ value: 'my-src', label: 'My docs' }]}
|
||||
attachedTools={[{ id: 'owners', label: 'Owner Jira' }]}
|
||||
attachedSources={[{ id: 'owner-src', label: 'Owner docs' }]}
|
||||
documentOptions={[]}
|
||||
jsonSchemaText=""
|
||||
jsonSchemaError={null}
|
||||
modelSupportsStructuredOutput={false}
|
||||
onJsonSchemaChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return onUpdate;
|
||||
};
|
||||
|
||||
// An editor's pickers don't list the owner's private tools and sources;
|
||||
// those still need a row so they can be taken off the node.
|
||||
it('adds remove-only options for attached tools and sources', async () => {
|
||||
const onUpdate = await render();
|
||||
const labels = pickers.flatMap((p) => p.options.map((o) => o.label));
|
||||
expect(labels).toContain(
|
||||
'agents.workflow.builder.attachedOption:Owner Jira',
|
||||
);
|
||||
expect(labels).toContain(
|
||||
'agents.workflow.builder.attachedOption:Owner docs',
|
||||
);
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-option="owners"]')!
|
||||
.click(),
|
||||
);
|
||||
expect(onUpdate).toHaveBeenCalledWith({
|
||||
config: { tools: ['mine'], sources: ['owner-src'] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { getToolDisplayName } from '../../../utils/toolUtils';
|
||||
import { withAttachedOptions } from '../../sponsorConsent';
|
||||
import NodeDocumentsControl from '../components/NodeDocumentsControl';
|
||||
import PromptTextArea from '../components/PromptTextArea';
|
||||
import {
|
||||
@@ -42,6 +43,13 @@ interface AgentPanelProps extends NodePanelBodyProps {
|
||||
availableModels: Model[];
|
||||
availableTools: UserTool[];
|
||||
sourceOptions: { value: string; label: string }[];
|
||||
/**
|
||||
* Names of the tools and sources the workflow's nodes reference, whoever
|
||||
* owns them: a selected one missing from the caller's own options (the
|
||||
* owner's private tool) still gets an option, so it can be removed.
|
||||
*/
|
||||
attachedTools?: { id: string; label: string }[];
|
||||
attachedSources?: { id: string; label: string }[];
|
||||
/** Upstream file variables the agent can read. */
|
||||
documentOptions: { value: string; label: string }[];
|
||||
/** The structured-output schema as typed. */
|
||||
@@ -101,6 +109,8 @@ export default function AgentPanel({
|
||||
availableModels,
|
||||
availableTools,
|
||||
sourceOptions,
|
||||
attachedTools = [],
|
||||
attachedSources = [],
|
||||
documentOptions,
|
||||
jsonSchemaText,
|
||||
jsonSchemaError,
|
||||
@@ -120,6 +130,23 @@ export default function AgentPanel({
|
||||
const builtinModels = availableModels.filter((m) => m.source !== 'user');
|
||||
const userModels = availableModels.filter((m) => m.source === 'user');
|
||||
const schemaInvalid = jsonSchemaText.trim() !== '' && jsonSchemaError;
|
||||
const attachedLabel = (name: string) =>
|
||||
t('agents.workflow.builder.attachedOption', { ...NO_ESCAPE, name });
|
||||
const toolOptions = withAttachedOptions(
|
||||
availableTools.map((tool) => ({
|
||||
value: tool.id,
|
||||
label: getToolDisplayName(tool),
|
||||
})),
|
||||
attachedTools,
|
||||
config.tools || [],
|
||||
attachedLabel,
|
||||
);
|
||||
const nodeSourceOptions = withAttachedOptions(
|
||||
sourceOptions,
|
||||
attachedSources,
|
||||
config.sources || [],
|
||||
attachedLabel,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -248,7 +275,7 @@ export default function AgentPanel({
|
||||
labelSurface="background"
|
||||
>
|
||||
<MultiSelect
|
||||
options={sourceOptions}
|
||||
options={nodeSourceOptions}
|
||||
selected={config.sources || []}
|
||||
onChange={(newSources) => updateConfig({ sources: newSources })}
|
||||
placeholder={t('agents.form.placeholders.selectSources')}
|
||||
@@ -261,10 +288,7 @@ export default function AgentPanel({
|
||||
labelSurface="background"
|
||||
>
|
||||
<MultiSelect
|
||||
options={availableTools.map((tool) => ({
|
||||
value: tool.id,
|
||||
label: getToolDisplayName(tool),
|
||||
}))}
|
||||
options={toolOptions}
|
||||
selected={config.tools || []}
|
||||
onChange={(newTools) => updateConfig({ tools: newTools })}
|
||||
placeholder={t('agents.form.placeholders.selectTools')}
|
||||
|
||||
@@ -2368,9 +2368,15 @@
|
||||
"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 hat keinen Zugriff mehr. Entferne sie oder wähle welche, die der Eigentümer nutzen kann.",
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
"title": "Mit deinem Zugriff ausführen?",
|
||||
@@ -2381,7 +2387,7 @@
|
||||
"audiencePublicLink": "Alle mit dem öffentlichen Link des Agenten",
|
||||
"audienceWebhook": "Alles, was den Webhook des Agenten aufruft",
|
||||
"audienceEditors": "Alle, die diesen Agenten öffnen können",
|
||||
"stopNote": "Sie laufen nicht mehr, wenn du den Bearbeitungszugriff auf sie oder auf diesen Agenten verlierst. Bis jemand, der sie bearbeiten kann, sie erneut hinzufügt, bleiben sie angehängt, tun aber nichts.",
|
||||
"stopNote": "Sie laufen nicht mehr, wenn du den Bearbeitungszugriff auf sie oder auf diesen Agenten verlierst. Dann bleiben sie angehängt, tun aber nichts, bis jemand, der sie bearbeiten kann, sie übernimmt oder sie entfernt werden.",
|
||||
"confirm": "Mit meinem Zugriff hinzufügen",
|
||||
"types": {
|
||||
"tool": "Tool",
|
||||
@@ -2718,7 +2724,8 @@
|
||||
"detailsTitle": "Workflow-Details",
|
||||
"detailsDescription": "Name, Beschreibung und Bild, die auf der Agentenkarte angezeigt werden.",
|
||||
"unsavedChanges": "Nicht gespeicherte Änderungen",
|
||||
"editDetailsMenu": "Details bearbeiten"
|
||||
"editDetailsMenu": "Details bearbeiten",
|
||||
"attachedOption": "{{name}} (von jemand anderem hinzugefügt)"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Workflow-Name ist erforderlich",
|
||||
|
||||
@@ -2386,9 +2386,15 @@
|
||||
"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 no longer has access. Remove them or choose ones the owner can use.",
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
"title": "Run with your access?",
|
||||
@@ -2399,7 +2405,7 @@
|
||||
"audiencePublicLink": "Anyone with the agent's public link",
|
||||
"audienceWebhook": "Anything that calls the agent's webhook",
|
||||
"audienceEditors": "Everyone who can open this agent",
|
||||
"stopNote": "They stop running if you lose edit access to them or to this agent. Until someone who can edit them adds them again, they stay attached but do nothing.",
|
||||
"stopNote": "They stop running if you lose edit access to them or to this agent. They then stay attached but do nothing until someone who can edit them takes them over, or they are removed.",
|
||||
"confirm": "Add with my access",
|
||||
"types": {
|
||||
"tool": "Tool",
|
||||
@@ -2753,7 +2759,8 @@
|
||||
"detailsTitle": "Workflow details",
|
||||
"detailsDescription": "Name, description and image shown on the agent card.",
|
||||
"unsavedChanges": "Unsaved changes",
|
||||
"editDetailsMenu": "Edit details"
|
||||
"editDetailsMenu": "Edit details",
|
||||
"attachedOption": "{{name}} (added by someone else)"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Workflow name is required",
|
||||
|
||||
@@ -2368,9 +2368,15 @@
|
||||
"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 tiene acceso. Quítalos o elige otros que el propietario pueda usar.",
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
"title": "¿Ejecutar con tu acceso?",
|
||||
@@ -2381,7 +2387,7 @@
|
||||
"audiencePublicLink": "Cualquiera con el enlace público del agente",
|
||||
"audienceWebhook": "Todo lo que llame al webhook del agente",
|
||||
"audienceEditors": "Todos los que pueden abrir este agente",
|
||||
"stopNote": "Dejan de ejecutarse si pierdes el acceso de edición a ellos o a este agente. Hasta que alguien que pueda editarlos los vuelva a añadir, siguen adjuntos pero no hacen nada.",
|
||||
"stopNote": "Dejan de ejecutarse si pierdes el acceso de edición a ellos o a este agente. Entonces siguen adjuntos pero no hacen nada hasta que alguien que pueda editarlos se haga cargo de ellos o se quiten.",
|
||||
"confirm": "Añadir con mi acceso",
|
||||
"types": {
|
||||
"tool": "Herramienta",
|
||||
@@ -2718,7 +2724,8 @@
|
||||
"detailsTitle": "Detalles del flujo de trabajo",
|
||||
"detailsDescription": "Nombre, descripción e imagen que se muestran en la tarjeta del agente.",
|
||||
"unsavedChanges": "Cambios sin guardar",
|
||||
"editDetailsMenu": "Editar detalles"
|
||||
"editDetailsMenu": "Editar detalles",
|
||||
"attachedOption": "{{name}} (añadido por otra persona)"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "El nombre del flujo de trabajo es obligatorio",
|
||||
|
||||
@@ -2353,9 +2353,15 @@
|
||||
"attachNote": "オーナーが使用できないツール、ソース、プロンプトを追加すると、このエージェントを使うすべての人に対してあなたのアクセス権で実行されます。自分が所有しているか編集できるものに限られ、追加時に確認を求められます。",
|
||||
"publicLinkNote": "このエージェントには公開リンクがあるため、リンクを知っている人は誰でもそれらから回答を得られます。",
|
||||
"addedBy": "{{person}} が追加: {{names}}",
|
||||
"unavailable": "実行されていません: {{names}}。追加した {{person}} はアクセス権を失いました。削除するか、オーナーが使用できるものを選んでください。",
|
||||
"unavailable": "実行されていません: {{names}}。追加した {{person}} は、このエージェントを通じてそれらを共有できなくなりました。削除するか、オーナーが使用できるものを選んでください。",
|
||||
"unknownItem": "名前のない項目",
|
||||
"notAllowed": "{{names}} をこのエージェントに追加できません。オーナーはそれらを使用できず、共有できるのは自分が所有しているか編集できるものだけです。"
|
||||
"notAllowed": "{{names}} をこのエージェントに追加できません。オーナーはそれらを使用できず、共有できるのは自分が所有しているか編集できるものだけです。",
|
||||
"stoppedCannotEditAgent": "実行されていません: {{names}}。追加した {{person}} は、このエージェントを編集できなくなりました。削除するか、オーナーが使用できるものを選んでください。",
|
||||
"stoppedCannotEditItem": "実行されていません: {{names}}。{{person}} が追加しましたが、それらを編集できなくなりました。エージェントを通じて共有できるのは、項目を所有しているか編集できる人だけです。削除するか、オーナーが使用できるものを選んでください。",
|
||||
"takeOver": "{{name}} を自分のアクセス権で実行",
|
||||
"takeOverPending": "保存すると、{{name}} はあなたのアクセス権で実行されます。",
|
||||
"undoTakeOver": "元に戻す",
|
||||
"confirmationOutdated": "あなたのアクセス権で実行するものを確認した後に、このエージェントが変更されました。通知を確認して、もう一度保存してください。"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
"title": "あなたのアクセス権で実行しますか?",
|
||||
@@ -2366,7 +2372,7 @@
|
||||
"audiencePublicLink": "エージェントの公開リンクを知っているすべての人",
|
||||
"audienceWebhook": "エージェントの Webhook を呼び出すすべてのもの",
|
||||
"audienceEditors": "このエージェントを開けるすべての人",
|
||||
"stopNote": "それらやこのエージェントの編集権限を失うと、実行されなくなります。編集できる人が再度追加するまで、追加されたまま何もしません。",
|
||||
"stopNote": "それらやこのエージェントの編集権限を失うと、実行されなくなります。その後は、編集できる人が引き継ぐか削除されるまで、追加されたまま何もしません。",
|
||||
"confirm": "自分のアクセス権で追加",
|
||||
"types": {
|
||||
"tool": "ツール",
|
||||
@@ -2703,7 +2709,8 @@
|
||||
"detailsTitle": "ワークフローの詳細",
|
||||
"detailsDescription": "エージェントカードに表示される名前、説明、画像です。",
|
||||
"unsavedChanges": "未保存の変更",
|
||||
"editDetailsMenu": "詳細を編集"
|
||||
"editDetailsMenu": "詳細を編集",
|
||||
"attachedOption": "{{name}}(他のユーザーが追加)"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "ワークフロー名は必須です",
|
||||
|
||||
@@ -2460,9 +2460,15 @@
|
||||
"attachNote": "Инструменты, источники и промпты, которые вы добавляете и которые владелец не может использовать, работают с вашим доступом для всех, кто пользуется этим агентом. Вы должны быть их владельцем или иметь право их редактировать, и вас попросят это подтвердить.",
|
||||
"publicLinkNote": "У этого агента есть публичная ссылка, поэтому любой, у кого она есть, может получать ответы на их основе.",
|
||||
"addedBy": "Добавил(а) {{person}}: {{names}}",
|
||||
"unavailable": "Не работают: {{names}}. {{person}} добавил(а) их и больше не имеет доступа. Удалите их или выберите те, которые может использовать владелец.",
|
||||
"unavailable": "Не работают: {{names}}. {{person}} добавил(а) их и больше не может делиться ими через этого агента. Удалите их или выберите те, которые может использовать владелец.",
|
||||
"unknownItem": "Элемент без названия",
|
||||
"notAllowed": "Вы не можете добавить {{names}} к этому агенту. Владелец не может их использовать, а делиться можно только тем, чем вы владеете или что можете редактировать."
|
||||
"notAllowed": "Вы не можете добавить {{names}} к этому агенту. Владелец не может их использовать, а делиться можно только тем, чем вы владеете или что можете редактировать.",
|
||||
"stoppedCannotEditAgent": "Не работают: {{names}}. {{person}} добавил(а) их и больше не может редактировать этого агента. Удалите их или выберите те, которые может использовать владелец.",
|
||||
"stoppedCannotEditItem": "Не работают: {{names}}. {{person}} добавил(а) их, но больше не может их редактировать, а делиться элементом через агента может только его владелец или тот, кто может его редактировать. Удалите их или выберите те, которые может использовать владелец.",
|
||||
"takeOver": "Запускать {{name}} с моим доступом",
|
||||
"takeOverPending": "{{name}} будет работать с вашим доступом после сохранения.",
|
||||
"undoTakeOver": "Отменить",
|
||||
"confirmationOutdated": "Агент изменился после того, как вы подтвердили, что работает с вашим доступом. Проверьте уведомления и сохраните снова."
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
"title": "Запускать с вашим доступом?",
|
||||
@@ -2473,7 +2479,7 @@
|
||||
"audiencePublicLink": "Все, у кого есть публичная ссылка на агента",
|
||||
"audienceWebhook": "Всё, что вызывает вебхук агента",
|
||||
"audienceEditors": "Все, кто может открыть этого агента",
|
||||
"stopNote": "Они перестанут работать, если вы потеряете право редактировать их или этого агента. Пока кто-то с правом редактирования не добавит их снова, они остаются подключёнными, но ничего не делают.",
|
||||
"stopNote": "Они перестанут работать, если вы потеряете право редактировать их или этого агента. Тогда они остаются подключёнными, но ничего не делают, пока кто-то с правом редактирования не возьмёт их на себя или их не удалят.",
|
||||
"confirm": "Добавить с моим доступом",
|
||||
"types": {
|
||||
"tool": "Инструмент",
|
||||
@@ -2816,7 +2822,8 @@
|
||||
"detailsTitle": "Сведения о рабочем процессе",
|
||||
"detailsDescription": "Название, описание и изображение, которые показываются на карточке агента.",
|
||||
"unsavedChanges": "Несохранённые изменения",
|
||||
"editDetailsMenu": "Изменить сведения"
|
||||
"editDetailsMenu": "Изменить сведения",
|
||||
"attachedOption": "{{name}} (добавлено другим пользователем)"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Укажите название рабочего процесса",
|
||||
|
||||
@@ -2353,9 +2353,15 @@
|
||||
"attachNote": "你新增的、擁有者無法使用的工具、來源和提示詞,會以你的存取權限為所有使用此智慧代理的人執行。你必須擁有它們或能夠編輯它們,並且需要確認。",
|
||||
"publicLinkNote": "此智慧代理有公開連結,任何擁有該連結的人都可以從中取得回答。",
|
||||
"addedBy": "由 {{person}} 新增:{{names}}",
|
||||
"unavailable": "未執行:{{names}}。新增它們的 {{person}} 已失去存取權限。請移除它們,或選擇擁有者可以使用的項目。",
|
||||
"unavailable": "未執行:{{names}}。新增它們的 {{person}} 已無法再透過此智慧代理分享它們。請移除它們,或選擇擁有者可以使用的項目。",
|
||||
"unknownItem": "未命名項目",
|
||||
"notAllowed": "無法將 {{names}} 新增到此智慧代理。擁有者無法使用它們,而你只能分享自己擁有或可以編輯的項目。"
|
||||
"notAllowed": "無法將 {{names}} 新增到此智慧代理。擁有者無法使用它們,而你只能分享自己擁有或可以編輯的項目。",
|
||||
"stoppedCannotEditAgent": "未執行:{{names}}。新增它們的 {{person}} 已無法再編輯此智慧代理。請移除它們,或選擇擁有者可以使用的項目。",
|
||||
"stoppedCannotEditItem": "未執行:{{names}}。{{person}} 新增了它們,但已無法再編輯它們,而只有擁有或可以編輯某個項目的人才能透過智慧代理分享它。請移除它們,或選擇擁有者可以使用的項目。",
|
||||
"takeOver": "以我的存取權限執行 {{name}}",
|
||||
"takeOverPending": "儲存後,{{name}} 將以你的存取權限執行。",
|
||||
"undoTakeOver": "復原",
|
||||
"confirmationOutdated": "在你確認以你的存取權限執行的內容之後,此智慧代理已變更。請查看提示並重新儲存。"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
"title": "以你的存取權限執行?",
|
||||
@@ -2366,7 +2372,7 @@
|
||||
"audiencePublicLink": "任何擁有此智慧代理公開連結的人",
|
||||
"audienceWebhook": "任何呼叫此智慧代理 Webhook 的程式",
|
||||
"audienceEditors": "所有可以開啟此智慧代理的人",
|
||||
"stopNote": "如果你失去對它們或此智慧代理的編輯權限,它們將停止執行。在有編輯權限的人重新新增之前,它們仍附加在智慧代理上,但不起作用。",
|
||||
"stopNote": "如果你失去對它們或此智慧代理的編輯權限,它們將停止執行。之後它們仍附加在智慧代理上但不起作用,直到有編輯權限的人接管它們或將其移除。",
|
||||
"confirm": "以我的存取權限新增",
|
||||
"types": {
|
||||
"tool": "工具",
|
||||
@@ -2703,7 +2709,8 @@
|
||||
"detailsTitle": "工作流程詳細資料",
|
||||
"detailsDescription": "顯示在代理卡片上的名稱、說明和圖片。",
|
||||
"unsavedChanges": "未儲存的變更",
|
||||
"editDetailsMenu": "編輯詳細資料"
|
||||
"editDetailsMenu": "編輯詳細資料",
|
||||
"attachedOption": "{{name}}(由其他人新增)"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "工作流程名稱為必填",
|
||||
|
||||
@@ -2353,9 +2353,15 @@
|
||||
"attachNote": "你添加的、所有者无法使用的工具、来源和提示词,会以你的访问权限为所有使用此智能体的人运行。你必须拥有它们或能够编辑它们,并且需要确认。",
|
||||
"publicLinkNote": "此智能体有公开链接,任何拥有该链接的人都可以从中获得回答。",
|
||||
"addedBy": "由 {{person}} 添加:{{names}}",
|
||||
"unavailable": "未运行:{{names}}。添加它们的 {{person}} 已失去访问权限。请移除它们,或选择所有者可以使用的项目。",
|
||||
"unavailable": "未运行:{{names}}。添加它们的 {{person}} 已无法再通过此智能体共享它们。请移除它们,或选择所有者可以使用的项目。",
|
||||
"unknownItem": "未命名项目",
|
||||
"notAllowed": "无法将 {{names}} 添加到此智能体。所有者无法使用它们,而你只能共享自己拥有或可以编辑的项目。"
|
||||
"notAllowed": "无法将 {{names}} 添加到此智能体。所有者无法使用它们,而你只能共享自己拥有或可以编辑的项目。",
|
||||
"stoppedCannotEditAgent": "未运行:{{names}}。添加它们的 {{person}} 已无法再编辑此智能体。请移除它们,或选择所有者可以使用的项目。",
|
||||
"stoppedCannotEditItem": "未运行:{{names}}。{{person}} 添加了它们,但已无法再编辑它们,而只有拥有或可以编辑某个项目的人才能通过智能体共享它。请移除它们,或选择所有者可以使用的项目。",
|
||||
"takeOver": "以我的访问权限运行 {{name}}",
|
||||
"takeOverPending": "保存后,{{name}} 将以你的访问权限运行。",
|
||||
"undoTakeOver": "撤销",
|
||||
"confirmationOutdated": "在你确认以你的访问权限运行的内容之后,此智能体已更改。请查看提示并重新保存。"
|
||||
},
|
||||
"sponsorConfirm": {
|
||||
"title": "以你的访问权限运行?",
|
||||
@@ -2366,7 +2372,7 @@
|
||||
"audiencePublicLink": "任何拥有此智能体公开链接的人",
|
||||
"audienceWebhook": "任何调用此智能体 Webhook 的程序",
|
||||
"audienceEditors": "所有可以打开此智能体的人",
|
||||
"stopNote": "如果你失去对它们或此智能体的编辑权限,它们将停止运行。在有编辑权限的人重新添加之前,它们仍附加在智能体上,但不起作用。",
|
||||
"stopNote": "如果你失去对它们或此智能体的编辑权限,它们将停止运行。之后它们仍附加在智能体上但不起作用,直到有编辑权限的人接管它们或将其移除。",
|
||||
"confirm": "以我的访问权限添加",
|
||||
"types": {
|
||||
"tool": "工具",
|
||||
@@ -2703,7 +2709,8 @@
|
||||
"detailsTitle": "工作流详情",
|
||||
"detailsDescription": "显示在代理卡片上的名称、描述和图片。",
|
||||
"unsavedChanges": "未保存的更改",
|
||||
"editDetailsMenu": "编辑详情"
|
||||
"editDetailsMenu": "编辑详情",
|
||||
"attachedOption": "{{name}}(由其他人添加)"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "工作流名称为必填项",
|
||||
|
||||
Reference in new issue
Block a user