Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/commons/src/UploadDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,21 @@ it('computes collection saves only for collections whose membership the publishe
const changes = collectionChanges([a, { ...b, modelIds: ['m'] }, c], 'm', { [a.id]: false, [b.id]: true });
expect(changes).toEqual([{ collection: a, fields: { title: a.title, description: a.description, visibility: a.visibility, modelIds: ['x'] } }]);
});

it('starts from AI Chat\'s preset and publishes on its own once the upload is ready, applying the preset collections', async () => {
const jigs = collection();
const f = setup({ collections: [jigs] }); const published = vi.fn(), publication = vi.fn();
const file = new File(['saved snapshot bytes'], 'Garden bench.pf');
cloud.upload.mockResolvedValue(asset({ filename: file.name, format: 'pf', size_bytes: file.size }));
cloud.stream.mockResolvedValue({ state: 'pending' });
render(<UploadDialog account={f.account} filesUrl="https://files.example.com" modelId={null} newModelId="design-snapshot" initialFile={file} snapshotLabel="Private cloud revision 12"
preset={{ details: { title: 'Cedar garden bench', description: 'A two-seat bench.', category: 'Outdoor and garden', license: 'CC BY-NC 4.0' }, collections: { ids: [jigs.id], names: ['Garden projects'] } }}
autoPublish onClose={vi.fn()} onPublished={published} onPublication={publication}/>);
await waitFor(() => expect(published).toHaveBeenCalledWith('design-snapshot'));
expect(publication).toHaveBeenCalledWith('design-snapshot', []);
const submitted = f.rpc.mock.calls.filter(([name]) => name === 'commons_submit_model' || name === 'commons_update_draft').at(-1)![1] as { p_metadata: { title: string; description: string; licenses: { name: string }[] } };
expect(submitted.p_metadata).toMatchObject({ title: 'Cedar garden bench', description: 'A two-seat bench.' });
expect(submitted.p_metadata.licenses[0]!.name).toBe('CC BY-NC 4.0');
expect(f.rpc).toHaveBeenCalledWith('commons_save_collection', expect.objectContaining({ p_id: jigs.id, p_model_ids: ['model-other', 'design-snapshot'] }));
expect(f.rpc).toHaveBeenCalledWith('commons_save_collection', expect.objectContaining({ p_id: null, p_title: 'Garden projects', p_public: false, p_model_ids: ['design-snapshot'] }));
});
10 changes: 10 additions & 0 deletions docs/help/shared/ai-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ AI Chat can change the document open in the tab you send from: a Layout document

Each change applies all at once, without a confirmation step. The chat then shows a line such as “Changed the document: Group the shelves. Undo reverts it.” One request is one undo step, so **Undo** reverts the whole change, even when the agent made it in several parts; the chat keeps one such line for the request and updates it as the parts apply. If you edit the document while the agent is still working, its later parts become a separate undo step, and the line says how many steps **Undo** takes. If any part of a change fails, nothing changes and the agent says what could not be done.

## Save a version from AI Chat

Ask AI Chat to save, such as “Save this” or “Save it with the note: added the drawer runners.” It saves a private cloud version, exactly as **Save version** does, and the chat shows “Saved version” with the number. If nothing changed since the last version, the chat says so and no new version is made. Add notes in your own words, or ask the agent to describe the changes it made in this conversation. Saving never publishes or shares the document.

## Publish a 3D model to Commons from AI Chat

In a 3D model, ask AI Chat to publish, such as “Publish this to Commons in my Shaker cabinets collection.” The agent saves a version first, then shows a **Publish to Commons** card with the title, a description it drafted, the category, the license and the collections. If you don't name a license, it suggests CC BY 4.0 and says so. Collections you name are matched to yours; a name that matches none becomes a new private collection.

Nothing is public until you choose **Publish model** on the card (**Publish changes** for a model you already published). That opens the publishing dialog with the card's values, and it publishes as soon as the upload finishes. The first time, it asks you to create your public profile. Choose **Edit details** to review or change everything in the dialog before you choose **Publish model** yourself, or **Cancel** to publish nothing. When it's done, the card shows “Published” with a link to the model in Commons.

## Clone a document and change the copy

