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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 86 additions & 7 deletions bun.lock

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions docs/adr/0001-office-extraction-parser-stack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Office extraction parser stack

Office attachments (doc/docx/xls/xlsx/ods, pptx/odt/odp, plus ppt stubbed) are extracted to text server-side in `packages/core/src/office.ts` instead of being forwarded to providers, because the AI SDK converters hard-reject office media types on every non-Bedrock route. We picked boring pure-JS parsers over richer WASM/OOXML stacks to keep the Bun server runtime WASM-free: `mammoth` for docx, `[email protected]` (SheetJS) for spreadsheets, `word-extractor` for legacy .doc, and a ~60-line `@zip.js/zip.js` + text-run reader for pptx/odt/odp.

## Considered Options

- `@silurus/ooxml` (already used for preview): headless docx extraction fails in Bun (canvas font-metric dependency), and WASM loading in the server runtime was the risk we set out to avoid. It stays preview-only.
- `officeparser`: does not parse legacy `.doc/.xls/.ppt` (the formats we committed to) and drags `pdfjs-dist` + `tesseract.js` as hard dependencies.
- Hybrid WASM stack (`@silurus` xlsx/pptx + mammoth + word-extractor): better xlsx fidelity, one more runtime risk surface; revisit if CSV-per-sheet extraction measurably annoys models.

## Consequences

