chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,124 @@
import type {
GoogleDocsCreateNamedRangeResponse,
GoogleDocsToolParams,
} from '@/tools/google_docs/types'
import {
buildBatchUpdateMetadata,
buildContentRange,
resolveDocumentId,
} from '@/tools/google_docs/utils'
import type { ToolConfig } from '@/tools/types'
export const createNamedRangeTool: ToolConfig<
GoogleDocsToolParams,
GoogleDocsCreateNamedRangeResponse
> = {
id: 'google_docs_create_named_range',
name: 'Create Named Range in Google Docs Document',
description:
'Create a named range over a span of content in a Google Docs document so it can be referenced or deleted later. The name may be 1-256 characters and need not be unique.',
version: '1.0',
oauth: {
required: true,
provider: 'google-docs',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Docs API',
},
documentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the document to update',
},
name: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The name of the range to create (1-256 characters)',
},
startIndex: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description:
'The start character index (the document body starts at index 1) of the range (inclusive)',
},
endIndex: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The end character index of the range (exclusive)',
},
},
request: {
url: (params) => {
const documentId = resolveDocumentId(params)
return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
const name = params.name ? String(params.name).trim() : ''
if (!name) {
throw new Error('name is required')
}
if (name.length > 256) {
throw new Error('name must be 256 characters or fewer')
}
const range = buildContentRange(params.startIndex, params.endIndex)
return {
requests: [
{
createNamedRange: { name, range },
},
],
}
},
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
const data = responseText.trim() ? JSON.parse(responseText) : {}
const metadata = buildBatchUpdateMetadata(data, response.url)
const namedRangeId = data.replies?.[0]?.createNamedRange?.namedRangeId ?? null
return {
success: true,
output: {
namedRangeId,
metadata,
},
}
},
outputs: {
namedRangeId: {
type: 'string',
description: 'The ID of the created named range',
optional: true,
},
metadata: {
type: 'json',
description: 'Updated document metadata including ID, title, and URL',
properties: {
documentId: { type: 'string', description: 'Google Docs document ID' },
title: { type: 'string', description: 'Document title' },
mimeType: { type: 'string', description: 'Document MIME type' },
url: { type: 'string', description: 'Document URL' },
},
},
},
}
@@ -0,0 +1,144 @@
import type {
GoogleDocsCreateParagraphBulletsResponse,
GoogleDocsToolParams,
} from '@/tools/google_docs/types'
import {
buildBatchUpdateMetadata,
buildContentRange,
resolveDocumentId,
} from '@/tools/google_docs/utils'
import type { ToolConfig } from '@/tools/types'
const BULLET_PRESETS = new Set([
'BULLET_DISC_CIRCLE_SQUARE',
'BULLET_DIAMONDX_ARROW3D_SQUARE',
'BULLET_CHECKBOX',
'BULLET_ARROW_DIAMOND_DISC',
'BULLET_STAR_CIRCLE_SQUARE',
'BULLET_ARROW3D_CIRCLE_SQUARE',
'NUMBERED_DECIMAL_ALPHA_ROMAN',
'NUMBERED_DECIMAL_ALPHA_ROMAN_PARENS',
'NUMBERED_DECIMAL_NESTED',
'NUMBERED_UPPERALPHA_ALPHA_ROMAN',
'NUMBERED_UPPERROMAN_UPPERALPHA_DECIMAL',
'NUMBERED_ZERODECIMAL_ALPHA_ROMAN',
])
const DEFAULT_BULLET_PRESET = 'BULLET_DISC_CIRCLE_SQUARE'
export const createParagraphBulletsTool: ToolConfig<
GoogleDocsToolParams,
GoogleDocsCreateParagraphBulletsResponse
> = {
id: 'google_docs_create_paragraph_bullets',
name: 'Create Paragraph Bullets in Google Docs Document',
description:
'Add bulleted or numbered list formatting to the paragraphs overlapping a range of text in a Google Docs document, using a chosen bullet glyph preset.',
version: '1.0',
oauth: {
required: true,
provider: 'google-docs',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Docs API',
},
documentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the document to update',
},
startIndex: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description:
'The start character index (the document body starts at index 1) of the range to bullet (inclusive)',
},
endIndex: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The end character index of the range to bullet (exclusive)',
},
bulletPreset: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The bullet glyph preset to apply. Defaults to BULLET_DISC_CIRCLE_SQUARE. Examples: BULLET_DISC_CIRCLE_SQUARE, BULLET_CHECKBOX, NUMBERED_DECIMAL_ALPHA_ROMAN, NUMBERED_DECIMAL_NESTED.',
},
},
request: {
url: (params) => {
const documentId = resolveDocumentId(params)
return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
const range = buildContentRange(params.startIndex, params.endIndex)
let bulletPreset = DEFAULT_BULLET_PRESET
if (params.bulletPreset != null && String(params.bulletPreset).trim() !== '') {
bulletPreset = String(params.bulletPreset).trim().toUpperCase()
if (!BULLET_PRESETS.has(bulletPreset)) {
throw new Error(
'bulletPreset must be a valid BulletGlyphPreset (e.g. BULLET_DISC_CIRCLE_SQUARE, BULLET_CHECKBOX, NUMBERED_DECIMAL_ALPHA_ROMAN)'
)
}
}
return {
requests: [
{
createParagraphBullets: { range, bulletPreset },
},
],
}
},
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
const data = responseText.trim() ? JSON.parse(responseText) : {}
const metadata = buildBatchUpdateMetadata(data, response.url)
return {
success: true,
output: {
updatedContent: true,
metadata,
},
}
},
outputs: {
updatedContent: {
type: 'boolean',
description: 'Indicates if the bullets were applied successfully',
},
metadata: {
type: 'json',
description: 'Updated document metadata including ID, title, and URL',
properties: {
documentId: { type: 'string', description: 'Google Docs document ID' },
title: { type: 'string', description: 'Document title' },
mimeType: { type: 'string', description: 'Document MIME type' },
url: { type: 'string', description: 'Document URL' },
},
},
},
}
+225
View File
@@ -0,0 +1,225 @@
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import type { GoogleDocsCreateResponse, GoogleDocsToolParams } from '@/tools/google_docs/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('GoogleDocsCreateTool')
const DOC_MIME_TYPE = 'application/vnd.google-apps.document'
/**
* Build a multipart/related body for Drive's files.create upload endpoint.
* Used when converting Markdown to a Google Doc in a single round-trip.
* See: https://developers.google.com/workspace/drive/api/guides/manage-uploads
*/
function buildMarkdownMultipartBody(
metadata: Record<string, unknown>,
markdownContent: string,
boundary: string
): string {
return (
`--${boundary}\r\n` +
`Content-Type: application/json; charset=UTF-8\r\n\r\n` +
`${JSON.stringify(metadata)}\r\n` +
`--${boundary}\r\n` +
`Content-Type: text/markdown\r\n\r\n` +
`${markdownContent}\r\n` +
`--${boundary}--`
)
}
function shouldUseMarkdownUpload(params: GoogleDocsToolParams): boolean {
return Boolean(params.markdown && params.content)
}
export const createTool: ToolConfig<GoogleDocsToolParams, GoogleDocsCreateResponse> = {
id: 'google_docs_create',
name: 'Create Google Docs Document',
description: 'Create a new Google Docs document',
version: '1.0',
oauth: {
required: true,
provider: 'google-docs',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Docs API',
},
title: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The title of the document to create',
},
content: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'The content of the document to create',
},
folderSelector: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Google Drive folder ID to create the document in (e.g., 1ABCxyz...)',
},
folderId: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'The ID of the folder to create the document in (internal use)',
},
markdown: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description:
'When true, content is interpreted as Markdown and converted to formatted Google Docs content (headings, bold/italic, lists, tables, links, code blocks, blockquotes). Default: false (content inserted as plain text).',
},
},
request: {
url: (params) => {
return shouldUseMarkdownUpload(params)
? 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true'
: 'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true'
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
if (shouldUseMarkdownUpload(params)) {
const boundary = `sim_gdocs_md_${generateShortId(24)}`
// Stash on params so body() uses the matching boundary string
;(params as GoogleDocsToolParams & { _boundary?: string })._boundary = boundary
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': `multipart/related; boundary=${boundary}`,
}
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
if (!params.title) {
throw new Error('Title is required')
}
const folderId = params.folderSelector || params.folderId
const metadata: Record<string, unknown> = {
name: params.title,
mimeType: DOC_MIME_TYPE,
}
if (folderId) {
metadata.parents = [folderId]
}
if (shouldUseMarkdownUpload(params)) {
const boundary = (params as GoogleDocsToolParams & { _boundary?: string })._boundary
if (!boundary) {
// headers() runs before body() in formatRequestParams and stashes the boundary
// on the same params reference. Missing _boundary means that contract was broken,
// which would silently produce a Content-Type / body boundary mismatch (HTTP 400).
// Throw loudly instead of fabricating a mismatched boundary.
throw new Error(
'Multipart boundary missing on params — headers() must run before body() for markdown upload'
)
}
return buildMarkdownMultipartBody(metadata, params.content ?? '', boundary)
}
return metadata
},
},
postProcess: async (result, params, executeTool) => {
if (!result.success) {
return result
}
const documentId = result.output.metadata.documentId
// When the markdown upload path ran, content was already inserted via Drive's
// text/markdown import conversion during files.create — no follow-up write needed.
if (shouldUseMarkdownUpload(params)) {
return result
}
if (params.content && documentId) {
try {
const writeParams = {
accessToken: params.accessToken,
documentId: documentId,
content: params.content,
}
const writeResult = await executeTool('google_docs_write', writeParams)
if (!writeResult.success) {
logger.warn(
'Failed to add content to document, but document was created:',
writeResult.error
)
}
} catch (error) {
logger.warn('Error adding content to document:', { error })
// Don't fail the overall operation if adding content fails
}
}
return result
},
transformResponse: async (response: Response) => {
try {
// Get the response data
const responseText = await response.text()
const data = JSON.parse(responseText)
const documentId = data.id
const title = data.name
const metadata = {
documentId,
title: title || 'Untitled Document',
mimeType: DOC_MIME_TYPE,
url: `https://docs.google.com/document/d/${documentId}/edit`,
}
return {
success: true,
output: {
metadata,
},
}
} catch (error) {
logger.error('Google Docs create - Error processing response:', {
error,
})
throw error
}
},
outputs: {
metadata: {
type: 'json',
description: 'Created document metadata including ID, title, and URL',
properties: {
documentId: { type: 'string', description: 'Google Docs document ID' },
title: { type: 'string', description: 'Document title' },
mimeType: { type: 'string', description: 'Document MIME type' },
url: { type: 'string', description: 'Document URL' },
},
},
},
}
@@ -0,0 +1,109 @@
import type {
GoogleDocsDeleteContentRangeResponse,
GoogleDocsToolParams,
} from '@/tools/google_docs/types'
import {
buildBatchUpdateMetadata,
buildContentRange,
resolveDocumentId,
} from '@/tools/google_docs/utils'
import type { ToolConfig } from '@/tools/types'
export const deleteContentRangeTool: ToolConfig<
GoogleDocsToolParams,
GoogleDocsDeleteContentRangeResponse
> = {
id: 'google_docs_delete_content_range',
name: 'Delete Content Range in Google Docs Document',
description:
'Delete all content between a start and end character index in a Google Docs document. The endIndex is exclusive and must be greater than the startIndex.',
version: '1.0',
oauth: {
required: true,
provider: 'google-docs',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Docs API',
},
documentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the document to delete content from',
},
startIndex: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description:
'The start character index (the document body starts at index 1) of the range to delete (inclusive)',
},
endIndex: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The end character index of the range to delete (exclusive)',
},
},
request: {
url: (params) => {
const documentId = resolveDocumentId(params)
return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
const range = buildContentRange(params.startIndex, params.endIndex)
return {
requests: [
{
deleteContentRange: { range },
},
],
}
},
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
const data = responseText.trim() ? JSON.parse(responseText) : {}
const metadata = buildBatchUpdateMetadata(data, response.url)
return {
success: true,
output: {
updatedContent: true,
metadata,
},
}
},
outputs: {
updatedContent: {
type: 'boolean',
description: 'Indicates if the content range was deleted successfully',
},
metadata: {
type: 'json',
description: 'Updated document metadata including ID, title, and URL',
properties: {
documentId: { type: 'string', description: 'Google Docs document ID' },
title: { type: 'string', description: 'Document title' },
mimeType: { type: 'string', description: 'Document MIME type' },
url: { type: 'string', description: 'Document URL' },
},
},
},
}
@@ -0,0 +1,110 @@
import type {
GoogleDocsDeleteNamedRangeResponse,
GoogleDocsToolParams,
} from '@/tools/google_docs/types'
import { buildBatchUpdateMetadata, resolveDocumentId } from '@/tools/google_docs/utils'
import type { ToolConfig } from '@/tools/types'
export const deleteNamedRangeTool: ToolConfig<
GoogleDocsToolParams,
GoogleDocsDeleteNamedRangeResponse
> = {
id: 'google_docs_delete_named_range',
name: 'Delete Named Range in Google Docs Document',
description:
'Delete one or more named ranges from a Google Docs document by their ID or by name. Provide exactly one of namedRangeId or name; deleting by name removes all ranges sharing that name. The content itself is not removed.',
version: '1.0',
oauth: {
required: true,
provider: 'google-docs',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Docs API',
},
documentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the document to update',
},
namedRangeId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The ID of the named range to delete. Provide exactly one of namedRangeId or namedRangeName.',
},
namedRangeName: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The name of the named range(s) to delete. All ranges sharing this name are removed. Provide exactly one of namedRangeId or namedRangeName.',
},
},
request: {
url: (params) => {
const documentId = resolveDocumentId(params)
return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
const namedRangeId = params.namedRangeId ? String(params.namedRangeId).trim() : ''
const name = params.namedRangeName ? String(params.namedRangeName).trim() : ''
if (!namedRangeId && !name) {
throw new Error('Either namedRangeId or namedRangeName is required')
}
if (namedRangeId && name) {
throw new Error('Provide exactly one of namedRangeId or namedRangeName, not both')
}
const deleteNamedRange = namedRangeId ? { namedRangeId } : { name }
return {
requests: [{ deleteNamedRange }],
}
},
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
const data = responseText.trim() ? JSON.parse(responseText) : {}
const metadata = buildBatchUpdateMetadata(data, response.url)
return {
success: true,
output: {
updatedContent: true,
metadata,
},
}
},
outputs: {
updatedContent: {
type: 'boolean',
description: 'Indicates if the named range(s) were deleted successfully',
},
metadata: {
type: 'json',
description: 'Updated document metadata including ID, title, and URL',
properties: {
documentId: { type: 'string', description: 'Google Docs document ID' },
title: { type: 'string', description: 'Document title' },
mimeType: { type: 'string', description: 'Document MIME type' },
url: { type: 'string', description: 'Document URL' },
},
},
},
}
@@ -0,0 +1,109 @@
import type {
GoogleDocsDeleteParagraphBulletsResponse,
GoogleDocsToolParams,
} from '@/tools/google_docs/types'
import {
buildBatchUpdateMetadata,
buildContentRange,
resolveDocumentId,
} from '@/tools/google_docs/utils'
import type { ToolConfig } from '@/tools/types'
export const deleteParagraphBulletsTool: ToolConfig<
GoogleDocsToolParams,
GoogleDocsDeleteParagraphBulletsResponse
> = {
id: 'google_docs_delete_paragraph_bullets',
name: 'Delete Paragraph Bullets in Google Docs Document',
description:
'Remove bullet or numbered list formatting from the paragraphs overlapping a range of text in a Google Docs document, identified by its start and end character index.',
version: '1.0',
oauth: {
required: true,
provider: 'google-docs',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Docs API',
},
documentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the document to update',
},
startIndex: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description:
'The start character index (the document body starts at index 1) of the range to clear bullets from (inclusive)',
},
endIndex: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The end character index of the range to clear bullets from (exclusive)',
},
},
request: {
url: (params) => {
const documentId = resolveDocumentId(params)
return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
const range = buildContentRange(params.startIndex, params.endIndex)
return {
requests: [
{
deleteParagraphBullets: { range },
},
],
}
},
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
const data = responseText.trim() ? JSON.parse(responseText) : {}
const metadata = buildBatchUpdateMetadata(data, response.url)
return {
success: true,
output: {
updatedContent: true,
metadata,
},
}
},
outputs: {
updatedContent: {
type: 'boolean',
description: 'Indicates if the bullets were removed successfully',
},
metadata: {
type: 'json',
description: 'Updated document metadata including ID, title, and URL',
properties: {
documentId: { type: 'string', description: 'Google Docs document ID' },
title: { type: 'string', description: 'Document title' },
mimeType: { type: 'string', description: 'Document MIME type' },
url: { type: 'string', description: 'Document URL' },
},
},
},
}
+33
View File
@@ -0,0 +1,33 @@
import { createTool } from '@/tools/google_docs/create'
import { createNamedRangeTool } from '@/tools/google_docs/create-named-range'
import { createParagraphBulletsTool } from '@/tools/google_docs/create-paragraph-bullets'
import { deleteContentRangeTool } from '@/tools/google_docs/delete-content-range'
import { deleteNamedRangeTool } from '@/tools/google_docs/delete-named-range'
import { deleteParagraphBulletsTool } from '@/tools/google_docs/delete-paragraph-bullets'
import { insertImageTool } from '@/tools/google_docs/insert-image'
import { insertPageBreakTool } from '@/tools/google_docs/insert-page-break'
import { insertTableTool } from '@/tools/google_docs/insert-table'
import { insertTextTool } from '@/tools/google_docs/insert-text'
import { readTool } from '@/tools/google_docs/read'
import { replaceTextTool } from '@/tools/google_docs/replace-text'
import { updateParagraphStyleTool } from '@/tools/google_docs/update-paragraph-style'
import { updateTextStyleTool } from '@/tools/google_docs/update-text-style'
import { writeTool } from '@/tools/google_docs/write'
export const googleDocsReadTool = readTool
export const googleDocsWriteTool = writeTool
export const googleDocsCreateTool = createTool
export const googleDocsInsertTextTool = insertTextTool
export const googleDocsReplaceTextTool = replaceTextTool
export const googleDocsInsertTableTool = insertTableTool
export const googleDocsInsertImageTool = insertImageTool
export const googleDocsInsertPageBreakTool = insertPageBreakTool
export const googleDocsUpdateTextStyleTool = updateTextStyleTool
export const googleDocsDeleteContentRangeTool = deleteContentRangeTool
export const googleDocsUpdateParagraphStyleTool = updateParagraphStyleTool
export const googleDocsCreateParagraphBulletsTool = createParagraphBulletsTool
export const googleDocsDeleteParagraphBulletsTool = deleteParagraphBulletsTool
export const googleDocsCreateNamedRangeTool = createNamedRangeTool
export const googleDocsDeleteNamedRangeTool = deleteNamedRangeTool
export * from './types'
+132
View File
@@ -0,0 +1,132 @@
import type { GoogleDocsInsertImageResponse, GoogleDocsToolParams } from '@/tools/google_docs/types'
import {
buildBatchUpdateMetadata,
buildInsertLocation,
resolveDocumentId,
} from '@/tools/google_docs/utils'
import type { ToolConfig } from '@/tools/types'
export const insertImageTool: ToolConfig<GoogleDocsToolParams, GoogleDocsInsertImageResponse> = {
id: 'google_docs_insert_image',
name: 'Insert Image into Google Docs Document',
description:
'Insert an inline image from a public URL into a Google Docs document. The image must be publicly accessible and under 50 MB. When no index is provided, the image is appended to the end of the document.',
version: '1.0',
oauth: {
required: true,
provider: 'google-docs',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Docs API',
},
documentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the document to insert the image into',
},
imageUrl: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The publicly accessible URL of the image to insert',
},
index: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description:
'The character index (the document body starts at index 1) at which to insert the image. When omitted, the image is appended to the end of the document.',
},
width: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Optional image width in points (PT)',
},
height: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Optional image height in points (PT)',
},
},
request: {
url: (params) => {
const documentId = resolveDocumentId(params)
return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
if (!params.imageUrl) {
throw new Error('Image URL is required')
}
const insertInlineImage: Record<string, unknown> = {
...buildInsertLocation(params.index),
uri: params.imageUrl,
}
const objectSize: Record<string, unknown> = {}
if (typeof params.width === 'number' && Number.isFinite(params.width)) {
objectSize.width = { magnitude: params.width, unit: 'PT' }
}
if (typeof params.height === 'number' && Number.isFinite(params.height)) {
objectSize.height = { magnitude: params.height, unit: 'PT' }
}
if (Object.keys(objectSize).length > 0) {
insertInlineImage.objectSize = objectSize
}
return {
requests: [{ insertInlineImage }],
}
},
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
const data = responseText.trim() ? JSON.parse(responseText) : {}
const metadata = buildBatchUpdateMetadata(data, response.url)
const objectId = data.replies?.[0]?.insertInlineImage?.objectId ?? null
return {
success: true,
output: {
objectId,
metadata,
},
}
},
outputs: {
objectId: {
type: 'string',
description: 'The ID of the inserted inline image object',
optional: true,
},
metadata: {
type: 'json',
description: 'Updated document metadata including ID, title, and URL',
properties: {
documentId: { type: 'string', description: 'Google Docs document ID' },
title: { type: 'string', description: 'Document title' },
mimeType: { type: 'string', description: 'Document MIME type' },
url: { type: 'string', description: 'Document URL' },
},
},
},
}
@@ -0,0 +1,104 @@
import type {
GoogleDocsInsertPageBreakResponse,
GoogleDocsToolParams,
} from '@/tools/google_docs/types'
import {
buildBatchUpdateMetadata,
buildInsertLocation,
resolveDocumentId,
} from '@/tools/google_docs/utils'
import type { ToolConfig } from '@/tools/types'
export const insertPageBreakTool: ToolConfig<
GoogleDocsToolParams,
GoogleDocsInsertPageBreakResponse
> = {
id: 'google_docs_insert_page_break',
name: 'Insert Page Break into Google Docs Document',
description:
'Insert a page break into a Google Docs document. When no index is provided, the page break is appended to the end of the document.',
version: '1.0',
oauth: {
required: true,
provider: 'google-docs',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Docs API',
},
documentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the document to insert the page break into',
},
index: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description:
'The character index (the document body starts at index 1) at which to insert the page break. When omitted, the page break is appended to the end of the document.',
},
},
request: {
url: (params) => {
const documentId = resolveDocumentId(params)
return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
return {
requests: [
{
insertPageBreak: {
...buildInsertLocation(params.index),
},
},
],
}
},
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
const data = responseText.trim() ? JSON.parse(responseText) : {}
const metadata = buildBatchUpdateMetadata(data, response.url)
return {
success: true,
output: {
updatedContent: true,
metadata,
},
}
},
outputs: {
updatedContent: {
type: 'boolean',
description: 'Indicates if the page break was inserted successfully',
},
metadata: {
type: 'json',
description: 'Updated document metadata including ID, title, and URL',
properties: {
documentId: { type: 'string', description: 'Google Docs document ID' },
title: { type: 'string', description: 'Document title' },
mimeType: { type: 'string', description: 'Document MIME type' },
url: { type: 'string', description: 'Document URL' },
},
},
},
}
+120
View File
@@ -0,0 +1,120 @@
import type { GoogleDocsInsertTableResponse, GoogleDocsToolParams } from '@/tools/google_docs/types'
import {
buildBatchUpdateMetadata,
buildInsertLocation,
resolveDocumentId,
} from '@/tools/google_docs/utils'
import type { ToolConfig } from '@/tools/types'
export const insertTableTool: ToolConfig<GoogleDocsToolParams, GoogleDocsInsertTableResponse> = {
id: 'google_docs_insert_table',
name: 'Insert Table into Google Docs Document',
description:
'Insert an empty table with the given number of rows and columns into a Google Docs document. When no index is provided, the table is appended to the end of the document.',
version: '1.0',
oauth: {
required: true,
provider: 'google-docs',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Docs API',
},
documentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the document to insert the table into',
},
rows: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The number of rows in the table',
},
columns: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The number of columns in the table',
},
index: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description:
'The character index (the document body starts at index 1) at which to insert the table. When omitted, the table is appended to the end of the document.',
},
},
request: {
url: (params) => {
const documentId = resolveDocumentId(params)
return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
const rows = Number(params.rows)
const columns = Number(params.columns)
if (!Number.isFinite(rows) || rows < 1) {
throw new Error('Rows must be a positive number')
}
if (!Number.isFinite(columns) || columns < 1) {
throw new Error('Columns must be a positive number')
}
return {
requests: [
{
insertTable: {
...buildInsertLocation(params.index),
rows,
columns,
},
},
],
}
},
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
const data = responseText.trim() ? JSON.parse(responseText) : {}
const metadata = buildBatchUpdateMetadata(data, response.url)
return {
success: true,
output: {
updatedContent: true,
metadata,
},
}
},
outputs: {
updatedContent: {
type: 'boolean',
description: 'Indicates if the table was inserted successfully',
},
metadata: {
type: 'json',
description: 'Updated document metadata including ID, title, and URL',
properties: {
documentId: { type: 'string', description: 'Google Docs document ID' },
title: { type: 'string', description: 'Document title' },
mimeType: { type: 'string', description: 'Document MIME type' },
url: { type: 'string', description: 'Document URL' },
},
},
},
}
+108
View File
@@ -0,0 +1,108 @@
import type { GoogleDocsInsertTextResponse, GoogleDocsToolParams } from '@/tools/google_docs/types'
import {
buildBatchUpdateMetadata,
buildInsertLocation,
resolveDocumentId,
} from '@/tools/google_docs/utils'
import type { ToolConfig } from '@/tools/types'
export const insertTextTool: ToolConfig<GoogleDocsToolParams, GoogleDocsInsertTextResponse> = {
id: 'google_docs_insert_text',
name: 'Insert Text into Google Docs Document',
description:
'Insert text at a specific index in a Google Docs document. When no index is provided, text is appended to the end of the document. Text is inserted literally; Markdown is not interpreted.',
version: '1.0',
oauth: {
required: true,
provider: 'google-docs',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Docs API',
},
documentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the document to insert text into',
},
text: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The text to insert',
},
index: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description:
'The character index (the document body starts at index 1) at which to insert the text. When omitted, text is appended to the end of the document.',
},
},
request: {
url: (params) => {
const documentId = resolveDocumentId(params)
return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
if (!params.text) {
throw new Error('Text is required')
}
return {
requests: [
{
insertText: {
...buildInsertLocation(params.index),
text: params.text,
},
},
],
}
},
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
const data = responseText.trim() ? JSON.parse(responseText) : {}
const metadata = buildBatchUpdateMetadata(data, response.url)
return {
success: true,
output: {
updatedContent: true,
metadata,
},
}
},
outputs: {
updatedContent: {
type: 'boolean',
description: 'Indicates if text was inserted successfully',
},
metadata: {
type: 'json',
description: 'Updated document metadata including ID, title, and URL',
properties: {
documentId: { type: 'string', description: 'Google Docs document ID' },
title: { type: 'string', description: 'Document title' },
mimeType: { type: 'string', description: 'Document MIME type' },
url: { type: 'string', description: 'Document URL' },
},
},
},
}
+93
View File
@@ -0,0 +1,93 @@
import type { GoogleDocsReadResponse, GoogleDocsToolParams } from '@/tools/google_docs/types'
import { extractTextFromDocument } from '@/tools/google_docs/utils'
import type { ToolConfig } from '@/tools/types'
export const readTool: ToolConfig<GoogleDocsToolParams, GoogleDocsReadResponse> = {
id: 'google_docs_read',
name: 'Read Google Docs Document',
description: 'Read content from a Google Docs document',
version: '1.0',
oauth: {
required: true,
provider: 'google-docs',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Docs API',
},
documentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Google Docs document ID',
},
},
request: {
url: (params) => {
// Ensure documentId is valid
const documentId = params.documentId?.trim() || params.manualDocumentId?.trim()
if (!documentId) {
throw new Error('Document ID is required')
}
return `https://docs.googleapis.com/v1/documents/${documentId}`
},
method: 'GET',
headers: (params) => {
// Validate access token
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
}
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
// Extract document content from the response
let content = ''
if (data.body?.content) {
content = extractTextFromDocument(data)
}
// Create document metadata
const metadata = {
documentId: data.documentId,
title: data.title || 'Untitled Document',
mimeType: 'application/vnd.google-apps.document',
url: `https://docs.google.com/document/d/${data.documentId}/edit`,
}
return {
success: true,
output: {
content,
metadata,
},
}
},
outputs: {
content: { type: 'string', description: 'Extracted document text content' },
metadata: {
type: 'json',
description: 'Document metadata including ID, title, and URL',
properties: {
documentId: { type: 'string', description: 'Google Docs document ID' },
title: { type: 'string', description: 'Document title' },
mimeType: { type: 'string', description: 'Document MIME type' },
url: { type: 'string', description: 'Document URL' },
},
},
},
}
+117
View File
@@ -0,0 +1,117 @@
import type { GoogleDocsReplaceTextResponse, GoogleDocsToolParams } from '@/tools/google_docs/types'
import {
buildBatchUpdateMetadata,
parseOptionalBoolean,
resolveDocumentId,
} from '@/tools/google_docs/utils'
import type { ToolConfig } from '@/tools/types'
export const replaceTextTool: ToolConfig<GoogleDocsToolParams, GoogleDocsReplaceTextResponse> = {
id: 'google_docs_replace_text',
name: 'Find and Replace Text in Google Docs Document',
description:
'Replace all occurrences of a search string with new text across a Google Docs document.',
version: '1.0',
oauth: {
required: true,
provider: 'google-docs',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Docs API',
},
documentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the document to update',
},
searchText: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The text to find',
},
replaceText: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'The text to replace matches with. Use an empty string to delete matches.',
},
matchCase: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether the search should be case sensitive. Defaults to false.',
},
},
request: {
url: (params) => {
const documentId = resolveDocumentId(params)
return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
if (!params.searchText) {
throw new Error('Search text is required')
}
return {
requests: [
{
replaceAllText: {
containsText: {
text: params.searchText,
matchCase: parseOptionalBoolean(params.matchCase) ?? false,
},
replaceText: params.replaceText ?? '',
},
},
],
}
},
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
const data = responseText.trim() ? JSON.parse(responseText) : {}
const metadata = buildBatchUpdateMetadata(data, response.url)
const occurrencesChanged = Number(data.replies?.[0]?.replaceAllText?.occurrencesChanged ?? 0)
return {
success: true,
output: {
occurrencesChanged,
metadata,
},
}
},
outputs: {
occurrencesChanged: {
type: 'number',
description: 'The number of occurrences that were replaced',
},
metadata: {
type: 'json',
description: 'Updated document metadata including ID, title, and URL',
properties: {
documentId: { type: 'string', description: 'Google Docs document ID' },
title: { type: 'string', description: 'Document title' },
mimeType: { type: 'string', description: 'Document MIME type' },
url: { type: 'string', description: 'Document URL' },
},
},
},
}
+164
View File
@@ -0,0 +1,164 @@
import type { ToolResponse } from '@/tools/types'
interface GoogleDocsMetadata {
documentId: string
title: string
mimeType?: string
createdTime?: string
modifiedTime?: string
url?: string
}
export interface GoogleDocsReadResponse extends ToolResponse {
output: {
content: string
metadata: GoogleDocsMetadata
}
}
export interface GoogleDocsWriteResponse extends ToolResponse {
output: {
updatedContent: boolean
metadata: GoogleDocsMetadata
}
}
export interface GoogleDocsCreateResponse extends ToolResponse {
output: {
metadata: GoogleDocsMetadata
}
}
export interface GoogleDocsInsertTextResponse extends ToolResponse {
output: {
updatedContent: boolean
metadata: GoogleDocsMetadata
}
}
export interface GoogleDocsReplaceTextResponse extends ToolResponse {
output: {
occurrencesChanged: number
metadata: GoogleDocsMetadata
}
}
export interface GoogleDocsInsertTableResponse extends ToolResponse {
output: {
updatedContent: boolean
metadata: GoogleDocsMetadata
}
}
export interface GoogleDocsInsertImageResponse extends ToolResponse {
output: {
objectId: string | null
metadata: GoogleDocsMetadata
}
}
export interface GoogleDocsInsertPageBreakResponse extends ToolResponse {
output: {
updatedContent: boolean
metadata: GoogleDocsMetadata
}
}
export interface GoogleDocsUpdateTextStyleResponse extends ToolResponse {
output: {
updatedContent: boolean
metadata: GoogleDocsMetadata
}
}
export interface GoogleDocsDeleteContentRangeResponse extends ToolResponse {
output: {
updatedContent: boolean
metadata: GoogleDocsMetadata
}
}
export interface GoogleDocsUpdateParagraphStyleResponse extends ToolResponse {
output: {
updatedContent: boolean
metadata: GoogleDocsMetadata
}
}
export interface GoogleDocsCreateParagraphBulletsResponse extends ToolResponse {
output: {
updatedContent: boolean
metadata: GoogleDocsMetadata
}
}
export interface GoogleDocsDeleteParagraphBulletsResponse extends ToolResponse {
output: {
updatedContent: boolean
metadata: GoogleDocsMetadata
}
}
export interface GoogleDocsCreateNamedRangeResponse extends ToolResponse {
output: {
namedRangeId: string | null
metadata: GoogleDocsMetadata
}
}
export interface GoogleDocsDeleteNamedRangeResponse extends ToolResponse {
output: {
updatedContent: boolean
metadata: GoogleDocsMetadata
}
}
export interface GoogleDocsToolParams {
accessToken: string
documentId?: string
manualDocumentId?: string
title?: string
content?: string
folderId?: string
folderSelector?: string
markdown?: boolean
text?: string
index?: number
searchText?: string
replaceText?: string
matchCase?: boolean
rows?: number
columns?: number
imageUrl?: string
width?: number
height?: number
startIndex?: number
endIndex?: number
bold?: boolean
italic?: boolean
underline?: boolean
fontSize?: number
namedStyleType?: string
alignment?: string
bulletPreset?: string
name?: string
namedRangeId?: string
namedRangeName?: string
}
export type GoogleDocsResponse =
| GoogleDocsReadResponse
| GoogleDocsWriteResponse
| GoogleDocsCreateResponse
| GoogleDocsInsertTextResponse
| GoogleDocsReplaceTextResponse
| GoogleDocsInsertTableResponse
| GoogleDocsInsertImageResponse
| GoogleDocsInsertPageBreakResponse
| GoogleDocsUpdateTextStyleResponse
| GoogleDocsDeleteContentRangeResponse
| GoogleDocsUpdateParagraphStyleResponse
| GoogleDocsCreateParagraphBulletsResponse
| GoogleDocsDeleteParagraphBulletsResponse
| GoogleDocsCreateNamedRangeResponse
| GoogleDocsDeleteNamedRangeResponse
@@ -0,0 +1,180 @@
import type {
GoogleDocsToolParams,
GoogleDocsUpdateParagraphStyleResponse,
} from '@/tools/google_docs/types'
import {
buildBatchUpdateMetadata,
buildContentRange,
resolveDocumentId,
} from '@/tools/google_docs/utils'
import type { ToolConfig } from '@/tools/types'
const NAMED_STYLE_TYPES = new Set([
'NORMAL_TEXT',
'TITLE',
'SUBTITLE',
'HEADING_1',
'HEADING_2',
'HEADING_3',
'HEADING_4',
'HEADING_5',
'HEADING_6',
])
/**
* Maps user-facing alignment names to the Google Docs API `ParagraphStyle.alignment`
* enum (the API rejects LEFT/RIGHT/JUSTIFY — the valid values are START/CENTER/END/JUSTIFIED).
*/
const ALIGNMENT_MAP: Record<string, string> = {
LEFT: 'START',
START: 'START',
CENTER: 'CENTER',
RIGHT: 'END',
END: 'END',
JUSTIFY: 'JUSTIFIED',
JUSTIFIED: 'JUSTIFIED',
}
export const updateParagraphStyleTool: ToolConfig<
GoogleDocsToolParams,
GoogleDocsUpdateParagraphStyleResponse
> = {
id: 'google_docs_update_paragraph_style',
name: 'Update Paragraph Style in Google Docs Document',
description:
'Apply a named paragraph style (such as a heading or title) and/or alignment to the paragraphs overlapping a range of text in a Google Docs document, identified by its start and end character index.',
version: '1.0',
oauth: {
required: true,
provider: 'google-docs',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Docs API',
},
documentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the document to update',
},
startIndex: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description:
'The start character index (the document body starts at index 1) of the range to style (inclusive)',
},
endIndex: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The end character index of the range to style (exclusive)',
},
namedStyleType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'The named paragraph style to apply. One of: NORMAL_TEXT, TITLE, SUBTITLE, HEADING_1, HEADING_2, HEADING_3, HEADING_4, HEADING_5, HEADING_6.',
},
alignment: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'The paragraph alignment to apply. One of: LEFT, CENTER, RIGHT, JUSTIFY.',
},
},
request: {
url: (params) => {
const documentId = resolveDocumentId(params)
return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
const range = buildContentRange(params.startIndex, params.endIndex)
const paragraphStyle: Record<string, unknown> = {}
const fields: string[] = []
if (params.namedStyleType != null && String(params.namedStyleType).trim() !== '') {
const namedStyleType = String(params.namedStyleType).trim().toUpperCase()
if (!NAMED_STYLE_TYPES.has(namedStyleType)) {
throw new Error(
'namedStyleType must be one of: NORMAL_TEXT, TITLE, SUBTITLE, HEADING_1, HEADING_2, HEADING_3, HEADING_4, HEADING_5, HEADING_6'
)
}
paragraphStyle.namedStyleType = namedStyleType
fields.push('namedStyleType')
}
if (params.alignment != null && String(params.alignment).trim() !== '') {
const alignment = ALIGNMENT_MAP[String(params.alignment).trim().toUpperCase()]
if (!alignment) {
throw new Error('alignment must be one of: LEFT, CENTER, RIGHT, JUSTIFY')
}
paragraphStyle.alignment = alignment
fields.push('alignment')
}
if (fields.length === 0) {
throw new Error('At least one of namedStyleType or alignment must be provided')
}
return {
requests: [
{
updateParagraphStyle: {
range,
paragraphStyle,
fields: fields.join(','),
},
},
],
}
},
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
const data = responseText.trim() ? JSON.parse(responseText) : {}
const metadata = buildBatchUpdateMetadata(data, response.url)
return {
success: true,
output: {
updatedContent: true,
metadata,
},
}
},
outputs: {
updatedContent: {
type: 'boolean',
description: 'Indicates if the paragraph style was applied successfully',
},
metadata: {
type: 'json',
description: 'Updated document metadata including ID, title, and URL',
properties: {
documentId: { type: 'string', description: 'Google Docs document ID' },
title: { type: 'string', description: 'Document title' },
mimeType: { type: 'string', description: 'Document MIME type' },
url: { type: 'string', description: 'Document URL' },
},
},
},
}
@@ -0,0 +1,175 @@
import type {
GoogleDocsToolParams,
GoogleDocsUpdateTextStyleResponse,
} from '@/tools/google_docs/types'
import {
buildBatchUpdateMetadata,
parseOptionalBoolean,
resolveDocumentId,
} from '@/tools/google_docs/utils'
import type { ToolConfig } from '@/tools/types'
export const updateTextStyleTool: ToolConfig<
GoogleDocsToolParams,
GoogleDocsUpdateTextStyleResponse
> = {
id: 'google_docs_update_text_style',
name: 'Apply Text Style in Google Docs Document',
description:
'Apply bold, italic, underline, and/or font size to a range of text in a Google Docs document, identified by its start and end character index.',
version: '1.0',
oauth: {
required: true,
provider: 'google-docs',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Docs API',
},
documentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The ID of the document to update',
},
startIndex: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description:
'The start character index (the document body starts at index 1) of the range to style (inclusive)',
},
endIndex: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'The end character index of the range to style (exclusive)',
},
bold: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to make the text bold',
},
italic: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to make the text italic',
},
underline: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to underline the text',
},
fontSize: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'The font size to apply, in points (PT)',
},
},
request: {
url: (params) => {
const documentId = resolveDocumentId(params)
return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate`
},
method: 'POST',
headers: (params) => {
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
const startIndex = Number(params.startIndex)
const endIndex = Number(params.endIndex)
if (!Number.isFinite(startIndex) || !Number.isFinite(endIndex)) {
throw new Error('startIndex and endIndex are required')
}
if (endIndex <= startIndex) {
throw new Error('endIndex must be greater than startIndex')
}
const textStyle: Record<string, unknown> = {}
const fields: string[] = []
const bold = parseOptionalBoolean(params.bold)
if (bold !== undefined) {
textStyle.bold = bold
fields.push('bold')
}
const italic = parseOptionalBoolean(params.italic)
if (italic !== undefined) {
textStyle.italic = italic
fields.push('italic')
}
const underline = parseOptionalBoolean(params.underline)
if (underline !== undefined) {
textStyle.underline = underline
fields.push('underline')
}
const fontSize = Number(params.fontSize)
if (params.fontSize != null && Number.isFinite(fontSize)) {
textStyle.fontSize = { magnitude: fontSize, unit: 'PT' }
fields.push('fontSize')
}
if (fields.length === 0) {
throw new Error(
'At least one style (bold, italic, underline, or fontSize) must be provided'
)
}
return {
requests: [
{
updateTextStyle: {
range: { startIndex, endIndex },
textStyle,
fields: fields.join(','),
},
},
],
}
},
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
const data = responseText.trim() ? JSON.parse(responseText) : {}
const metadata = buildBatchUpdateMetadata(data, response.url)
return {
success: true,
output: {
updatedContent: true,
metadata,
},
}
},
outputs: {
updatedContent: {
type: 'boolean',
description: 'Indicates if the text style was applied successfully',
},
metadata: {
type: 'json',
description: 'Updated document metadata including ID, title, and URL',
properties: {
documentId: { type: 'string', description: 'Google Docs document ID' },
title: { type: 'string', description: 'Document title' },
mimeType: { type: 'string', description: 'Document MIME type' },
url: { type: 'string', description: 'Document URL' },
},
},
},
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Resolve the document ID for a Docs API request, preferring the selected
* document ID and falling back to a manually entered one. Throws when neither
* is present so callers fail loudly before issuing a request.
*/
export function resolveDocumentId(params: {
documentId?: string
manualDocumentId?: string
}): string {
const documentId = params.documentId?.trim() || params.manualDocumentId?.trim()
if (!documentId) {
throw new Error('Document ID is required')
}
return documentId
}
/**
* Normalize an optional boolean param that may arrive as a real boolean or as a
* string (`"true"`/`"false"`), which happens when values come from block inputs
* or agent tool-calls rather than a typed UI switch. Returns `undefined` when the
* value is absent or unrecognized so callers can skip the field entirely.
*/
export function parseOptionalBoolean(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (normalized === 'true') return true
if (normalized === 'false') return false
}
return undefined
}
/**
* Build a `Location` for a batchUpdate request when an insertion index is
* provided, otherwise fall back to `endOfSegmentLocation` (end of the body).
* The Docs API treats index 0 as invalid, so a positive index is required to
* use an explicit location.
*/
export function buildInsertLocation(
index?: number
): { location: { index: number } } | { endOfSegmentLocation: Record<string, never> } {
if (typeof index === 'number' && Number.isFinite(index) && index >= 1) {
return { location: { index } }
}
return { endOfSegmentLocation: {} }
}
/**
* Build and validate a Docs API `Range` from a start and end character index.
* The Docs API requires `endIndex` to be strictly greater than `startIndex`.
* Throws when either index is missing or the range is empty/inverted so callers
* fail loudly before issuing a request.
*/
export function buildContentRange(
startIndex: unknown,
endIndex: unknown
): { startIndex: number; endIndex: number } {
const start = Number(startIndex)
const end = Number(endIndex)
if (!Number.isFinite(start) || !Number.isFinite(end)) {
throw new Error('startIndex and endIndex are required')
}
if (end <= start) {
throw new Error('endIndex must be greater than startIndex')
}
return { startIndex: start, endIndex: end }
}
/**
* Build canonical Google Docs metadata from a batchUpdate response. The
* `documentId` is taken from the response body when present, otherwise parsed
* from the request URL (`.../documents/{id}:batchUpdate`).
*/
export function buildBatchUpdateMetadata(
data: { documentId?: string },
responseUrl: string
): { documentId: string; title: string; mimeType: string; url: string } {
let documentId = data.documentId ?? ''
if (!documentId) {
const urlParts = responseUrl.split('/')
for (let i = 0; i < urlParts.length; i++) {
if (urlParts[i] === 'documents' && i + 1 < urlParts.length) {
documentId = urlParts[i + 1].split(':')[0]
break
}
}
}
return {
documentId,
title: 'Updated Document',
mimeType: 'application/vnd.google-apps.document',
url: `https://docs.google.com/document/d/${documentId}/edit`,
}
}
// Helper function to extract text content from Google Docs document structure
export function extractTextFromDocument(document: any): string {
let text = ''
if (!document.body || !document.body.content) {
return text
}
// Process each structural element in the document
for (const element of document.body.content) {
if (element.paragraph) {
for (const paragraphElement of element.paragraph.elements) {
if (paragraphElement.textRun?.content) {
text += paragraphElement.textRun.content
}
}
} else if (element.table) {
// Process tables if needed
for (const tableRow of element.table.tableRows) {
for (const tableCell of tableRow.tableCells) {
if (tableCell.content) {
for (const cellContent of tableCell.content) {
if (cellContent.paragraph) {
for (const paragraphElement of cellContent.paragraph.elements) {
if (paragraphElement.textRun?.content) {
text += paragraphElement.textRun.content
}
}
}
}
}
}
}
}
}
return text
}
+130
View File
@@ -0,0 +1,130 @@
import type { GoogleDocsToolParams, GoogleDocsWriteResponse } from '@/tools/google_docs/types'
import type { ToolConfig } from '@/tools/types'
export const writeTool: ToolConfig<GoogleDocsToolParams, GoogleDocsWriteResponse> = {
id: 'google_docs_write',
name: 'Write to Google Docs Document',
description:
'Append content to a Google Docs document. Content is inserted literally; Markdown is not interpreted. For formatted output from Markdown, use the Create operation with the markdown toggle enabled.',
version: '1.0',
oauth: {
required: true,
provider: 'google-docs',
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'The access token for the Google Docs API',
},
documentId: {
type: 'string',
required: true,
description: 'The ID of the document to write to',
},
content: {
type: 'string',
required: true,
description: 'The content to write to the document',
},
},
request: {
url: (params) => {
// Ensure documentId is valid
const documentId = params.documentId?.trim() || params.manualDocumentId?.trim()
if (!documentId) {
throw new Error('Document ID is required')
}
return `https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate`
},
method: 'POST',
headers: (params) => {
// Validate access token
if (!params.accessToken) {
throw new Error('Access token is required')
}
return {
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}
},
body: (params) => {
// Validate content
if (!params.content) {
throw new Error('Content is required')
}
// Following the exact format from the Google Docs API examples
// Always insert at the end of the document to avoid duplication
// See: https://developers.google.com/docs/api/reference/rest/v1/documents/request#InsertTextRequest
const requestBody = {
requests: [
{
insertText: {
endOfSegmentLocation: {},
text: params.content,
},
},
],
}
return requestBody
},
},
outputs: {
updatedContent: {
type: 'boolean',
description: 'Indicates if document content was updated successfully',
},
metadata: {
type: 'json',
description: 'Updated document metadata including ID, title, and URL',
properties: {
documentId: { type: 'string', description: 'Google Docs document ID' },
title: { type: 'string', description: 'Document title' },
mimeType: { type: 'string', description: 'Document MIME type' },
url: { type: 'string', description: 'Document URL' },
},
},
},
transformResponse: async (response: Response) => {
const responseText = await response.text()
// Parse the response if it's not empty
let _data = {}
if (responseText.trim()) {
_data = JSON.parse(responseText)
}
// Get the document ID from the URL
const urlParts = response.url.split('/')
let documentId = ''
for (let i = 0; i < urlParts.length; i++) {
if (urlParts[i] === 'documents' && i + 1 < urlParts.length) {
documentId = urlParts[i + 1].split(':')[0]
break
}
}
// Create document metadata
const metadata = {
documentId,
title: 'Updated Document',
mimeType: 'application/vnd.google-apps.document',
url: `https://docs.google.com/document/d/${documentId}/edit`,
}
return {
success: true,
output: {
updatedContent: true,
metadata,
},
}
},
}