Files
claude-code/src/utils/computerUse/win32/comWord.ts
unraid c17edcb12e feat: Computer Use — Windows 跨平台支持 + GUI 无障碍增强 + Python Bridge
三平台 Computer Use (macOS + Windows + Linux),Windows 专项增强。

- MCP server: toolCalls/tools/executor/mcpServer 等 12 文件完整实现
- 平台抽象层: platforms/{win32,darwin,linux}.ts
- 跨平台 executor: executorCrossPlatform.ts
- CHICAGO_MCP + VOICE_MODE feature flags 启用

- windowMessage.ts: SendMessageW (WM_CHAR Unicode + 剪贴板粘贴)
- windowBorder.ts: 4 叠加窗口边框 (30fps 跟踪)
- uiAutomation.ts: UI Automation 元素树/点击/写值
- accessibilitySnapshot.ts: 无障碍快照 → 模型感知 GUI
- bridge.py + bridgeClient.ts: Python 长驻进程 (替代 per-call PS)

- window_management: min/max/restore/close/focus (Win32 API)
- click_element / type_into_element: 按名称操作 (无需坐标)
- 截图自动附带 Accessibility Snapshot

- 17 种方法, stdin/stdout JSON 通信
- 窗口枚举 1.5ms vs PS 500ms, 截图 360ms vs PS 800ms
- 依赖: mss + Pillow + pywinauto
2026-04-05 15:47:20 +08:00

451 lines
13 KiB
TypeScript