Ask for a new document based on the one you have open, such as “Clone this model, but make it 36″ wide” or “Start a new drawing from this one.” Before anything is copied, the chat shows a **Clone this document?** card with a name for the copy, which you can change. Choose:
Expand Down
36 changes: 33 additions & 3 deletions packages/commons-ui/src/UploadDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,16 @@ export interface UploadDialogProps {
scopeControl?: ReactNode;
inherited?: (Pick<CommonsSubmission, 'licenses' | 'credits' | 'source'> & { creator?: string }) | undefined;
newModelId?: string | undefined;
/**
* Values to start from, as AI Chat proposes them: details override what a published model has, and the collections
* are checked (by id) or created (by name) when the publisher's collections load.
*/
preset?: { details?: Partial<ModelDetails> | undefined; collections?: { ids: readonly string[]; names: readonly string[] } | undefined } | undefined;
/** Publish once everything is ready, as if Publish were chosen; anything that needs the publisher waits for them. */
autoPublish?: boolean | undefined;
onClose(): void; onPublished(modelId: string): void;
/** Called once the model is published, before collection failures are shown, with each failed collection's message. */
onPublication?: ((modelId: string, failures: readonly string[]) => void) | undefined;
}

const ACCEPT = [...COMMONS_MODEL_EXTENSIONS].map((extension) => '.' + extension).join(',');
Expand Down Expand Up @@ -80,14 +89,14 @@ export function fileStatus(file: FileState): { label: string; detail: string | n
return { label: 'File checked', detail: null, tone: 'good', progress: null };
}

