mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-13 15:44:56 +00:00
feat: better image handling (auto resize & max size constraints) (#26401)
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
### General Principles
|
### General Principles
|
||||||
|
|
||||||
- Keep things in one function unless composable or reusable
|
- Keep things in one function unless composable or reusable
|
||||||
|
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
|
||||||
- Avoid `try`/`catch` where possible
|
- Avoid `try`/`catch` where possible
|
||||||
- Avoid using the `any` type
|
- Avoid using the `any` type
|
||||||
- Use Bun APIs when possible, like `Bun.file()`
|
- Use Bun APIs when possible, like `Bun.file()`
|
||||||
|
|||||||
4
bun.lock
4
bun.lock
@@ -412,6 +412,7 @@
|
|||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/scheduled": "1.5.2",
|
"@solid-primitives/scheduled": "1.5.2",
|
||||||
"@standard-schema/spec": "1.0.0",
|
"@standard-schema/spec": "1.0.0",
|
||||||
@@ -677,6 +678,7 @@
|
|||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||||
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
@@ -2035,6 +2037,8 @@
|
|||||||
|
|
||||||
"@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="],
|
"@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="],
|
||||||
|
|
||||||
|
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
|
||||||
|
|
||||||
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||||
|
|
||||||
"@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="],
|
"@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="],
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
|
||||||
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@
|
|||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/scheduled": "1.5.2",
|
"@solid-primitives/scheduled": "1.5.2",
|
||||||
"@standard-schema/spec": "1.0.0",
|
"@standard-schema/spec": "1.0.0",
|
||||||
|
|||||||
5
packages/opencode/src/audio.d.ts
vendored
5
packages/opencode/src/audio.d.ts
vendored
@@ -2,3 +2,8 @@ declare module "*.wav" {
|
|||||||
const file: string
|
const file: string
|
||||||
export default file
|
export default file
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "*.wasm" {
|
||||||
|
const file: string
|
||||||
|
export default file
|
||||||
|
}
|
||||||
|
|||||||
30
packages/opencode/src/config/attachment.ts
Normal file
30
packages/opencode/src/config/attachment.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export * as ConfigAttachment from "./attachment"
|
||||||
|
|
||||||
|
import { Schema } from "effect"
|
||||||
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
|
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||||
|
|
||||||
|
export const Image = Schema.Struct({
|
||||||
|
auto_resize: Schema.optional(Schema.Boolean).annotate({
|
||||||
|
description: "Resize images before sending them to the model when they exceed configured limits (default: true)",
|
||||||
|
}),
|
||||||
|
max_width: Schema.optional(PositiveInt).annotate({
|
||||||
|
description: "Maximum image width before resizing or rejecting the attachment (default: 2000)",
|
||||||
|
}),
|
||||||
|
max_height: Schema.optional(PositiveInt).annotate({
|
||||||
|
description: "Maximum image height before resizing or rejecting the attachment (default: 2000)",
|
||||||
|
}),
|
||||||
|
max_base64_bytes: Schema.optional(PositiveInt).annotate({
|
||||||
|
description: "Maximum base64 payload bytes for an image attachment (default: 4718592)",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.annotate({ identifier: "ImageAttachmentConfig" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type Image = Schema.Schema.Type<typeof Image>
|
||||||
|
|
||||||
|
export const Info = Schema.Struct({
|
||||||
|
image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }),
|
||||||
|
})
|
||||||
|
.annotate({ identifier: "AttachmentConfig" })
|
||||||
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
export type Info = Schema.Schema.Type<typeof Info>
|
||||||
@@ -25,6 +25,7 @@ import { containsPath } from "../project/instance-context"
|
|||||||
import { zod } from "@opencode-ai/core/effect-zod"
|
import { zod } from "@opencode-ai/core/effect-zod"
|
||||||
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||||
import { ConfigAgent } from "./agent"
|
import { ConfigAgent } from "./agent"
|
||||||
|
import { ConfigAttachment } from "./attachment"
|
||||||
import { ConfigCommand } from "./command"
|
import { ConfigCommand } from "./command"
|
||||||
import { ConfigFormatter } from "./formatter"
|
import { ConfigFormatter } from "./formatter"
|
||||||
import { ConfigLayout } from "./layout"
|
import { ConfigLayout } from "./layout"
|
||||||
@@ -241,6 +242,9 @@ export const Info = Schema.Struct({
|
|||||||
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
||||||
permission: Schema.optional(ConfigPermission.Info),
|
permission: Schema.optional(ConfigPermission.Info),
|
||||||
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||||
|
attachment: Schema.optional(ConfigAttachment.Info).annotate({
|
||||||
|
description: "Attachment processing configuration, including image size limits and resizing behavior",
|
||||||
|
}),
|
||||||
enterprise: Schema.optional(
|
enterprise: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
|
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
|
||||||
|
|||||||
180
packages/opencode/src/image/image.ts
Normal file
180
packages/opencode/src/image/image.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { Config } from "@/config/config"
|
||||||
|
import type { MessageV2 } from "@/session/message-v2"
|
||||||
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
|
|
||||||
|
const MAX_BASE64_BYTES = 4.5 * 1024 * 1024
|
||||||
|
const MAX_WIDTH = 2000
|
||||||
|
const MAX_HEIGHT = 2000
|
||||||
|
const AUTO_RESIZE = true
|
||||||
|
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
|
||||||
|
const log = Log.create({ service: "image" })
|
||||||
|
|
||||||
|
export class PhotonUnavailableError extends Schema.TaggedErrorClass<PhotonUnavailableError>()(
|
||||||
|
"ImagePhotonUnavailableError",
|
||||||
|
{},
|
||||||
|
) {
|
||||||
|
override get message() {
|
||||||
|
return "Photon image processor is unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidDataUrlError extends Schema.TaggedErrorClass<InvalidDataUrlError>()("ImageInvalidDataUrlError", {
|
||||||
|
url: Schema.String,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return "Image URL must be a base64 data URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("ImageDecodeError", {}) {
|
||||||
|
override get message() {
|
||||||
|
return "Image could not be decoded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SizeError extends Schema.TaggedErrorClass<SizeError>()("ImageSizeError", {
|
||||||
|
bytes: Schema.Number,
|
||||||
|
max: Schema.Number,
|
||||||
|
width: Schema.Number,
|
||||||
|
height: Schema.Number,
|
||||||
|
max_width: Schema.Number,
|
||||||
|
max_height: Schema.Number,
|
||||||
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return `Image ${this.width}x${this.height} with base64 size ${this.bytes} exceeds configured limits and could not be resized below ${this.max_width}x${this.max_height}/${this.max} bytes`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Error = PhotonUnavailableError | InvalidDataUrlError | DecodeError | SizeError
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly normalize: (input: MessageV2.FilePart) => Effect.Effect<MessageV2.FilePart, Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const loadPhoton = yield* Effect.cached(
|
||||||
|
Effect.promise(async () => {
|
||||||
|
try {
|
||||||
|
const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } }))
|
||||||
|
.default
|
||||||
|
// Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path.
|
||||||
|
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
|
||||||
|
photonWasm
|
||||||
|
return await import("@silvia-odwyer/photon-node")
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) {
|
||||||
|
const image = (yield* config.get()).attachment?.image
|
||||||
|
const info = {
|
||||||
|
autoResize: image?.auto_resize ?? AUTO_RESIZE,
|
||||||
|
maxWidth: image?.max_width ?? MAX_WIDTH,
|
||||||
|
maxHeight: image?.max_height ?? MAX_HEIGHT,
|
||||||
|
maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES,
|
||||||
|
}
|
||||||
|
if (!input.url.startsWith("data:") || !input.url.includes(";base64,"))
|
||||||
|
return yield* new InvalidDataUrlError({ url: input.url })
|
||||||
|
|
||||||
|
const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length)
|
||||||
|
const photon = yield* loadPhoton
|
||||||
|
if (!photon) return yield* new PhotonUnavailableError()
|
||||||
|
|
||||||
|
const decoded = yield* Effect.sync(() => {
|
||||||
|
try {
|
||||||
|
return photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64"))
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!decoded) return yield* new DecodeError()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const originalWidth = decoded.get_width()
|
||||||
|
const originalHeight = decoded.get_height()
|
||||||
|
if (
|
||||||
|
originalWidth <= info.maxWidth &&
|
||||||
|
originalHeight <= info.maxHeight &&
|
||||||
|
Buffer.byteLength(base64, "utf8") <= info.maxBase64Bytes
|
||||||
|
)
|
||||||
|
return input
|
||||||
|
if (!info.autoResize)
|
||||||
|
return yield* new SizeError({
|
||||||
|
bytes: Buffer.byteLength(base64, "utf8"),
|
||||||
|
max: info.maxBase64Bytes,
|
||||||
|
width: originalWidth,
|
||||||
|
height: originalHeight,
|
||||||
|
max_width: info.maxWidth,
|
||||||
|
max_height: info.maxHeight,
|
||||||
|
})
|
||||||
|
|
||||||
|
const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight)
|
||||||
|
for (const size of Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
|
||||||
|
const previous = acc.at(-1) ?? {
|
||||||
|
width: Math.max(1, Math.round(originalWidth * scale)),
|
||||||
|
height: Math.max(1, Math.round(originalHeight * scale)),
|
||||||
|
}
|
||||||
|
const next =
|
||||||
|
acc.length === 0
|
||||||
|
? previous
|
||||||
|
: {
|
||||||
|
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
|
||||||
|
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
|
||||||
|
}
|
||||||
|
return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next]
|
||||||
|
}, [])) {
|
||||||
|
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
|
||||||
|
const candidate = [
|
||||||
|
{ data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
|
||||||
|
...JPEG_QUALITIES.map((quality) => ({
|
||||||
|
data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
|
||||||
|
mime: "image/jpeg",
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
.map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") }))
|
||||||
|
.find((item) => item.bytes <= info.maxBase64Bytes)
|
||||||
|
resized.free()
|
||||||
|
|
||||||
|
if (candidate) {
|
||||||
|
log.info("using resized image", {
|
||||||
|
from_mime: input.mime,
|
||||||
|
to_mime: candidate.mime,
|
||||||
|
from: `${originalWidth}x${originalHeight}`,
|
||||||
|
to: `${size.width}x${size.height}`,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
mime: candidate.mime,
|
||||||
|
url: `data:${candidate.mime};base64,${candidate.data}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return yield* new SizeError({
|
||||||
|
bytes: Buffer.byteLength(base64, "utf8"),
|
||||||
|
max: info.maxBase64Bytes,
|
||||||
|
width: originalWidth,
|
||||||
|
height: originalHeight,
|
||||||
|
max_width: info.maxWidth,
|
||||||
|
max_height: info.maxHeight,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
decoded.free()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ normalize })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||||
|
|
||||||
|
export * as Image from "./image"
|
||||||
@@ -203,13 +203,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
|||||||
params: { sessionID: SessionID }
|
params: { sessionID: SessionID }
|
||||||
payload: typeof InitPayload.Type
|
payload: typeof InitPayload.Type
|
||||||
}) {
|
}) {
|
||||||
yield* promptSvc.command({
|
yield* promptSvc
|
||||||
sessionID: ctx.params.sessionID,
|
.command({
|
||||||
messageID: ctx.payload.messageID,
|
sessionID: ctx.params.sessionID,
|
||||||
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
|
messageID: ctx.payload.messageID,
|
||||||
command: Command.Default.INIT,
|
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
|
||||||
arguments: "",
|
command: Command.Default.INIT,
|
||||||
})
|
arguments: "",
|
||||||
|
})
|
||||||
|
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -258,18 +260,18 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
|||||||
}) {
|
}) {
|
||||||
const instance = yield* InstanceState.context
|
const instance = yield* InstanceState.context
|
||||||
const workspace = yield* InstanceState.workspaceID
|
const workspace = yield* InstanceState.workspaceID
|
||||||
|
const message = yield* promptSvc
|
||||||
|
.prompt({
|
||||||
|
...ctx.payload,
|
||||||
|
sessionID: ctx.params.sessionID,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
Effect.provideService(InstanceRef, instance),
|
||||||
|
Effect.provideService(WorkspaceRef, workspace),
|
||||||
|
Effect.mapError(() => new HttpApiError.BadRequest({})),
|
||||||
|
)
|
||||||
return HttpServerResponse.stream(
|
return HttpServerResponse.stream(
|
||||||
Stream.fromEffect(
|
Stream.make(JSON.stringify(message)).pipe(Stream.encodeText),
|
||||||
promptSvc
|
|
||||||
.prompt({
|
|
||||||
...ctx.payload,
|
|
||||||
sessionID: ctx.params.sessionID,
|
|
||||||
})
|
|
||||||
.pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)),
|
|
||||||
).pipe(
|
|
||||||
Stream.map((message) => JSON.stringify(message)),
|
|
||||||
Stream.encodeText,
|
|
||||||
),
|
|
||||||
{ contentType: "application/json" },
|
{ contentType: "application/json" },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -297,7 +299,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
|||||||
params: { sessionID: SessionID }
|
params: { sessionID: SessionID }
|
||||||
payload: typeof CommandPayload.Type
|
payload: typeof CommandPayload.Type
|
||||||
}) {
|
}) {
|
||||||
return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
|
return yield* promptSvc
|
||||||
|
.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
|
||||||
|
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||||
})
|
})
|
||||||
|
|
||||||
const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
|
const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect"
|
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
@@ -9,6 +9,7 @@ import { Snapshot } from "@/snapshot"
|
|||||||
import * as Session from "./session"
|
import * as Session from "./session"
|
||||||
import { LLM } from "./llm"
|
import { LLM } from "./llm"
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
|
import { Image } from "@/image/image"
|
||||||
import { isOverflow } from "./overflow"
|
import { isOverflow } from "./overflow"
|
||||||
import { PartID } from "./schema"
|
import { PartID } from "./schema"
|
||||||
import type { SessionID } from "./schema"
|
import type { SessionID } from "./schema"
|
||||||
@@ -92,6 +93,7 @@ export const layer: Layer.Layer<
|
|||||||
| LLM.Service
|
| LLM.Service
|
||||||
| Permission.Service
|
| Permission.Service
|
||||||
| Plugin.Service
|
| Plugin.Service
|
||||||
|
| Image.Service
|
||||||
| SessionSummary.Service
|
| SessionSummary.Service
|
||||||
| SessionStatus.Service
|
| SessionStatus.Service
|
||||||
> = Layer.effect(
|
> = Layer.effect(
|
||||||
@@ -108,6 +110,7 @@ export const layer: Layer.Layer<
|
|||||||
const summary = yield* SessionSummary.Service
|
const summary = yield* SessionSummary.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const status = yield* SessionStatus.Service
|
const status = yield* SessionStatus.Service
|
||||||
|
const image = yield* Image.Service
|
||||||
|
|
||||||
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
|
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
|
||||||
// Pre-capture snapshot before the LLM stream starts. The AI SDK
|
// Pre-capture snapshot before the LLM stream starts. The AI SDK
|
||||||
@@ -377,17 +380,43 @@ export const layer: Layer.Layer<
|
|||||||
|
|
||||||
case "tool-result": {
|
case "tool-result": {
|
||||||
const toolCall = yield* readToolCall(value.toolCallId)
|
const toolCall = yield* readToolCall(value.toolCallId)
|
||||||
|
const toolAttachments: MessageV2.FilePart[] = (
|
||||||
|
Array.isArray(value.output.attachments) ? value.output.attachments : []
|
||||||
|
).filter(
|
||||||
|
(attachment: unknown): attachment is MessageV2.FilePart =>
|
||||||
|
isRecord(attachment) &&
|
||||||
|
attachment.type === "file" &&
|
||||||
|
typeof attachment.mime === "string" &&
|
||||||
|
typeof attachment.url === "string",
|
||||||
|
)
|
||||||
|
const normalized = yield* Effect.forEach(
|
||||||
|
toolAttachments,
|
||||||
|
(attachment) =>
|
||||||
|
attachment.mime.startsWith("image/")
|
||||||
|
? image.normalize(attachment).pipe(Effect.exit)
|
||||||
|
: Effect.succeed(Exit.succeed<MessageV2.FilePart>(attachment)),
|
||||||
|
)
|
||||||
|
const omitted = normalized.filter(Exit.isFailure).length
|
||||||
|
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
|
||||||
|
const output = {
|
||||||
|
...value.output,
|
||||||
|
output:
|
||||||
|
omitted === 0
|
||||||
|
? value.output.output
|
||||||
|
: `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the inline image size limit.]`,
|
||||||
|
attachments: attachments?.length ? attachments : undefined,
|
||||||
|
}
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
EventV2.run(SessionEvent.Tool.Success.Sync, {
|
EventV2.run(SessionEvent.Tool.Success.Sync, {
|
||||||
sessionID: ctx.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
callID: value.toolCallId,
|
callID: value.toolCallId,
|
||||||
structured: value.output.metadata,
|
structured: output.metadata,
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: value.output.output,
|
text: output.output,
|
||||||
},
|
},
|
||||||
...(value.output.attachments?.map((item: MessageV2.FilePart) => ({
|
...(output.attachments?.map((item: MessageV2.FilePart) => ({
|
||||||
type: "file",
|
type: "file",
|
||||||
uri: item.url,
|
uri: item.url,
|
||||||
mime: item.mime,
|
mime: item.mime,
|
||||||
@@ -399,7 +428,7 @@ export const layer: Layer.Layer<
|
|||||||
},
|
},
|
||||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||||
})
|
})
|
||||||
yield* completeToolCall(value.toolCallId, value.output)
|
yield* completeToolCall(value.toolCallId, output)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,6 +787,7 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
Layer.provide(SessionSummary.defaultLayer),
|
Layer.provide(SessionSummary.defaultLayer),
|
||||||
Layer.provide(SessionStatus.defaultLayer),
|
Layer.provide(SessionStatus.defaultLayer),
|
||||||
|
Layer.provide(Image.defaultLayer),
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Bus.layer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { Shell } from "@/shell/shell"
|
|||||||
import { ShellID } from "@/tool/shell/id"
|
import { ShellID } from "@/tool/shell/id"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncate"
|
||||||
|
import { Image } from "@/image/image"
|
||||||
import { decodeDataUrl } from "@/util/data-url"
|
import { decodeDataUrl } from "@/util/data-url"
|
||||||
import { Process } from "@/util/process"
|
import { Process } from "@/util/process"
|
||||||
import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect"
|
import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect"
|
||||||
@@ -80,10 +81,10 @@ const elog = EffectLogger.create({ service: "session.prompt" })
|
|||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
||||||
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
|
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
|
||||||
readonly loop: (input: LoopInput) => Effect.Effect<MessageV2.WithParts>
|
readonly loop: (input: LoopInput) => Effect.Effect<MessageV2.WithParts>
|
||||||
readonly shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts>
|
readonly shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts>
|
||||||
readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts>
|
readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
|
||||||
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
|
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ export const layer = Layer.effect(
|
|||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
const truncate = yield* Truncate.Service
|
const truncate = yield* Truncate.Service
|
||||||
|
const image = yield* Image.Service
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const instruction = yield* Instruction.Service
|
const instruction = yield* Instruction.Service
|
||||||
@@ -123,7 +125,7 @@ export const layer = Layer.effect(
|
|||||||
return {
|
return {
|
||||||
cancel: (sessionID: SessionID) => cancel(sessionID),
|
cancel: (sessionID: SessionID) => cancel(sessionID),
|
||||||
resolvePromptParts: (template: string) => resolvePromptParts(template),
|
resolvePromptParts: (template: string) => resolvePromptParts(template),
|
||||||
prompt: (input: PromptInput) => prompt(input),
|
prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)),
|
||||||
} satisfies TaskPromptOps
|
} satisfies TaskPromptOps
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1259,7 +1261,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
return [{ ...part, messageID: info.id, sessionID: input.sessionID }]
|
return [{ ...part, messageID: info.id, sessionID: input.sessionID }]
|
||||||
})
|
})
|
||||||
|
|
||||||
const parts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(
|
const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(
|
||||||
Effect.map((x) => x.flat().map(assign)),
|
Effect.map((x) => x.flat().map(assign)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1272,7 +1274,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
messageID: input.messageID,
|
messageID: input.messageID,
|
||||||
variant: input.variant,
|
variant: input.variant,
|
||||||
},
|
},
|
||||||
{ message: info, parts },
|
{ message: info, parts: resolvedParts },
|
||||||
|
)
|
||||||
|
|
||||||
|
const parts = yield* Effect.forEach(resolvedParts, (part) =>
|
||||||
|
part.type === "file" && part.mime.startsWith("image/")
|
||||||
|
? image.normalize(part)
|
||||||
|
: Effect.succeed(part),
|
||||||
)
|
)
|
||||||
|
|
||||||
const parsed = MessageV2.Info.zod.safeParse(info)
|
const parsed = MessageV2.Info.zod.safeParse(info)
|
||||||
@@ -1368,7 +1376,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
return { info, parts }
|
return { info, parts }
|
||||||
}, Effect.scoped)
|
}, Effect.scoped)
|
||||||
|
|
||||||
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
|
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error> = Effect.fn("SessionPrompt.prompt")(
|
||||||
function* (input: PromptInput) {
|
function* (input: PromptInput) {
|
||||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||||
yield* revert.cleanup(session)
|
yield* revert.cleanup(session)
|
||||||
@@ -1788,6 +1796,7 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(SessionRevert.defaultLayer),
|
Layer.provide(SessionRevert.defaultLayer),
|
||||||
Layer.provide(SessionSummary.defaultLayer),
|
Layer.provide(SessionSummary.defaultLayer),
|
||||||
|
Layer.provide(Image.defaultLayer),
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
Agent.defaultLayer,
|
Agent.defaultLayer,
|
||||||
|
|||||||
78
packages/opencode/test/image/image.test.ts
Normal file
78
packages/opencode/test/image/image.test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Cause, Effect, Exit, Layer } from "effect"
|
||||||
|
import { Image } from "@/image/image"
|
||||||
|
import { MessageID, PartID, SessionID } from "@/session/schema"
|
||||||
|
import { TestConfig } from "../fixture/config"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
const it = testEffect(Layer.mergeAll(Image.layer.pipe(Layer.provide(TestConfig.layer()))))
|
||||||
|
const tiny = testEffect(
|
||||||
|
Layer.mergeAll(
|
||||||
|
Image.layer.pipe(
|
||||||
|
Layer.provide(TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
function part(mime: string, data: string) {
|
||||||
|
return {
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: MessageID.ascending(),
|
||||||
|
sessionID: SessionID.make("ses_test"),
|
||||||
|
type: "file" as const,
|
||||||
|
mime,
|
||||||
|
url: `data:${mime};base64,${data}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Image", () => {
|
||||||
|
it.effect("normalizes generated png and jpeg attachments", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
|
||||||
|
const source = new photon.PhotonImage(
|
||||||
|
new Uint8Array(Array.from({ length: 64 * 64 * 4 }, (_, index) => (index % 4 === 3 ? 255 : index % 251))),
|
||||||
|
64,
|
||||||
|
64,
|
||||||
|
)
|
||||||
|
const image = yield* Image.Service
|
||||||
|
const results = yield* Effect.all([
|
||||||
|
image.normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64"))),
|
||||||
|
image.normalize(part("image/jpeg", Buffer.from(source.get_bytes_jpeg(90)).toString("base64"))),
|
||||||
|
])
|
||||||
|
|
||||||
|
source.free()
|
||||||
|
expect(results.map((result) => result.url.startsWith(`data:${result.mime};base64,`))).toEqual([true, true])
|
||||||
|
expect(results.every((result) => result.mime === "image/png" || result.mime === "image/jpeg")).toBe(true)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("accepts webp attachments that are already within limits", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const image = yield* Image.Service
|
||||||
|
const input = part("image/webp", "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA")
|
||||||
|
|
||||||
|
expect(yield* image.normalize(input)).toEqual(input)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
tiny.effect("fails with a typed size error when no resized candidate fits", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
|
||||||
|
const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 4 }, () => 255)), 1, 1)
|
||||||
|
const image = yield* Image.Service
|
||||||
|
const exit = yield* image.normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64"))).pipe(Effect.exit)
|
||||||
|
|
||||||
|
source.free()
|
||||||
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
if (Exit.isFailure(exit)) {
|
||||||
|
const error = Cause.squash(exit.cause)
|
||||||
|
expect(error).toBeInstanceOf(Image.SizeError)
|
||||||
|
if (error instanceof Image.SizeError) {
|
||||||
|
expect(error.width).toBe(1)
|
||||||
|
expect(error.height).toBe(1)
|
||||||
|
expect(error.max).toBe(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -5,6 +5,7 @@ import * as Stream from "effect/Stream"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
|
import { Image } from "@/image/image"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { LLM } from "../../src/session/llm"
|
import { LLM } from "../../src/session/llm"
|
||||||
import { SessionCompaction } from "../../src/session/compaction"
|
import { SessionCompaction } from "../../src/session/compaction"
|
||||||
@@ -278,7 +279,7 @@ function llm() {
|
|||||||
function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fake(), config = Config.defaultLayer) {
|
function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fake(), config = Config.defaultLayer) {
|
||||||
const bus = Bus.layer
|
const bus = Bus.layer
|
||||||
const status = SessionStatus.layer.pipe(Layer.provide(bus))
|
const status = SessionStatus.layer.pipe(Layer.provide(bus))
|
||||||
const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary))
|
const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(Image.defaultLayer))
|
||||||
return ManagedRuntime.make(
|
return ManagedRuntime.make(
|
||||||
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
|
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
|
||||||
Layer.provide(provider.layer),
|
Layer.provide(provider.layer),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Agent } from "../../src/agent/agent"
|
|||||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
|
import { Image } from "@/image/image"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
import { Plugin } from "../../src/plugin"
|
import { Plugin } from "../../src/plugin"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
@@ -168,7 +169,7 @@ const deps = Layer.mergeAll(
|
|||||||
).pipe(Layer.provideMerge(infra))
|
).pipe(Layer.provideMerge(infra))
|
||||||
const env = Layer.mergeAll(
|
const env = Layer.mergeAll(
|
||||||
TestLLMServer.layer,
|
TestLLMServer.layer,
|
||||||
SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)),
|
SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(Image.defaultLayer), Layer.provideMerge(deps)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const it = testEffect(env)
|
const it = testEffect(env)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Plugin } from "../../src/plugin"
|
|||||||
import { Provider as ProviderSvc } from "@/provider/provider"
|
import { Provider as ProviderSvc } from "@/provider/provider"
|
||||||
import { Env } from "../../src/env"
|
import { Env } from "../../src/env"
|
||||||
import { Git } from "../../src/git"
|
import { Git } from "../../src/git"
|
||||||
|
import { Image } from "../../src/image/image"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { Question } from "../../src/question"
|
import { Question } from "../../src/question"
|
||||||
import { Todo } from "../../src/session/todo"
|
import { Todo } from "../../src/session/todo"
|
||||||
@@ -187,12 +188,13 @@ function makeHttp() {
|
|||||||
Layer.provideMerge(deps),
|
Layer.provideMerge(deps),
|
||||||
)
|
)
|
||||||
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
||||||
const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps))
|
const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(Image.defaultLayer), Layer.provideMerge(deps))
|
||||||
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
|
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
|
||||||
return Layer.mergeAll(
|
return Layer.mergeAll(
|
||||||
TestLLMServer.layer,
|
TestLLMServer.layer,
|
||||||
SessionPrompt.layer.pipe(
|
SessionPrompt.layer.pipe(
|
||||||
Layer.provide(SessionRevert.defaultLayer),
|
Layer.provide(SessionRevert.defaultLayer),
|
||||||
|
Layer.provide(Image.defaultLayer),
|
||||||
Layer.provide(summary),
|
Layer.provide(summary),
|
||||||
Layer.provideMerge(run),
|
Layer.provideMerge(run),
|
||||||
Layer.provideMerge(compact),
|
Layer.provideMerge(compact),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { Plugin } from "../../src/plugin"
|
|||||||
import { Provider as ProviderSvc } from "@/provider/provider"
|
import { Provider as ProviderSvc } from "@/provider/provider"
|
||||||
import { Env } from "../../src/env"
|
import { Env } from "../../src/env"
|
||||||
import { Question } from "../../src/question"
|
import { Question } from "../../src/question"
|
||||||
|
import { Image } from "../../src/image/image"
|
||||||
import { Skill } from "../../src/skill"
|
import { Skill } from "../../src/skill"
|
||||||
import { SystemPrompt } from "../../src/session/system"
|
import { SystemPrompt } from "../../src/session/system"
|
||||||
import { Todo } from "../../src/session/todo"
|
import { Todo } from "../../src/session/todo"
|
||||||
@@ -137,13 +138,18 @@ function makeHttp() {
|
|||||||
Layer.provideMerge(deps),
|
Layer.provideMerge(deps),
|
||||||
)
|
)
|
||||||
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
|
||||||
const proc = SessionProcessor.layer.pipe(Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(deps))
|
const proc = SessionProcessor.layer.pipe(
|
||||||
|
Layer.provide(SessionSummary.defaultLayer),
|
||||||
|
Layer.provide(Image.defaultLayer),
|
||||||
|
Layer.provideMerge(deps),
|
||||||
|
)
|
||||||
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
|
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
|
||||||
return Layer.mergeAll(
|
return Layer.mergeAll(
|
||||||
TestLLMServer.layer,
|
TestLLMServer.layer,
|
||||||
SessionSummary.defaultLayer,
|
SessionSummary.defaultLayer,
|
||||||
SessionPrompt.layer.pipe(
|
SessionPrompt.layer.pipe(
|
||||||
Layer.provide(SessionRevert.defaultLayer),
|
Layer.provide(SessionRevert.defaultLayer),
|
||||||
|
Layer.provide(Image.defaultLayer),
|
||||||
Layer.provide(SessionSummary.defaultLayer),
|
Layer.provide(SessionSummary.defaultLayer),
|
||||||
Layer.provideMerge(run),
|
Layer.provideMerge(run),
|
||||||
Layer.provideMerge(compact),
|
Layer.provideMerge(compact),
|
||||||
|
|||||||
@@ -1132,6 +1132,17 @@ export type McpRemoteConfig = {
|
|||||||
*/
|
*/
|
||||||
export type LayoutConfig = "auto" | "stretch"
|
export type LayoutConfig = "auto" | "stretch"
|
||||||
|
|
||||||
|
export type ImageAttachmentConfig = {
|
||||||
|
auto_resize?: boolean
|
||||||
|
max_width?: number
|
||||||
|
max_height?: number
|
||||||
|
max_base64_bytes?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttachmentConfig = {
|
||||||
|
image?: ImageAttachmentConfig
|
||||||
|
}
|
||||||
|
|
||||||
export type Config = {
|
export type Config = {
|
||||||
$schema?: string
|
$schema?: string
|
||||||
shell?: string
|
shell?: string
|
||||||
@@ -1246,6 +1257,7 @@ export type Config = {
|
|||||||
tools?: {
|
tools?: {
|
||||||
[key: string]: boolean
|
[key: string]: boolean
|
||||||
}
|
}
|
||||||
|
attachment?: AttachmentConfig
|
||||||
enterprise?: {
|
enterprise?: {
|
||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
|
|||||||
14
patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch
Normal file
14
patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
diff --git a/photon_rs.js b/photon_rs.js
|
||||||
|
index 8f4144d..b83e9a9 100644
|
||||||
|
--- a/photon_rs.js
|
||||||
|
+++ b/photon_rs.js
|
||||||
|
@@ -4509,7 +4509,8 @@ module.exports.__wbindgen_init_externref_table = function() {
|
||||||
|
;
|
||||||
|
};
|
||||||
|
|
||||||
|
-const path = require('path').join(__dirname, 'photon_rs_bg.wasm');
|
||||||
|
+// Allow opencode's Bun compiled binary to point photon-node at its embedded wasm asset.
|
||||||
|
+const path = globalThis.__OPENCODE_PHOTON_WASM_PATH || require('path').join(__dirname, 'photon_rs_bg.wasm');
|
||||||
|
const bytes = require('fs').readFileSync(path);
|
||||||
|
|
||||||
|
const wasmModule = new WebAssembly.Module(bytes);
|
||||||
Reference in New Issue
Block a user