Files
simstudioai--sim/apps/sim/lib/copilot/tools/server/files/file-preview.ts
T
wehub-resource-sync d25d482dc2
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

194 lines
6.0 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import {
fetchWorkspaceFileBuffer,
getWorkspaceFile,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
const logger = createLogger('CopilotFilePreview')
type FilePreviewEdit = {
strategy?: string
search?: string
replaceAll?: boolean
mode?: string
occurrence?: number
before_anchor?: string
after_anchor?: string
anchor?: string
start_anchor?: string
end_anchor?: string
}
interface BuildFilePreviewTextOptions {
operation: 'create' | 'append' | 'update' | 'patch'
streamedContent: string
existingContent?: string
edit?: Record<string, unknown>
}
function findAnchorIndex(lines: string[], anchor: string, occurrence = 1, afterIndex = -1): number {
const trimmed = anchor.trim()
let count = 0
for (let i = afterIndex + 1; i < lines.length; i++) {
if (lines[i].trim() === trimmed) {
count++
if (count === occurrence) return i
}
}
return -1
}
function extractPatchPreview(
streamedContent: string,
existingContent: string,
edit?: Record<string, unknown>
): string | undefined {
const strategy = typeof edit?.strategy === 'string' ? edit.strategy : undefined
const lines = existingContent.split('\n')
const occurrence =
typeof edit?.occurrence === 'number' && Number.isFinite(edit.occurrence) ? edit.occurrence : 1
if (strategy === 'search_replace') {
const search = typeof edit?.search === 'string' ? edit.search : ''
if (!search) return undefined
if (edit?.replaceAll === true) {
return existingContent.split(search).join(streamedContent)
}
const firstIdx = existingContent.indexOf(search)
if (firstIdx === -1) return undefined
return (
existingContent.slice(0, firstIdx) +
streamedContent +
existingContent.slice(firstIdx + search.length)
)
}
const mode = typeof edit?.mode === 'string' ? edit.mode : undefined
if (!mode) return undefined
if (mode === 'replace_between') {
const beforeAnchor = typeof edit?.before_anchor === 'string' ? edit.before_anchor : undefined
const afterAnchor = typeof edit?.after_anchor === 'string' ? edit.after_anchor : undefined
if (!beforeAnchor || !afterAnchor) return undefined
const beforeIdx = findAnchorIndex(lines, beforeAnchor, occurrence)
const afterIdx = findAnchorIndex(lines, afterAnchor, occurrence, beforeIdx)
if (beforeIdx === -1 || afterIdx === -1 || afterIdx <= beforeIdx) return undefined
const spliced = [
...lines.slice(0, beforeIdx + 1),
...(streamedContent.length > 0 ? streamedContent.split('\n') : []),
...lines.slice(afterIdx),
]
return spliced.join('\n')
}
if (mode === 'insert_after') {
const anchor = typeof edit?.anchor === 'string' ? edit.anchor : undefined
if (!anchor) return undefined
const anchorIdx = findAnchorIndex(lines, anchor, occurrence)
if (anchorIdx === -1) return undefined
const spliced = [
...lines.slice(0, anchorIdx + 1),
...(streamedContent.length > 0 ? streamedContent.split('\n') : []),
...lines.slice(anchorIdx + 1),
]
return spliced.join('\n')
}
if (mode === 'delete_between') {
const startAnchor = typeof edit?.start_anchor === 'string' ? edit.start_anchor : undefined
const endAnchor = typeof edit?.end_anchor === 'string' ? edit.end_anchor : undefined
if (!startAnchor || !endAnchor) return undefined
const startIdx = findAnchorIndex(lines, startAnchor, occurrence)
const endIdx = findAnchorIndex(lines, endAnchor, occurrence, startIdx)
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return undefined
const spliced = [...lines.slice(0, startIdx), ...lines.slice(endIdx)]
return spliced.join('\n')
}
return undefined
}
function shouldApplyPatchPreview(streamedContent: string, edit?: Record<string, unknown>): boolean {
const strategy = typeof edit?.strategy === 'string' ? edit.strategy : undefined
const mode = typeof edit?.mode === 'string' ? edit.mode : undefined
if (strategy === 'anchored' && mode === 'delete_between') {
return true
}
return streamedContent.length > 0
}
function buildAppendPreview(existingContent: string, incomingContent: string): string {
if (incomingContent.length === 0) return existingContent
if (existingContent.length === 0) return incomingContent
return `${existingContent}\n${incomingContent}`
}
/**
* Reads the current UTF-8 text of a workspace file for streaming previews.
*
* Preview runs in the SSE loop on `workspace_file` **call** events, which are
* processed **before** the async tool executor persists {@link storeFileIntent}.
* Loading the base here avoids a race where `edit_content` `args_delta` arrives
* before Redis holds `existingContent`, which would make append previews look like
* full-file replacement until the intent landed.
*/
export async function loadWorkspaceFileTextForPreview(
workspaceId: string,
fileId: string
): Promise<string | undefined> {
try {
const record = await getWorkspaceFile(workspaceId, fileId)
if (!record) return undefined
const buffer = await fetchWorkspaceFileBuffer(record)
return buffer.toString('utf-8')
} catch (error) {
logger.warn('Failed to load workspace file text for preview', {
workspaceId,
fileId,
error: toError(error).message,
})
return undefined
}
}
export function buildFilePreviewText({
operation,
streamedContent,
existingContent,
edit,
}: BuildFilePreviewTextOptions): string | undefined {
if (operation === 'update') {
return streamedContent
}
if (operation === 'create') {
return streamedContent
}
if (operation === 'append') {
if (existingContent !== undefined) {
return buildAppendPreview(existingContent, streamedContent)
}
return streamedContent
}
if (existingContent === undefined) {
return undefined
}
if (!shouldApplyPatchPreview(streamedContent, edit)) {
return undefined
}
return extractPatchPreview(streamedContent, existingContent, edit)
}