From 088ea78a48f39bd41708ebe1ada311a1b4ab4e06 Mon Sep 17 00:00:00 2001 From: oskarcode Date: Fri, 13 Mar 2026 15:52:11 -0400 Subject: [PATCH 1/2] feat(docs): add advanced document tools (insertImage, insertTable, createHeaderFooter, addComment) --- workspace-server/src/index.ts | 89 ++++++ workspace-server/src/services/DocsService.ts | 318 ++++++++++++++++++- 2 files changed, 406 insertions(+), 1 deletion(-) diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index 674d678..5dd2b86 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -312,6 +312,95 @@ async function main() { docsService.replaceText, ); + server.registerTool( + 'docs.insertImage', + { + description: + 'Inserts an image into a Google Doc at a specified position or at the end of the document.', + inputSchema: { + documentId: z.string().describe('The ID of the document to modify.'), + imageUrl: z + .string() + .describe('The URL of the image to insert. Must be publicly accessible.'), + positionIndex: z + .number() + .optional() + .describe( + 'The index position to insert the image. If not provided, inserts at the end.', + ), + tabId: z + .string() + .optional() + .describe('The ID of the tab to modify. If not provided, modifies the first tab.'), + widthPt: z + .number() + .optional() + .describe('The width of the image in points (pt).'), + heightPt: z + .number() + .optional() + .describe('The height of the image in points (pt).'), + }, + }, + docsService.insertImage, + ); + + server.registerTool( + 'docs.insertTable', + { + description: + 'Inserts a table into a Google Doc at a specified position or at the end of the document.', + inputSchema: { + documentId: z.string().describe('The ID of the document to modify.'), + rows: z.number().min(1).describe('The number of rows in the table.'), + columns: z.number().min(1).describe('The number of columns in the table.'), + tabId: z + .string() + .optional() + .describe('The ID of the tab to modify. If not provided, modifies the first tab.'), + positionIndex: z + .number() + .optional() + .describe( + 'The index position to insert the table. If not provided, inserts at the end.', + ), + }, + }, + docsService.insertTable, + ); + + server.registerTool( + 'docs.createHeaderFooter', + { + description: + 'Creates a header or footer in a Google Doc with optional initial text.', + inputSchema: { + documentId: z.string().describe('The ID of the document to modify.'), + type: z + .enum(['header', 'footer']) + .describe('The type of element to create: "header" or "footer".'), + text: z + .string() + .optional() + .describe('Optional text to insert into the header or footer.'), + }, + }, + docsService.createHeaderFooter, + ); + + server.registerTool( + 'docs.addComment', + { + description: + 'Adds a comment to a Google Doc. The comment will appear in the document\'s comment thread.', + inputSchema: { + documentId: z.string().describe('The ID of the document to comment on.'), + content: z.string().describe('The text content of the comment.'), + }, + }, + docsService.addComment, + ); + server.registerTool( 'docs.formatText', { diff --git a/workspace-server/src/services/DocsService.ts b/workspace-server/src/services/DocsService.ts index ea55591..6f68d27 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { google, docs_v1 } from 'googleapis'; +import { google, docs_v1, drive_v3 } from 'googleapis'; import { AuthManager } from '../auth/AuthManager'; import { logToFile } from '../utils/logger'; import { extractDocId } from '../utils/IdUtils'; @@ -65,6 +65,12 @@ export class DocsService { return google.docs({ version: 'v1', ...options }); } + private async getDriveClient(): Promise { + const auth = await this.authManager.getAuthenticatedClient(); + const options = { ...gaxiosOptions, auth }; + return google.drive({ version: 'v3', ...options }); + } + public getSuggestions = async ({ documentId }: { documentId: string }) => { logToFile( `[DocsService] Starting getSuggestions for document: ${documentId}`, @@ -649,6 +655,316 @@ export class DocsService { } }; + public insertImage = async ({ + documentId, + imageUrl, + positionIndex, + tabId, + widthPt, + heightPt, + }: { + documentId: string; + imageUrl: string; + positionIndex?: number; + tabId?: string; + widthPt?: number; + heightPt?: number; + }) => { + logToFile( + `[DocsService] Starting insertImage for document: ${documentId}, tabId: ${tabId}`, + ); + try { + const id = extractDocId(documentId) || documentId; + const docs = await this.getDocsClient(); + + let insertIndex = positionIndex; + if (!insertIndex) { + const res = await docs.documents.get({ + documentId: id, + fields: 'tabs', + includeTabsContent: true, + }); + const tabs = res.data.tabs || []; + let content: docs_v1.Schema$StructuralElement[] | undefined; + if (tabId) { + const tab = tabs.find((t) => t.tabProperties?.tabId === tabId); + if (!tab) { + throw new Error(`Tab with ID ${tabId} not found.`); + } + content = tab.documentTab?.body?.content; + } else if (tabs.length > 0) { + content = tabs[0].documentTab?.body?.content; + } + const lastElement = content?.[content.length - 1]; + const endIndex = lastElement?.endIndex || 2; + insertIndex = Math.max(1, endIndex - 1); + } + + const imageRequest: docs_v1.Schema$Request = { + insertInlineImage: { + uri: imageUrl, + location: { + index: insertIndex, + tabId, + }, + }, + }; + + if (widthPt || heightPt) { + imageRequest.insertInlineImage!.objectSize = { + width: widthPt + ? { magnitude: widthPt, unit: 'PT' } + : { magnitude: 300, unit: 'PT' }, + height: heightPt + ? { magnitude: heightPt, unit: 'PT' } + : { magnitude: 200, unit: 'PT' }, + }; + } + + const update = await docs.documents.batchUpdate({ + documentId: id, + requestBody: { + requests: [imageRequest], + }, + }); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + documentId: update.data.documentId, + insertedAt: insertIndex, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[DocsService] Error during docs.insertImage: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public insertTable = async ({ + documentId, + rows, + columns, + tabId, + positionIndex, + }: { + documentId: string; + rows: number; + columns: number; + tabId?: string; + positionIndex?: number; + }) => { + logToFile( + `[DocsService] Starting insertTable for document: ${documentId}, tabId: ${tabId}`, + ); + try { + const id = extractDocId(documentId) || documentId; + const docs = await this.getDocsClient(); + + let insertIndex = positionIndex; + if (!insertIndex) { + const res = await docs.documents.get({ + documentId: id, + fields: 'tabs', + includeTabsContent: true, + }); + const tabs = res.data.tabs || []; + let content: docs_v1.Schema$StructuralElement[] | undefined; + if (tabId) { + const tab = tabs.find((t) => t.tabProperties?.tabId === tabId); + if (!tab) { + throw new Error(`Tab with ID ${tabId} not found.`); + } + content = tab.documentTab?.body?.content; + } else if (tabs.length > 0) { + content = tabs[0].documentTab?.body?.content; + } + const lastElement = content?.[content.length - 1]; + const endIndex = lastElement?.endIndex || 2; + insertIndex = Math.max(1, endIndex - 1); + } + + const update = await docs.documents.batchUpdate({ + documentId: id, + requestBody: { + requests: [ + { + insertTable: { + rows, + columns, + location: { + index: insertIndex, + tabId, + }, + }, + }, + ], + }, + }); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + documentId: update.data.documentId, + rows, + columns, + insertedAt: insertIndex, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[DocsService] Error during docs.insertTable: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public createHeaderFooter = async ({ + documentId, + type, + text, + }: { + documentId: string; + type: 'header' | 'footer'; + text?: string; + }) => { + logToFile( + `[DocsService] Starting createHeaderFooter for document: ${documentId}, type: ${type}`, + ); + try { + const id = extractDocId(documentId) || documentId; + const docs = await this.getDocsClient(); + + const createRequest: docs_v1.Schema$Request = + type === 'header' + ? ({ createHeader: { type: 'DEFAULT' } } as docs_v1.Schema$Request) + : ({ createFooter: { type: 'DEFAULT' } } as docs_v1.Schema$Request); + + const createResult = await docs.documents.batchUpdate({ + documentId: id, + requestBody: { + requests: [createRequest], + }, + }); + + const segmentId = + type === 'header' + ? createResult.data.replies?.[0]?.createHeader?.headerId + : createResult.data.replies?.[0]?.createFooter?.footerId; + + if (text && segmentId) { + await docs.documents.batchUpdate({ + documentId: id, + requestBody: { + requests: [ + { + insertText: { + endOfSegmentLocation: { + segmentId, + }, + text, + }, + }, + ], + }, + }); + } + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + documentId: id, + type, + segmentId, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[DocsService] Error during docs.createHeaderFooter: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public addComment = async ({ + documentId, + content, + }: { + documentId: string; + content: string; + }) => { + logToFile(`[DocsService] Starting addComment for document: ${documentId}`); + try { + const id = extractDocId(documentId) || documentId; + const drive = await this.getDriveClient(); + const res = await drive.comments.create({ + fileId: id, + requestBody: { + content, + }, + fields: 'id,content,createdTime,author', + }); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(res.data), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[DocsService] Error during docs.addComment: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + private _readStructuralElement( element: docs_v1.Schema$StructuralElement, ): string { From 8677abd043f865129a9c3c0cd33c6b47a38bd11d Mon Sep 17 00:00:00 2001 From: oskarcode Date: Fri, 13 Mar 2026 17:02:32 -0400 Subject: [PATCH 2/2] fix: preserve aspect ratio for images, use consistent endIndex default --- workspace-server/src/services/DocsService.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/workspace-server/src/services/DocsService.ts b/workspace-server/src/services/DocsService.ts index 6f68d27..0a26232 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -696,7 +696,7 @@ export class DocsService { content = tabs[0].documentTab?.body?.content; } const lastElement = content?.[content.length - 1]; - const endIndex = lastElement?.endIndex || 2; + const endIndex = lastElement?.endIndex || 1; insertIndex = Math.max(1, endIndex - 1); } @@ -711,14 +711,14 @@ export class DocsService { }; if (widthPt || heightPt) { - imageRequest.insertInlineImage!.objectSize = { - width: widthPt - ? { magnitude: widthPt, unit: 'PT' } - : { magnitude: 300, unit: 'PT' }, - height: heightPt - ? { magnitude: heightPt, unit: 'PT' } - : { magnitude: 200, unit: 'PT' }, - }; + const objectSize: docs_v1.Schema$Size = {}; + if (widthPt) { + objectSize.width = { magnitude: widthPt, unit: 'PT' }; + } + if (heightPt) { + objectSize.height = { magnitude: heightPt, unit: 'PT' }; + } + imageRequest.insertInlineImage!.objectSize = objectSize; } const update = await docs.documents.batchUpdate({ @@ -793,7 +793,7 @@ export class DocsService { content = tabs[0].documentTab?.body?.content; } const lastElement = content?.[content.length - 1]; - const endIndex = lastElement?.endIndex || 2; + const endIndex = lastElement?.endIndex || 1; insertIndex = Math.max(1, endIndex - 1); }