export function UploadDialog({ account, filesUrl, modelId, initialFile, initialImage, newModelId, snapshotLabel, scopeControl, inherited, onClose, onPublished }: UploadDialogProps): JSX.Element {
export function UploadDialog({ account, filesUrl, modelId, initialFile, initialImage, newModelId, snapshotLabel, scopeControl, inherited, preset, autoPublish, onClose, onPublished, onPublication }: UploadDialogProps): JSX.Element {
const api = useMemo(() => new CommonsPublishing(account.client), [account.client]);
const collectionsApi = useMemo(() => new CommonsCollections(account.client), [account.client]);
const [profile, setProfile] = useState<CommonsProfile | null | undefined>();
const [loadError, setLoadError] = useState('');
const [model, setModel] = useState<ModelState | null>(null);
const [step, setStep] = useState<'upload' | 'details'>(modelId ? 'details' : 'upload');
const [details, setDetails] = useState<ModelDetails>(emptyDetails);
const [details, setDetails] = useState<ModelDetails>(() => ({ ...emptyDetails, ...preset?.details }));
const [modelFile, setModelFile] = useState<FileState | null>(null);
const [cover, setCover] = useState<FileState | null>(null);
const [save, setSave] = useState<SaveState>('idle');
Expand Down Expand Up @@ -197,7 +206,8 @@ export function UploadDialog({ account, filesUrl, modelId, initialFile, initialI
try {
const { state, model: loaded } = await refresh(modelId);
if (!live) return;
setDetails(detailsFromMetadata(loaded.current?.metadata));
setDetails({ ...detailsFromMetadata(loaded.current?.metadata), ...preset?.details });
if (preset?.details && Object.keys(preset.details).length) dirty.current = true;
const files = state.assets.filter((asset) => asset.revision_id === loaded.current?.id);
// An upload interrupted by closing the dialog is discarded; the owner chooses the file again.
for (const pending of files.filter((asset) => asset.status === 'pending')) await removeCommonsFile(filesUrl, await token(), pending.id, control.current.signal).catch(() => {});
Expand Down Expand Up @@ -381,6 +391,7 @@ export function UploadDialog({ account, filesUrl, modelId, initialFile, initialI
}
const failures = await applyCollections(published);
window.dispatchEvent(new Event('commons:catalog-changed'));
onPublication?.(published, failures);
if (!failures.length) { onPublished(published); return; }
setPublishedId(published);
setMessage(`${collectionsOnly ? 'Some collections were not updated' : 'Published, but some collections were not updated'}. ${failures.join(' ')} You can change them from the model page.`);
Expand All @@ -389,6 +400,25 @@ export function UploadDialog({ account, filesUrl, modelId, initialFile, initialI
} finally { setPublishing(false); }
};

// Preset collections are chosen once the publisher's collections load: existing ones by id, the rest created by name.
const presetApplied = useRef(false);
useEffect(() => {
if (presetApplied.current || !collections || !preset?.collections) return;
presetApplied.current = true;
const known = new Set(collections.map((collection) => collection.id));
const titles = new Set(collections.map((collection) => collection.title.toLocaleLowerCase()));
setCollectionChoices((value) => ({ ...value, ...Object.fromEntries(preset.collections!.ids.filter((id) => known.has(id)).map((id) => [id, true])) }));
setNewCollections((value) => [...value, ...preset.collections!.names.filter((name) => name.trim() && !titles.has(name.trim().toLocaleLowerCase())).map((name) => name.trim())]);
}, [collections, preset]);
// Auto-publish waits for the preset collections too, so they are applied with the publication.
const autoTried = useRef(false);
const presetReady = !preset?.collections || presetApplied.current || !!collectionsError;
useEffect(() => {
if (!autoPublish || autoTried.current || !canPublish || !presetReady) return;
autoTried.current = true;
void publish();
});

const addCollection = () => {
const title = newCollection.trim();
if (!title) return;
Expand Down
11 changes: 9 additions & 2 deletions packages/requests-core/src/agents/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Copyright 2026 PushForm contributors
import { guidanceHighlightSchema, guidanceListSchema, guidanceSidebarSchema } from '../guidance.ts';
import { CLONE_TOOL, cloneProposalSchema } from '../clone.ts';
import { PUBLISH_TOOL, PUBLISHING_INFO_TOOL, SAVE_TOOL, publishingInfoSchema, publishProposalSchema, saveSchema } from '../publish.ts';
import type Anthropic from '@anthropic-ai/sdk';
import {
LAYOUT_API_VERSION, LAYOUT_TOOLS, MODEL_API_VERSION, MODEL_TOOLS, layoutBatchSchema, layoutGetDocumentSchema, layoutListSchema,
Expand All @@ -16,9 +17,10 @@ import type { AgentContext } from './store.ts';
*/
/**
* What the sending tab can run. Model tabs sent `apiVersion: 1` for guidance alone before models could be edited, so
* a model is editable only when the tab also sends `edit: 1`. `clone: 1` says the tab can clone its document from the chat.
* a model is editable only when the tab also sends `edit: 1`. `clone: 1` says the tab can clone its document from the chat,
* `save: 1` that it can save a version, and `publish: 1` (a model tab) that it can publish to Commons.
*/
export interface DocumentCapability { type: 'layout' | 'model'; apiVersion: number; guidance?: 1 | undefined; edit?: 1 | undefined; clone?: 1 | undefined }
export interface DocumentCapability { type: 'layout' | 'model'; apiVersion: number; guidance?: 1 | undefined; edit?: 1 | undefined; clone?: 1 | undefined; save?: 1 | undefined; publish?: 1 | undefined }

export const layoutDocumentTools: readonly Anthropic.Tool[] = [
defineTool(LAYOUT_TOOLS.getDocument.name, LAYOUT_TOOLS.getDocument.description, layoutGetDocumentSchema),
Expand Down Expand Up @@ -50,6 +52,11 @@ export function documentTools(document: DocumentCapability | null): readonly Ant
const editable = editableDocument(document);
return [
...(editable === 'layout' ? layoutDocumentTools : editable === 'model' ? modelDocumentTools : []),
...(editable && document?.save === 1 ? [defineTool(SAVE_TOOL, 'Save the open document as a new private cloud version, as Save version does, with optional change notes. Returns the version number.', saveSchema)] : []),
...(editable === 'model' && document?.publish === 1 ? [
defineTool(PUBLISHING_INFO_TOOL, 'Read what publishing this model to Commons needs: whether it can be published, its current publication if any, retained source licenses, the requester\'s collections, and the categories and licenses offered. Changes nothing.', publishingInfoSchema),
defineTool(PUBLISH_TOOL, 'Save a version and show the requester a card proposing a Commons publication of it. Only their click on the card publishes; returns what the card came to.', publishProposalSchema),
] : []),
...(editable && document?.clone === 1 ? [defineTool(CLONE_TOOL, 'Propose a new document that copies the one open now. The requester confirms on a chat card that also offers copying inside this document; returns their choice.', cloneProposalSchema)] : []),
...(document?.guidance === 1 && document.apiVersion === 1 ? [
defineTool('ui_list_targets', 'Read the current editor and active tool, and list available toolbar/menu routes, action IDs, enabled states, tabs and visible object-browser items. Does not change any UI.', guidanceListSchema),
Expand Down
27 changes: 27 additions & 0 deletions packages/requests-core/src/backend/conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { SAMPLE_CONFIG_INPUT } from '../config/sample.ts';
import type { ConversationMessage } from '../domain/types.ts';
import { SAYINGS } from './status.ts';
import { CLONE_TOOL } from '../clone.ts';
import { PUBLISH_TOOL } from '../publish.ts';
import { documentTools } from '../agents/document.ts';
import { attachmentSection, closeSuspended, createConversationHandler, WARM_DOCUMENTS, warmChatPrompts, formatBytes, historyOf, issueSummaryOf, parseSse, requestIdOfIssue, routePath, type ConversationDeps } from './conversation.ts';
import { conversationFromRow, ServiceDb, UserDb } from './db.ts';
Expand Down Expand Up @@ -1502,3 +1503,29 @@ describe('cloning the document from AI Chat', () => {
expect(WARM_DOCUMENTS.filter(document => document?.clone === 1)).toHaveLength(2);
});
});

describe('saving and publishing from AI Chat (backend)', () => {
const anthropicOf = (h: Harness) => h.deps.anthropic as ReturnType<typeof fakeAnthropic>;
const model = { type: 'model', apiVersion: 1, edit: 1, guidance: 1, clone: 1, save: 1, publish: 1 };
const proposal = { id: 'p1', name: PUBLISH_TOOL, input: { title: 'Shaker base cabinet', description: 'A 30 in base cabinet.', category: 'Cabinets and built-ins', license: 'CC BY 4.0', collections: ['Shaker cabinets'] } };

it('adds the save and publishing rules when those tools are offered', async () => {
const h = harness({ service: { fr_conversation_get: conversationRow({ kind: 'help', path: 'help' }) }, turns: [{ text: ['Sure.'] }] });
await (await call(h.handler, 'POST', `/conversations/${CONVERSATION_ID}/messages`, { text: 'publish this', document: model })).text();
const system = (anthropicOf(h).calls[0]!.system as { text: string }[])[0]!.text;
expect(system).toContain('Saving the document:');
expect(system).toContain('Publishing to Commons:');
expect(WARM_DOCUMENTS).toContainEqual(model);
});

it.each([['published', { ok: true, outcome: 'published', title: 'Shaker base cabinet', url: 'https://commons.pushform.io/#models/design-1' }, 0], ['cancel', { ok: true, outcome: 'cancel' }, 0],
['failed', { ok: false, outcome: 'failed', error: 'The upload failed.' }, 1]])('after a card that was %s, calls the model %i more times', async (_label, answer, calls) => {
const first = harness({ service: { fr_conversation_get: conversationRow({ kind: 'help', path: 'help' }) }, turns: [{ text: ['Here is the publication.'], toolUses: [proposal] }] });
await (await call(first.handler, 'POST', `/conversations/${CONVERSATION_ID}/messages`, { text: 'publish this to commons in my shaker cabinet collection', document: model })).text();
const stored = first.service.named('fr_conversation_append')[0]!.params['p_messages'] as ConversationMessage[];
const next = harness({ service: { fr_conversation_get: conversationRow({ kind: 'help', path: 'help', messages: stored, message_count: 1 }) }, turns: [{ text: ['It could not be published.'] }] });
const response = await call(next.handler, 'POST', `/conversations/${CONVERSATION_ID}/tool-results`, { results: [{ toolUseId: 'p1', content: JSON.stringify(answer), isError: !answer.ok }] });
await response.text();
expect(anthropicOf(next).calls).toHaveLength(calls);
});
});
Loading
Loading