/**
* Word COM automation module for Windows.
* Uses PowerShell to drive Word.Application COM object — fully headless (Visible=false).
* Each function builds a PowerShell script, runs it via Bun.spawnSync, and parses JSON output.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface WordParagraph {
text: string
bold?: boolean
italic?: boolean
fontSize?: number
}
export interface WordTable {
rows: number
cols: number
data: string[][]
}
export interface WordDocInfo {
text: string
paragraphs: WordParagraph[]
tables: WordTable[]
wordCount: number
pageCount: number
}
export interface AppendTextOptions {
bold?: boolean
italic?: boolean
fontSize?: number
fontName?: string
}
// ---------------------------------------------------------------------------
// PowerShell runner
// ---------------------------------------------------------------------------
function runPs(script: string): string {
const result = Bun.spawnSync({
cmd: ['powershell', '-NoProfile', '-NonInteractive', '-Command', script],
stdout: 'pipe',
stderr: 'pipe',
})
return new TextDecoder().decode(result.stdout).trim()
}
function parseJsonOutput<T>(raw: string, fallback: T): T {
if (!raw) return fallback
try {
return JSON.parse(raw) as T
} catch {
return fallback
}
}
/** Escape a string for safe embedding inside a PowerShell single-quoted string. */
function psEscape(s: string): string {
return s.replace(/'/g, "''")
}
// ---------------------------------------------------------------------------
// Word COM wrapper template
// ---------------------------------------------------------------------------
/**
* Wraps a Word COM script body with standard open/cleanup boilerplate.
* The body receives $word and $doc variables.
* If `openPath` is provided the document is opened; otherwise a new doc is created.
*/
function wrapWordScript(body: string, openPath?: string): string {
const openCmd = openPath
? `$doc = $word.Documents.Open('${psEscape(openPath)}')`
: '$doc = $word.Documents.Add()'
return `
$word = New-Object -ComObject Word.Application
$word.Visible = $false
$word.DisplayAlerts = 0
try {
${openCmd}
${body}
} finally {
if ($doc -ne $null) { $doc.Close($false); }
if ($word -ne $null) { $word.Quit(); }
if ($word -ne $null) { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null }
}
`
}
/**
* Same as wrapWordScript but the body is responsible for saving before close.
* After body runs, $doc.Save() is called automatically.
*/
function wrapWordScriptWithSave(body: string, openPath: string): string {
return `
$word = New-Object -ComObject Word.Application
$word.Visible = $false
$word.DisplayAlerts = 0
try {
$doc = $word.Documents.Open('${psEscape(openPath)}')
${body}
$doc.Save()
Write-Output '{"ok":true}'
} catch {
Write-Output ('{"ok":false,"error":"' + ($_.Exception.Message -replace '"','\\"') + '"}')
} finally {
if ($doc -ne $null) { $doc.Close($false); }
if ($word -ne $null) { $word.Quit(); }
if ($word -ne $null) { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null }
}
`
}
// ---------------------------------------------------------------------------
// 1. openWord
// ---------------------------------------------------------------------------
export async function openWord(filePath: string): Promise<WordDocInfo> {
const script = wrapWordScript(
`
# Paragraphs (limit 500)
$paras = @()
$paraCount = $doc.Paragraphs.Count
$limit = [Math]::Min($paraCount, 500)
for ($i = 1; $i -le $limit; $i++) {
$p = $doc.Paragraphs.Item($i)
$r = $p.Range
$paras += @{
text = $r.Text -replace '\\r$',''
bold = [bool]($r.Font.Bold -eq -1)
italic = [bool]($r.Font.Italic -eq -1)
fontSize = $r.Font.Size
}
}
# Tables
$tables = @()
foreach ($table in $doc.Tables) {
$rows = $table.Rows.Count
$cols = $table.Columns.Count
$data = @()
for ($r = 1; $r -le $rows; $r++) {
$row = @()
for ($c = 1; $c -le $cols; $c++) {
try {
$cellText = $table.Cell($r, $c).Range.Text
# Trim trailing \\r\\a that Word adds to cell text
$cellText = $cellText -replace '[\\r\\n\\a]+$',''
$row += $cellText
} catch {
$row += ''
}
}
$data += ,@($row)
}
$tables += @{ rows = $rows; cols = $cols; data = $data }
}
# Counts: wdStatisticWords=0, wdStatisticPages=2
$wordCount = $doc.ComputeStatistics(0)
$pageCount = $doc.ComputeStatistics(2)
$result = @{
text = $doc.Content.Text
paragraphs = $paras
tables = $tables
wordCount = $wordCount
pageCount = $pageCount
}
Write-Output (ConvertTo-Json $result -Depth 5 -Compress)
`,
filePath,
)
const raw = runPs(script)
return parseJsonOutput<WordDocInfo>(raw, {
text: '',
paragraphs: [],
tables: [],
wordCount: 0,
pageCount: 0,
})
}
// ---------------------------------------------------------------------------
// 2. readText
// ---------------------------------------------------------------------------
export async function readText(filePath: string): Promise<string> {
const script = wrapWordScript(
`Write-Output $doc.Content.Text`,
filePath,
)
return runPs(script)
}
// ---------------------------------------------------------------------------
// 3. appendText
// ---------------------------------------------------------------------------
export async function appendText(
filePath: string,
text: string,
opts?: AppendTextOptions,
): Promise<boolean> {
const fontSetup = opts
? [
opts.bold !== undefined ? `$sel.Font.Bold = ${opts.bold ? '-1' : '0'}` : '',
opts.italic !== undefined ? `$sel.Font.Italic = ${opts.italic ? '-1' : '0'}` : '',
opts.fontSize !== undefined ? `$sel.Font.Size = ${opts.fontSize}` : '',
opts.fontName ? `$sel.Font.Name = '${psEscape(opts.fontName)}'` : '',
]
.filter(Boolean)
.join('\n ')
: ''
const body = `
$sel = $word.Selection
$sel.EndKey(6) | Out-Null
${fontSetup}
$sel.TypeText('${psEscape(text)}')
`
const script = wrapWordScriptWithSave(body, filePath)
const raw = runPs(script)
return parseJsonOutput<{ ok: boolean }>(raw, { ok: false }).ok
}
// ---------------------------------------------------------------------------
// 4. insertText
// ---------------------------------------------------------------------------
export async function insertText(
filePath: string,
paraIndex: number,
text: string,
): Promise<boolean> {
const body = `
$doc.Paragraphs.Item(${paraIndex}).Range.InsertBefore('${psEscape(text)}')
`
const script = wrapWordScriptWithSave(body, filePath)
const raw = runPs(script)
return parseJsonOutput<{ ok: boolean }>(raw, { ok: false }).ok
}
// ---------------------------------------------------------------------------
// 5. findReplace
// ---------------------------------------------------------------------------
export async function findReplace(
filePath: string,
find: string,
replace: string,
replaceAll?: boolean,
): Promise<number> {
// wdReplaceAll=2, wdReplaceOne=1
const replaceConst = replaceAll !== false ? 2 : 1
const body = `
$content = $doc.Content
$findObj = $content.Find
$findObj.ClearFormatting()
$findObj.Replacement.ClearFormatting()
# Count replacements by iterating
$count = 0
$findObj.Text = '${psEscape(find)}'
$findObj.Replacement.Text = '${psEscape(replace)}'
$findObj.Forward = $true
$findObj.Wrap = 0
$findObj.Format = $false
$findObj.MatchCase = $false
$findObj.MatchWholeWord = $false
$findObj.MatchWildcards = $false
if (${replaceConst} -eq 2) {
# Count occurrences first using a clone of content
$range2 = $doc.Content.Duplicate
while ($range2.Find.Execute('${psEscape(find)}')) { $count++ }
# Now do the actual replace
$findObj.Execute('${psEscape(find)}', $false, $false, $false, $false, $false, $true, 0, $false, '${psEscape(replace)}', 2)
} else {
$found = $findObj.Execute('${psEscape(find)}', $false, $false, $false, $false, $false, $true, 0, $false, '${psEscape(replace)}', 1)
if ($found) { $count = 1 }
}
`
const script = `
$word = New-Object -ComObject Word.Application
$word.Visible = $false
$word.DisplayAlerts = 0
try {
$doc = $word.Documents.Open('${psEscape(filePath)}')
${body}
$doc.Save()
Write-Output ('{"count":' + $count + '}')
} catch {
Write-Output '{"count":0}'
} finally {
if ($doc -ne $null) { $doc.Close($false); }
if ($word -ne $null) { $word.Quit(); }
if ($word -ne $null) { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null }
}
`
const raw = runPs(script)
return parseJsonOutput<{ count: number }>(raw, { count: 0 }).count
}
// ---------------------------------------------------------------------------
// 6. insertTable
// ---------------------------------------------------------------------------
export async function insertTable(
filePath: string,
rows: number,
cols: number,
data: string[][],
): Promise<boolean> {
// Build PowerShell array literal for the data
const psData = data
.map(
(row) =>
',@(' + row.map((cell) => `'${psEscape(cell)}'`).join(',') + ')',
)
.join('\n ')
const body = `
$sel = $word.Selection
$sel.EndKey(6) | Out-Null
$table = $doc.Tables.Add($sel.Range, ${rows}, ${cols})
$data = @(${psData})
for ($r = 0; $r -lt $data.Count; $r++) {
for ($c = 0; $c -lt $data[$r].Count; $c++) {
$table.Cell($r + 1, $c + 1).Range.Text = $data[$r][$c]
}
}
`
const script = wrapWordScriptWithSave(body, filePath)
const raw = runPs(script)
return parseJsonOutput<{ ok: boolean }>(raw, { ok: false }).ok
}
// ---------------------------------------------------------------------------
// 7. saveWord
// ---------------------------------------------------------------------------
export async function saveWord(
filePath: string,
savePath?: string,
): Promise<boolean> {
if (!savePath || savePath === filePath) {
const script = wrapWordScriptWithSave('', filePath)
const raw = runPs(script)
return parseJsonOutput<{ ok: boolean }>(raw, { ok: false }).ok
}
const body = `$doc.SaveAs('${psEscape(savePath)}')`
const script = `
$word = New-Object -ComObject Word.Application
$word.Visible = $false
$word.DisplayAlerts = 0
try {
$doc = $word.Documents.Open('${psEscape(filePath)}')
${body}
Write-Output '{"ok":true}'
} catch {
Write-Output ('{"ok":false,"error":"' + ($_.Exception.Message -replace '"','\\"') + '"}')
} finally {
if ($doc -ne $null) { $doc.Close($false); }
if ($word -ne $null) { $word.Quit(); }
if ($word -ne $null) { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null }
}
`
const raw = runPs(script)
return parseJsonOutput<{ ok: boolean }>(raw, { ok: false }).ok
}
// ---------------------------------------------------------------------------
// 8. saveAsPdf
// ---------------------------------------------------------------------------
export async function saveAsPdf(
filePath: string,
pdfPath: string,
): Promise<boolean> {
// wdFormatPDF = 17
const body = `$doc.SaveAs2('${psEscape(pdfPath)}', 17)`
const script = `
$word = New-Object -ComObject Word.Application
$word.Visible = $false
$word.DisplayAlerts = 0
try {
$doc = $word.Documents.Open('${psEscape(filePath)}')
${body}
Write-Output '{"ok":true}'
} catch {
Write-Output ('{"ok":false,"error":"' + ($_.Exception.Message -replace '"','\\"') + '"}')
} finally {
if ($doc -ne $null) { $doc.Close($false); }
if ($word -ne $null) { $word.Quit(); }
if ($word -ne $null) { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null }
}
`
const raw = runPs(script)
return parseJsonOutput<{ ok: boolean }>(raw, { ok: false }).ok
}
// ---------------------------------------------------------------------------
// 9. createWord
// ---------------------------------------------------------------------------
export async function createWord(savePath: string): Promise<boolean> {
const script = `
$word = New-Object -ComObject Word.Application
$word.Visible = $false
$word.DisplayAlerts = 0
try {
$doc = $word.Documents.Add()
$doc.SaveAs('${psEscape(savePath)}')
Write-Output '{"ok":true}'
} catch {
Write-Output ('{"ok":false,"error":"' + ($_.Exception.Message -replace '"','\\"') + '"}')
} finally {
if ($doc -ne $null) { $doc.Close($false); }
if ($word -ne $null) { $word.Quit(); }
if ($word -ne $null) { [System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null }
}
`
const raw = runPs(script)
return parseJsonOutput<{ ok: boolean }>(raw, { ok: false }).ok
}
// ---------------------------------------------------------------------------
// 10. closeWord (no-op)
// ---------------------------------------------------------------------------
/**
* closeWord is a no-op since each operation opens and closes its own COM instance.
*/
export function closeWord(_filePath: string): void {
// No-op: each function manages its own Word lifecycle
}