- `[email protected]` is the frozen npm snapshot: SheetJS left the npm registry, so this version never receives updates and has no Dependabot path. It is pure JS, sandbox-safe input parsing, and pinned deliberately; migrate to SheetJS's CDN package only if a real parse bug forces it.
- Extraction caps: 10 MiB per file, 50 KB of text per attachment, both with model-facing stub messages when exceeded or unreadable.
- `.ppt` (legacy binary decks) has no parser and always yields the stub; the upgrade path is a CFB `TextBytesAtom` scan via SheetJS's bundled CFB module when a real file shows up.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"docx-preview": "0.4.0",
"docx-renderer": "0.2.2",
"@silurus/ooxml": "0.80.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/prompt-input-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
description: language.t("prompt.toast.pasteUnsupported.description"),
}),
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
oversize: () => showToast({ title: language.t("prompt.toast.attachmentTooLarge.title") }),
onError: (error) =>
showToast({
variant: "error",
Expand Down
5 changes: 4 additions & 1 deletion packages/app/src/components/prompt-input/files.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES, OFFICE_FILE_TYPES } from "@/constants/file-picker"

export { ACCEPTED_FILE_TYPES }

Expand Down Expand Up @@ -82,10 +82,13 @@ function textBytes(bytes: Uint8Array) {
return count / bytes.length <= 0.3
}

const OFFICE_MIMES = new Set(OFFICE_FILE_TYPES)

export async function attachmentMime(file: File) {
const type = kind(file.type)
if (IMAGE_MIMES.has(type)) return type
if (type === "application/pdf") return type
if (OFFICE_MIMES.has(type)) return type

const suffix = ext(file.name)
const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
Expand Down
22 changes: 22 additions & 0 deletions packages/app/src/constants/file-picker.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]

export const OFFICE_FILE_TYPES = [
"application/msword",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.presentation",
]

export const ACCEPTED_FILE_TYPES = [
...ACCEPTED_IMAGE_TYPES,
...OFFICE_FILE_TYPES,
"application/pdf",
"text/*",
"application/json",
Expand Down Expand Up @@ -68,6 +81,15 @@ const MIME_EXT = new Map([
["application/x-yaml", "yaml"],
["application/xml", "xml"],
["application/yaml", "yaml"],
["application/msword", "doc"],
["application/vnd.ms-excel", "xls"],
["application/vnd.ms-powerpoint", "ppt"],
["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"],
["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"],
["application/vnd.openxmlformats-officedocument.presentationml.presentation", "pptx"],
["application/vnd.oasis.opendocument.text", "odt"],
["application/vnd.oasis.opendocument.spreadsheet", "ods"],
["application/vnd.oasis.opendocument.presentation", "odp"],
])

const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"]
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,10 @@ const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | un

const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
const path = sessionPath(key)
const active = tabs.active && !tabs.active.startsWith("preview:") ? normalizeSessionTab(path, tabs.active) : undefined
return {
all: normalizeSessionTabList(path, tabs.all),
active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
all: normalizeSessionTabList(path, tabs.all).filter((tab) => !tab.startsWith("preview:")),
active,
}
}

Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ type PlatformBase = {
/** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */
revealPath?(path: string): Promise<boolean>

/** Open a file with the OS default application (desktop only) */
openInApp?(path: string): Promise<boolean>

/** Restart the app */
restart(): Promise<void>

Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ export const dict = {
"common.goForward": "Navigate forward",
"common.loading": "Loading",
"common.loading.ellipsis": "...",
"titlebar.debugTools.toggle": "Toggle debug tools",
"common.cancel": "Cancel",
"common.clear": "Clear",
"common.open": "Open",
Expand Down Expand Up @@ -311,6 +312,7 @@ export const dict = {
"prompt.toast.pasteUnsupported.title": "Unsupported attachment",
"prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
"prompt.toast.attachmentDuplicate.title": "This file has already been uploaded",
"prompt.toast.attachmentTooLarge.title": "This file is larger than the 10 MiB attachment limit",
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
Expand Down Expand Up @@ -670,6 +672,8 @@ export const dict = {
"session.tab.context": "Context",
"session.tab.unknown": "Unknown Session",
"session.panel.reviewAndFiles": "Review and files",
"session.attachment.openInApp": "Open in app",
"session.attachment.download": "Download",
"session.error.notFound": "This session cannot be found",
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ export const dict = {
"common.goForward": "Tiến tới",
"common.loading": "Đang tải",
"common.loading.ellipsis": "...",
"titlebar.debugTools.toggle": "Chuyển công cụ gỡ lỗi",
"common.cancel": "Hủy",
"common.clear": "Xóa",
"common.open": "Mở",
Expand Down Expand Up @@ -388,6 +389,7 @@ export const dict = {
"prompt.toast.pasteUnsupported.title": "Tệp đính kèm không được hỗ trợ",
"prompt.toast.pasteUnsupported.description": "Chỉ có thể đính kèm hình ảnh, tệp PDF hoặc tệp văn bản ở đây.",
"prompt.toast.attachmentDuplicate.title": "Tệp này đã được tải lên",
"prompt.toast.attachmentTooLarge.title": "Tệp này lớn hơn giới hạn tệp đính kèm 10 MiB",
"prompt.toast.modelAgentRequired.title": "Chọn tác nhân và mô hình",
"prompt.toast.modelAgentRequired.description": "Chọn một tác nhân và mô hình trước khi gửi lời nhắc.",
"prompt.toast.worktreeCreateFailed.title": "Không tạo được worktree",
Expand Down Expand Up @@ -715,6 +717,8 @@ export const dict = {
"session.tab.context": "Ngữ cảnh",
"session.tab.unknown": "Phiên không xác định",
"session.panel.reviewAndFiles": "Xem lại và tệp",
"session.attachment.openInApp": "Mở trong ứng dụng",
"session.attachment.download": "Tải xuống",
"session.error.notFound": "Không thể tìm thấy phiên này",
"session.error.notFound.description": "Tab này trỏ đến một phiên không còn tồn tại trên máy chủ này.",
"session.error.notFound.closeTab": "Đóng tab",
Expand Down
114 changes: 105 additions & 9 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,14 @@ import { Tabs } from "@opencode-ai/ui/tabs"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { DocumentPreview } from "@opencode-ai/session-ui/document-preview"
import { Button } from "@opencode-ai/ui/button"
import { listPlugins, pluginInvoke, setPluginServer, type OfficePreviewResult } from "@/utils/plugin-invoke"
import { OfficePreview } from "@opencode-ai/session-ui/office-preview"
import { previewTabKey, setPreviewPayload, type PreviewPayload } from "@/pages/session/document-preview-tab"
import { showToast } from "@/utils/toast"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { OFFICE_FILE_TYPES } from "@/constants/file-picker"
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { ErrorPage } from "@/pages/error"
Expand Down Expand Up @@ -361,6 +366,8 @@ export default function Page() {
const language = useLanguage()
const sdk = useSDK()
const serverSDK = useServerSDK()
const server = useServer()
createEffect(() => setPluginServer(server.current?.http))
const settings = useSettings()
const platform = usePlatform()
const prompt = usePrompt()
Expand Down Expand Up @@ -1906,21 +1913,110 @@ export default function Page() {
const download = () => {
const anchor = document.createElement("a")
anchor.href = file.url
anchor.download = getFilename(file.filename) || "attachment"
anchor.download = getFilename(file.filename) || language.t("common.attachment")
anchor.click()
}
const path = file.filename ?? ""
const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
if (platform.revealPath && absolute) {
void platform.revealPath(path).then(
(revealed) => {
if (!revealed) download()
},
() => download(),
)
const filename = getFilename(file.filename) || language.t("common.attachment")
const openInApp = () => void platform.openInApp?.(path)
const kind =
file.mime === "application/pdf"
? "pdf"
: file.mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
/\.docx$/i.test(filename)
? "docx"
: file.mime === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
/\.xlsx$/i.test(filename)
? "xlsx"
: file.mime === "application/vnd.openxmlformats-officedocument.presentationml.presentation" ||
/\.pptx$/i.test(filename)
? "pptx"
: file.mime === "text/markdown" || /\.md$/i.test(filename)
? "markdown"
: null
const actions = (
<>
<Show when={platform.openInApp && absolute}>
<Button variant="ghost" onClick={openInApp}>
{language.t("session.attachment.openInApp")}
</Button>
</Show>
<Button variant="primary" onClick={download}>
{language.t("session.attachment.download")}
</Button>
</>
)
const openInPanel = (payload: PreviewPayload) => {
const key = previewTabKey(payload.filename)
setPreviewPayload(key, payload)
openReviewPanel()
void tabs().open(key)
// ponytail: Kobalte falls back to the first tab (firing onChange→setActive) before the new trigger registers; re-assert after it settles
queueMicrotask(() => tabs().setActive(key))
}
const openPreview = () => {
if (kind) {
if (isDesktop()) {
openInPanel({ source: "builtin", filename, kind, url: file.url, path: absolute ? path : undefined })
return
}
dialog.show(() => <DocumentPreview filename={filename} kind={kind} url={file.url} actions={actions} />)
return
}
if (platform.revealPath && absolute) {
void platform.revealPath(path).then(
(revealed) => {
if (!revealed) download()
},
() => download(),
)
return
}
download()
}
const sessionID = params.id
const openManaged = async (sessionID: string) => {
let found: { plugin: { id: string; invokes: string[] }; result: OfficePreviewResult } | undefined
try {
const plugins = await listPlugins()
const plugin = plugins.find((entry) => entry.invokes.includes("office.preview"))
if (plugin) {
const result = await pluginInvoke<OfficePreviewResult>(plugin.id, "office.preview", {
filePath: path,
sessionID,
})
if (result?.managed) found = { plugin, result }
}
} catch {
// detection failed; fall through to the built-in preview
}
if (found) {
const { plugin, result } = found
if (isDesktop()) {
openInPanel({ source: "plugin", filename: result.filename, pluginId: plugin.id, result, path, sessionID })
return
}
const invoke = <T,>(name: string, input?: Record<string, unknown>) =>
pluginInvoke<T>(plugin.id, name, { filePath: path, sessionID, ...input })
dialog.show(() => (
<OfficePreview
result={result}
invoke={invoke}
openInApp={platform.openInApp ? openInApp : undefined}
download={download}
/>
))
return
}
openPreview()
}
if (!OFFICE_FILE_TYPES.includes(file.mime) || !absolute || !sessionID) {
openPreview()
return
}
download()
// ponytail: detection is silent; any failure (plugin absent, HTTP error, timeout) falls back to the built-in preview
void openManaged(sessionID)
}

const actions = { revert, openAttachment }
Expand Down
Loading
Loading