mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ffa0645572 | |||
| c4fa5e6619 | |||
| 6ec17e5d55 | |||
| 0405670cab | |||
| caf727ecb7 | |||
| 9c38358197 | |||
| dd6c95fdc7 | |||
| 6f4b9504e5 | |||
| dbfbb13ccc | |||
| 99b2e78d75 | |||
| 023f76f6fc | |||
| 69d7c2add7 | |||
| cf651bc41b | |||
| 8d80365ef4 | |||
| 3ea8895299 | |||
| 3304e79827 | |||
| cf2c1bf425 | |||
| e0810753f2 | |||
| 7243bd9e12 | |||
| dd50d457b0 | |||
| 592ef7433a | |||
| fd97d789ef | |||
| 7111f93836 | |||
| e770415fd5 | |||
| 96dc560833 | |||
| eb4ff91c2d | |||
| a81c04fd31 | |||
| 63e2054f50 | |||
| 09a38f1984 | |||
| 065b108bba | |||
| 39fdd67123 | |||
| 44b6938b2a | |||
| fc7e4cf93e | |||
| 43c08387f1 | |||
| 407ac2d8aa | |||
| 9445b4d940 | |||
| 0409e6884d | |||
| ace77a5cf7 | |||
| f5a487ffcd | |||
| 455b5d3165 | |||
| a2c4c7a64b | |||
| e93fa06d05 | |||
| fa4ec98e1d | |||
| 4c9a4309ce | |||
| c0ba62b972 | |||
| 8cb9361149 | |||
| 642950a763 | |||
| df1f42d80f | |||
| 0c18d06c9f | |||
| 17c1e9b083 | |||
| 0b2e2cbab0 | |||
| 79d0077f63 | |||
| 0aa4031dd0 | |||
| f971aa0719 | |||
| 61b7caa0b5 | |||
| 98c29075b4 | |||
| 6d50023457 |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description: translate English to other languages
|
description: translate English to other languages
|
||||||
model: opencode/claude-opus-4-8
|
model: opencode/gpt-5.6-sol
|
||||||
---
|
---
|
||||||
|
|
||||||
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
|
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
|
||||||
|
|||||||
@@ -8,6 +8,25 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8"
|
|||||||
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
||||||
export const deployAws = $app.stage === awsStage
|
export const deployAws = $app.stage === awsStage
|
||||||
|
|
||||||
|
if ($app.stage === "production") {
|
||||||
|
new cloudflare.DnsRecord("TrustCenter", {
|
||||||
|
zoneId: zoneID,
|
||||||
|
name: "trust.opencode.ai",
|
||||||
|
type: "CNAME",
|
||||||
|
content: "3a69a5bb27875189.vercel-dns-016.com",
|
||||||
|
proxied: false,
|
||||||
|
ttl: 60,
|
||||||
|
})
|
||||||
|
|
||||||
|
new cloudflare.DnsRecord("TrustCenterVerification", {
|
||||||
|
zoneId: zoneID,
|
||||||
|
name: "opencode.ai",
|
||||||
|
type: "TXT",
|
||||||
|
content: "compai-domain-verification=org_6993a99c6200a2d642bb115d",
|
||||||
|
ttl: 60,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
new cloudflare.RegionalHostname("RegionalHostname", {
|
new cloudflare.RegionalHostname("RegionalHostname", {
|
||||||
hostname: domain,
|
hostname: domain,
|
||||||
regionKey: "us",
|
regionKey: "us",
|
||||||
|
|||||||
+66
-33
@@ -8,6 +8,8 @@
|
|||||||
makeWrapper,
|
makeWrapper,
|
||||||
writableTmpDirAsHomeHook,
|
writableTmpDirAsHomeHook,
|
||||||
autoPatchelfHook,
|
autoPatchelfHook,
|
||||||
|
copyDesktopItems,
|
||||||
|
makeDesktopItem,
|
||||||
opencode,
|
opencode,
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
@@ -27,9 +29,12 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
nodejs
|
nodejs
|
||||||
makeWrapper
|
makeWrapper
|
||||||
writableTmpDirAsHomeHook
|
writableTmpDirAsHomeHook
|
||||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
]
|
||||||
|
++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
autoPatchelfHook
|
autoPatchelfHook
|
||||||
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
|
copyDesktopItems
|
||||||
|
]
|
||||||
|
++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||||
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
||||||
darwin.autoSignDarwinBinariesHook
|
darwin.autoSignDarwinBinariesHook
|
||||||
];
|
];
|
||||||
@@ -38,20 +43,37 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
(lib.getLib stdenv.cc.cc)
|
(lib.getLib stdenv.cc.cc)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem {
|
||||||
|
name = "ai.opencode.desktop";
|
||||||
|
desktopName = "OpenCode";
|
||||||
|
exec = "opencode-desktop %U";
|
||||||
|
icon = "ai.opencode.desktop";
|
||||||
|
# Electron 41 derives X11 WM_CLASS from app.name.
|
||||||
|
startupWMClass = "OpenCode";
|
||||||
|
categories = [ "Development" ];
|
||||||
|
});
|
||||||
|
|
||||||
env = opencode.env // {
|
env = opencode.env // {
|
||||||
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||||
};
|
};
|
||||||
|
|
||||||
# https://github.com/electron/electron/issues/31121
|
postPatch =
|
||||||
# mac builds use a .app bundle which doesnt have this issue
|
# NOTE: Relax Bun version check to be a warning instead of an error
|
||||||
postPatch = lib.optionalString stdenv.isLinux ''
|
''
|
||||||
BASE_PATH=packages/desktop
|
substituteInPlace packages/script/src/index.ts \
|
||||||
FILES=(src/main/windows.ts)
|
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
|
||||||
for file in "''${FILES[@]}"; do
|
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
|
||||||
substituteInPlace $BASE_PATH/$file \
|
''
|
||||||
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
|
# https://github.com/electron/electron/issues/31121
|
||||||
done
|
# mac builds use a .app bundle which doesnt have this issue
|
||||||
'';
|
+ lib.optionalString stdenv.isLinux ''
|
||||||
|
BASE_PATH=packages/desktop
|
||||||
|
FILES=(src/main/windows.ts)
|
||||||
|
for file in "''${FILES[@]}"; do
|
||||||
|
substituteInPlace $BASE_PATH/$file \
|
||||||
|
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
|
||||||
preBuild = ''
|
preBuild = ''
|
||||||
cp -r "${electron.dist}" $HOME/.electron-dist
|
cp -r "${electron.dist}" $HOME/.electron-dist
|
||||||
@@ -76,27 +98,38 @@ stdenv.mkDerivation (finalAttrs: {
|
|||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
|
|
||||||
installPhase =
|
installPhase = ''
|
||||||
''
|
runHook preInstall
|
||||||
runHook preInstall
|
''
|
||||||
''
|
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||||
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
mkdir -p $out/Applications
|
||||||
mkdir -p $out/Applications
|
mv dist/mac*/*.app $out/Applications
|
||||||
mv dist/mac*/*.app $out/Applications
|
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
||||||
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
''
|
||||||
''
|
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||||
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
mkdir -p $out/opt/opencode-desktop
|
||||||
mkdir -p $out/opt/opencode-desktop
|
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
||||||
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
install -Dm644 resources/icons/32x32.png \
|
||||||
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
"$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png"
|
||||||
--inherit-argv0 \
|
install -Dm644 resources/icons/64x64.png \
|
||||||
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
"$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png"
|
||||||
--add-flags $out/opt/opencode-desktop/resources/app.asar \
|
install -Dm644 resources/icons/128x128.png \
|
||||||
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
|
"$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png"
|
||||||
''
|
install -Dm644 resources/icons/128x128@2x.png \
|
||||||
+ ''
|
"$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png"
|
||||||
runHook postInstall
|
install -Dm644 resources/icons/icon.png \
|
||||||
'';
|
"$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png"
|
||||||
|
install -Dm644 resources/ai.opencode.desktop.metainfo.xml \
|
||||||
|
"$out/share/metainfo/ai.opencode.desktop.metainfo.xml"
|
||||||
|
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
||||||
|
--inherit-argv0 \
|
||||||
|
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
||||||
|
--add-flags $out/opt/opencode-desktop/resources/app.asar \
|
||||||
|
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
|
||||||
|
''
|
||||||
|
+ ''
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
autoPatchelfIgnoreMissingDeps = [
|
autoPatchelfIgnoreMissingDeps = [
|
||||||
"libc.musl-x86_64.so.1"
|
"libc.musl-x86_64.so.1"
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=",
|
"x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=",
|
||||||
"aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=",
|
"aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=",
|
||||||
"aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=",
|
"aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=",
|
||||||
"x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw="
|
"x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
"dev:www": "bun run --cwd packages/www dev",
|
"dev:www": "bun run --cwd packages/www dev",
|
||||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||||
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||||
"typecheck": "bun turbo typecheck --concurrency=3",
|
"typecheck": "bun turbo typecheck --concurrency=3",
|
||||||
"typecheck:profile": "bun script/profile-typecheck.ts",
|
"typecheck:profile": "bun script/profile-typecheck.ts",
|
||||||
|
|||||||
+132
-5
@@ -29,22 +29,149 @@ Run `LLMClient.stream(request)` instead of `generate` when you want incremental
|
|||||||
Use `Image.generate` with an image model for direct asset generation:
|
Use `Image.generate` with an image model for direct asset generation:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { Image } from "@opencode-ai/ai"
|
import { Image, ImageInput } from "@opencode-ai/ai"
|
||||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||||
|
|
||||||
const program = Effect.gen(function* () {
|
const program = Effect.gen(function* () {
|
||||||
const response = yield* Image.generate({
|
const response = yield* Image.generate({
|
||||||
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).image("gpt-image-2"),
|
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).image("gpt-image-2"),
|
||||||
prompt: "A robot tending a rooftop garden",
|
prompt: "A robot tending a rooftop garden",
|
||||||
count: 2,
|
options: {
|
||||||
size: { width: 1024, height: 1024 },
|
n: 2,
|
||||||
providerOptions: { openai: { quality: "high", outputFormat: "webp" } },
|
size: "1024x1024",
|
||||||
|
quality: "high", // inferred from the OpenAI image model
|
||||||
|
outputFormat: "webp",
|
||||||
|
future_option: true, // unknown native options pass through unchanged
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return response.images // GeneratedImage[] with owned bytes or a provider URL
|
return response.images // GeneratedImage[] with owned bytes or a provider URL
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const response =
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model,
|
||||||
|
prompt: "Combine these product photos into one studio scene",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(firstBytes, "image/png"),
|
||||||
|
ImageInput.url("https://example.com/second.webp"),
|
||||||
|
ImageInput.file("file_123"),
|
||||||
|
],
|
||||||
|
options,
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`ImageInput.fileUri(uri, mediaType)` represents provider file URIs such as Gemini Files. Raw strings are not
|
||||||
|
accepted as image inputs, avoiding ambiguity between base64, URLs, and provider IDs. Empty or omitted `images`
|
||||||
|
uses text-to-image generation; a non-empty array selects the provider's edit behavior without enforcing provider
|
||||||
|
image-count limits locally. `images` is the only common image-editing field. OpenAI uses multipart for byte/data-URL
|
||||||
|
edits and its JSON reference body for URL or file-ID edits. Its provider-specific `options.mask` accepts an
|
||||||
|
`ImageInput` for inpainting:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model: OpenAI.configure({ apiKey }).image("gpt-image-2"),
|
||||||
|
prompt,
|
||||||
|
images: [ImageInput.bytes(sourceBytes, "image/png")],
|
||||||
|
options: { mask: ImageInput.bytes(maskBytes, "image/png") },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The OpenAI adapter extracts this helper value into the edit request's native `mask` field rather than passing the
|
||||||
|
tagged `ImageInput` object through as an ordinary option. On multipart requests, `http.body` can override option
|
||||||
|
fields but not structural `model`, `prompt`, `image[]`, or `mask` fields, and the transport owns the multipart
|
||||||
|
`Content-Type` boundary. For JSON requests, `http.body` remains the final raw-native overlay. Gemini does not fetch
|
||||||
|
public HTTP URLs, and hosted Z.ai image generation does not accept image inputs. These cases fail with
|
||||||
|
`InvalidRequest` before network I/O.
|
||||||
|
|
||||||
|
Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const model = OpenAI.configure({ apiKey }).image("gpt-image-2")
|
||||||
|
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
options: { quality: "medium" },
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
xAI image models use the same request API with xAI-native controls:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model: XAI.configure({ apiKey }).image("any-model-id"),
|
||||||
|
prompt,
|
||||||
|
options: {
|
||||||
|
n: 2,
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
resolution: "1k",
|
||||||
|
responseFormat: "b64_json",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Google's current Gemini image models use the same direct API:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Google } from "@opencode-ai/ai/providers"
|
||||||
|
|
||||||
|
const googleProgram = Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model: Google.configure({ apiKey }).image("any-model-id"),
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
options: {
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
imageSize: "2K",
|
||||||
|
seed: 42,
|
||||||
|
thinkingLevel: "HIGH",
|
||||||
|
includeThoughts: true,
|
||||||
|
futureOption: true,
|
||||||
|
},
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.images
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Google image options are request-scoped and inferred from the selected model. Known fields autocomplete while
|
||||||
|
future string values and arbitrary native Gemini `generationConfig` fields remain available. Native fields override
|
||||||
|
their mapped aliases, and `http.body` is the final deep overlay. The selected model ID is sent to Gemini
|
||||||
|
`generateContent` without a local allowlist.
|
||||||
|
|
||||||
|
Z.ai image models infer open Z.ai-native options from the selected model:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model: ZAI.configure({ apiKey }).image("any-model-id"),
|
||||||
|
prompt,
|
||||||
|
options: {
|
||||||
|
quality: "hd",
|
||||||
|
userID: "user-123",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Z.ai does not include trustworthy MIME metadata for output URLs, so generated images use
|
||||||
|
`application/octet-stream`. Output URLs expire after 30 days; download and persist them promptly if they must
|
||||||
|
remain available.
|
||||||
|
|
||||||
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
|
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -145,7 +272,7 @@ const gateway = CloudflareAIGateway.configure({
|
|||||||
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
||||||
```
|
```
|
||||||
|
|
||||||
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
|
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
|
||||||
|
|
||||||
### Package-like entrypoints
|
### Package-like entrypoints
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"setup:recording-env": "bun run script/setup-recording-env.ts",
|
"setup:recording-env": "bun run script/setup-recording-env.ts",
|
||||||
"test": "bun test --timeout 30000 --only-failures",
|
"test": "bun test --timeout 30000 --only-failures",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.types.json",
|
||||||
"build": "tsc -p tsconfig.build.json"
|
"build": "tsc -p tsconfig.build.json"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { RequestExecutor } from "./route/executor"
|
import { RequestExecutor } from "./route/executor"
|
||||||
import type { ImageRequest, ImageResponse } from "./image"
|
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image"
|
||||||
import type { LLMError } from "./schema"
|
import type { LLMError } from "./schema"
|
||||||
|
|
||||||
export type Execute = RequestExecutor.Interface["execute"]
|
export type Execute = RequestExecutor.Interface["execute"]
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly generate: (request: ImageRequest) => Effect.Effect<ImageResponse, LLMError>
|
readonly generate: <Options extends ImageOptions>(
|
||||||
|
request: ImageRequestFor<Options>,
|
||||||
|
) => Effect.Effect<ImageResponse, LLMError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
|
||||||
|
|
||||||
export const generate = (request: ImageRequest): Effect.Effect<ImageResponse, LLMError> =>
|
export const generate = <Options extends ImageOptions>(
|
||||||
|
request: ImageRequestFor<Options>,
|
||||||
|
): Effect.Effect<ImageResponse, LLMError> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const client = yield* Service
|
const client = yield* Service
|
||||||
return yield* client.generate(request)
|
return yield* client.generate(request)
|
||||||
|
|||||||
+90
-40
@@ -1,80 +1,120 @@
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { HttpOptions, InvalidRequestReason, LLMError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
|
import { HttpOptions, InvalidRequestReason, LLMError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
|
||||||
import { ImageClient, type Execute as ImageExecute } from "./image-client"
|
import { ImageClient, Service, type Execute as ImageExecute } from "./image-client"
|
||||||
|
|
||||||
export interface ImageRoute {
|
export interface ImageRoute<Options extends ImageOptions = ImageOptions> {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly generate: (request: ImageRequest, execute: ImageExecute) => Effect.Effect<ImageResponse, LLMError>
|
readonly generate: (
|
||||||
|
request: ImageRequestFor<Options>,
|
||||||
|
execute: ImageExecute,
|
||||||
|
) => Effect.Effect<ImageResponse, LLMError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImageModel {
|
export type ImageOptions = Record<string, unknown>
|
||||||
|
|
||||||
|
export class ImageModel<Options extends ImageOptions = ImageOptions> {
|
||||||
|
declare protected readonly _Options: (options: Options) => Options
|
||||||
readonly id: ModelID
|
readonly id: ModelID
|
||||||
readonly provider: ProviderID
|
readonly provider: ProviderID
|
||||||
readonly route: ImageRoute
|
readonly route: ImageRoute<Options>
|
||||||
readonly defaults?: ImageModelDefaults
|
readonly http?: HttpOptions
|
||||||
|
|
||||||
constructor(input: ImageModel.Input) {
|
constructor(input: ImageModel.Input<Options>) {
|
||||||
this.id = input.id
|
this.id = input.id
|
||||||
this.provider = input.provider
|
this.provider = input.provider
|
||||||
this.route = input.route
|
this.route = input.route
|
||||||
this.defaults = input.defaults
|
this.http = input.http
|
||||||
}
|
}
|
||||||
|
|
||||||
static make(input: ImageModel.MakeInput) {
|
static make<Options extends ImageOptions = ImageOptions>(input: ImageModel.MakeInput<Options>) {
|
||||||
return new ImageModel({
|
return new ImageModel<Options>({
|
||||||
id: ModelID.make(input.id),
|
id: ModelID.make(input.id),
|
||||||
provider: ProviderID.make(input.provider),
|
provider: ProviderID.make(input.provider),
|
||||||
route: input.route,
|
route: input.route,
|
||||||
defaults: input.defaults,
|
http: input.http,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace ImageModel {
|
export namespace ImageModel {
|
||||||
export interface Input {
|
export interface Input<Options extends ImageOptions = ImageOptions> {
|
||||||
readonly id: ModelID
|
readonly id: ModelID
|
||||||
readonly provider: ProviderID
|
readonly provider: ProviderID
|
||||||
readonly route: ImageRoute
|
readonly route: ImageRoute<Options>
|
||||||
readonly defaults?: ImageModelDefaults
|
readonly http?: HttpOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MakeInput extends Omit<Input, "id" | "provider"> {
|
export interface MakeInput<Options extends ImageOptions = ImageOptions>
|
||||||
|
extends Omit<Input<Options>, "id" | "provider"> {
|
||||||
readonly id: string | ModelID
|
readonly id: string | ModelID
|
||||||
readonly provider: string | ProviderID
|
readonly provider: string | ProviderID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageModelDefaults {
|
|
||||||
readonly providerOptions?: Record<string, Record<string, unknown>>
|
|
||||||
readonly http?: HttpOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
|
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
|
||||||
expected: "Image.Model",
|
expected: "Image.Model",
|
||||||
})
|
})
|
||||||
|
|
||||||
export const ImageSize = Schema.Struct({
|
const ImageBytesInput = Schema.Struct({
|
||||||
width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
|
type: Schema.Literal("bytes"),
|
||||||
height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
|
data: Schema.Uint8Array,
|
||||||
}).annotate({ identifier: "Image.Size" })
|
mediaType: Schema.String,
|
||||||
export type ImageSize = Schema.Schema.Type<typeof ImageSize>
|
})
|
||||||
|
const ImageUrlInput = Schema.Struct({
|
||||||
|
type: Schema.Literal("url"),
|
||||||
|
url: Schema.String,
|
||||||
|
})
|
||||||
|
const ImageFileIDInput = Schema.Struct({
|
||||||
|
type: Schema.Literal("file-id"),
|
||||||
|
id: Schema.String,
|
||||||
|
})
|
||||||
|
const ImageFileURIInput = Schema.Struct({
|
||||||
|
type: Schema.Literal("file-uri"),
|
||||||
|
uri: Schema.String,
|
||||||
|
mediaType: Schema.String,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ImageInputSchema = Schema.Union([
|
||||||
|
ImageBytesInput,
|
||||||
|
ImageUrlInput,
|
||||||
|
ImageFileIDInput,
|
||||||
|
ImageFileURIInput,
|
||||||
|
]).pipe(Schema.toTaggedUnion("type"))
|
||||||
|
export type ImageInput = Schema.Schema.Type<typeof ImageInputSchema>
|
||||||
|
|
||||||
|
export const ImageInput = {
|
||||||
|
bytes: (data: Uint8Array, mediaType: string): ImageInput => ({ type: "bytes", data, mediaType }),
|
||||||
|
url: (url: string): ImageInput => ({ type: "url", url }),
|
||||||
|
file: (id: string): ImageInput => ({ type: "file-id", id }),
|
||||||
|
fileUri: (uri: string, mediaType: string): ImageInput => ({ type: "file-uri", uri, mediaType }),
|
||||||
|
} as const
|
||||||
|
|
||||||
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
|
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
|
||||||
model: ImageModelSchema,
|
model: ImageModelSchema,
|
||||||
prompt: Schema.String,
|
prompt: Schema.String,
|
||||||
count: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
images: Schema.optional(Schema.Array(ImageInputSchema)),
|
||||||
size: Schema.optional(ImageSize),
|
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
aspectRatio: Schema.optional(Schema.String),
|
|
||||||
seed: Schema.optional(Schema.Number),
|
|
||||||
providerOptions: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))),
|
|
||||||
http: Schema.optional(HttpOptions),
|
http: Schema.optional(HttpOptions),
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
}) {
|
||||||
}) {}
|
declare protected readonly _ImageRequest: void
|
||||||
|
|
||||||
export type ImageRequestInput = Omit<ConstructorParameters<typeof ImageRequest>[0], "http"> & {
|
|
||||||
readonly http?: HttpOptions.Input
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImageRequestFor<Options extends ImageOptions = ImageOptions> = Omit<ImageRequest, "model" | "options"> & {
|
||||||
|
readonly model: ImageModel<Options>
|
||||||
|
readonly options?: Options
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageModelOptions<Model> = Model extends ImageModel<infer Options> ? Options : never
|
||||||
|
|
||||||
|
export type ImageRequestInput<Model extends object = ImageModel> = Omit<
|
||||||
|
ConstructorParameters<typeof ImageRequest>[0],
|
||||||
|
"model" | "options" | "http"
|
||||||
|
> & {
|
||||||
|
readonly model: Model
|
||||||
|
readonly options?: NoInfer<ImageModelOptions<Model>>
|
||||||
|
readonly http?: HttpOptions.Input
|
||||||
|
} & (Model extends ImageModel<ImageModelOptions<Model>> ? unknown : never)
|
||||||
|
|
||||||
export class GeneratedImage extends Schema.Class<GeneratedImage>("Image.Generated")({
|
export class GeneratedImage extends Schema.Class<GeneratedImage>("Image.Generated")({
|
||||||
mediaType: Schema.String,
|
mediaType: Schema.String,
|
||||||
data: Schema.Union([Schema.String, Schema.Uint8Array]),
|
data: Schema.Union([Schema.String, Schema.Uint8Array]),
|
||||||
@@ -91,24 +131,34 @@ export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const request = (input: ImageRequest | ImageRequestInput) => {
|
export function request<const Model extends object>(
|
||||||
|
input: ImageRequestInput<Model>,
|
||||||
|
): ImageRequestFor<ImageModelOptions<Model>>
|
||||||
|
export function request(input: ImageRequest): ImageRequest
|
||||||
|
export function request(input: ImageRequest | ImageRequestInput) {
|
||||||
if (input instanceof ImageRequest) return input
|
if (input instanceof ImageRequest) return input
|
||||||
return new ImageRequest({
|
return new ImageRequest({
|
||||||
...input,
|
...input,
|
||||||
|
model: input.model as unknown as ImageModel,
|
||||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generate = (input: ImageRequest | ImageRequestInput) =>
|
export function generate<const Model extends object>(
|
||||||
Effect.try({
|
input: ImageRequestInput<Model>,
|
||||||
try: () => request(input),
|
): Effect.Effect<ImageResponse, LLMError, Service>
|
||||||
|
export function generate(input: ImageRequest): Effect.Effect<ImageResponse, LLMError, Service>
|
||||||
|
export function generate(input: ImageRequest | ImageRequestInput) {
|
||||||
|
return Effect.try({
|
||||||
|
try: () => (input instanceof ImageRequest ? input : request(input)),
|
||||||
catch: (error) =>
|
catch: (error) =>
|
||||||
new LLMError({
|
new LLMError({
|
||||||
module: "Image",
|
module: "Image",
|
||||||
method: "generate",
|
method: "generate",
|
||||||
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
|
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
|
||||||
}),
|
}),
|
||||||
}).pipe(Effect.flatMap(ImageClient.generate))
|
}).pipe(Effect.flatMap((request) => ImageClient.generate(request as unknown as ImageRequestFor<ImageOptions>)))
|
||||||
|
}
|
||||||
|
|
||||||
export const Image = {
|
export const Image = {
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export type {
|
|||||||
Service as LLMClientService,
|
Service as LLMClientService,
|
||||||
} from "./route/client"
|
} from "./route/client"
|
||||||
export * from "./schema"
|
export * from "./schema"
|
||||||
export { GeneratedImage, ImageModel, ImageRequest, ImageResponse, ImageSize } from "./image"
|
export { GeneratedImage, ImageInput, ImageInputSchema, ImageModel, ImageRequest, ImageResponse } from "./image"
|
||||||
export type { ImageModelDefaults, ImageRequestInput, ImageRoute } from "./image"
|
export type { ImageModelOptions, ImageOptions, ImageRequestFor, ImageRequestInput, ImageRoute } from "./image"
|
||||||
export { Image } from "./image"
|
export { Image } from "./image"
|
||||||
export { Tool, ToolFailure, toDefinitions } from "./tool"
|
export { Tool, ToolFailure, toDefinitions } from "./tool"
|
||||||
export { ToolRuntime } from "./tool-runtime"
|
export { ToolRuntime } from "./tool-runtime"
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { Effect, Encoding, Schema } from "effect"
|
||||||
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import {
|
||||||
|
GeneratedImage,
|
||||||
|
ImageModel,
|
||||||
|
ImageResponse,
|
||||||
|
type ImageInput,
|
||||||
|
type ImageRequestFor,
|
||||||
|
type ImageRoute,
|
||||||
|
} from "../image"
|
||||||
|
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||||
|
import {
|
||||||
|
InvalidProviderOutputReason,
|
||||||
|
LLMError,
|
||||||
|
Usage,
|
||||||
|
mergeHttpOptions,
|
||||||
|
mergeJsonRecords,
|
||||||
|
type HttpOptions,
|
||||||
|
type ProviderMetadata,
|
||||||
|
} from "../schema"
|
||||||
|
import { ProviderShared } from "./shared"
|
||||||
|
import { ImageInputs } from "./utils/image-input"
|
||||||
|
|
||||||
|
const ADAPTER = "google-images"
|
||||||
|
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
|
||||||
|
export type GoogleImageString<Known extends string> = Known | (string & {})
|
||||||
|
|
||||||
|
export type GoogleImageOptions = {
|
||||||
|
readonly aspectRatio?: GoogleImageString<
|
||||||
|
"1:1" | "2:3" | "3:2" | "3:4" | "4:3" | "4:5" | "5:4" | "9:16" | "16:9" | "21:9"
|
||||||
|
>
|
||||||
|
readonly imageSize?: GoogleImageString<"1K" | "2K" | "4K">
|
||||||
|
readonly seed?: number
|
||||||
|
readonly thinkingLevel?: GoogleImageString<"MINIMAL" | "LOW" | "MEDIUM" | "HIGH">
|
||||||
|
readonly includeThoughts?: boolean
|
||||||
|
} & Record<string, unknown>
|
||||||
|
|
||||||
|
export type GoogleImageBody = Record<string, unknown> & {
|
||||||
|
readonly contents: ReadonlyArray<{
|
||||||
|
readonly role: "user"
|
||||||
|
readonly parts: ReadonlyArray<Record<string, unknown>>
|
||||||
|
}>
|
||||||
|
readonly generationConfig: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const GoogleUsage = Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
|
cachedContentTokenCount: Schema.optional(Schema.Number),
|
||||||
|
thoughtsTokenCount: Schema.optional(Schema.Number),
|
||||||
|
promptTokenCount: Schema.optional(Schema.Number),
|
||||||
|
candidatesTokenCount: Schema.optional(Schema.Number),
|
||||||
|
totalTokenCount: Schema.optional(Schema.Number),
|
||||||
|
promptTokensDetails: Schema.optional(Schema.Unknown),
|
||||||
|
candidatesTokensDetails: Schema.optional(Schema.Unknown),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
)
|
||||||
|
|
||||||
|
const GoogleImageResponse = Schema.Struct({
|
||||||
|
candidates: Schema.optional(
|
||||||
|
Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
index: Schema.optional(Schema.Number),
|
||||||
|
content: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
parts: Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
text: Schema.optional(Schema.String),
|
||||||
|
thought: Schema.optional(Schema.Boolean),
|
||||||
|
thoughtSignature: Schema.optional(Schema.String),
|
||||||
|
inlineData: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
mimeType: Schema.String,
|
||||||
|
data: Schema.String,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
finishReason: Schema.optional(Schema.String),
|
||||||
|
finishMessage: Schema.optional(Schema.String),
|
||||||
|
safetyRatings: Schema.optional(Schema.Unknown),
|
||||||
|
citationMetadata: Schema.optional(Schema.Unknown),
|
||||||
|
groundingMetadata: Schema.optional(Schema.Unknown),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
usageMetadata: Schema.optional(GoogleUsage),
|
||||||
|
modelVersion: Schema.optional(Schema.String),
|
||||||
|
responseId: Schema.optional(Schema.String),
|
||||||
|
promptFeedback: Schema.optional(Schema.Unknown),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ModelInput {
|
||||||
|
readonly id: string
|
||||||
|
readonly auth: AuthDefinition
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeOptions = (options: GoogleImageOptions | undefined) => {
|
||||||
|
const { aspectRatio, imageSize, seed, thinkingLevel, includeThoughts, ...native } = options ?? {}
|
||||||
|
const image = {
|
||||||
|
aspectRatio,
|
||||||
|
imageSize,
|
||||||
|
}
|
||||||
|
const thinkingConfig = {
|
||||||
|
thinkingLevel,
|
||||||
|
includeThoughts,
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
mergeJsonRecords(
|
||||||
|
{
|
||||||
|
responseModalities: ["IMAGE"],
|
||||||
|
imageConfig: Object.values(image).some((value) => value !== undefined) ? image : undefined,
|
||||||
|
seed,
|
||||||
|
thinkingConfig: Object.values(thinkingConfig).some((value) => value !== undefined) ? thinkingConfig : undefined,
|
||||||
|
},
|
||||||
|
native,
|
||||||
|
) ?? { responseModalities: ["IMAGE"] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) =>
|
||||||
|
new LLMError({
|
||||||
|
module: ADAPTER,
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidProviderOutputReason({ message, route: ADAPTER, providerMetadata }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
|
if (!query) return url
|
||||||
|
const next = new URL(url)
|
||||||
|
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||||
|
return next.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model = (input: ModelInput) => {
|
||||||
|
const route: ImageRoute<GoogleImageOptions> = {
|
||||||
|
id: ADAPTER,
|
||||||
|
generate: Effect.fn("GoogleImages.generate")(function* (request: ImageRequestFor<GoogleImageOptions>, execute) {
|
||||||
|
const imageParts = yield* Effect.forEach(request.images ?? [], googleImagePart)
|
||||||
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
|
const requestBody = mergeJsonRecords(
|
||||||
|
{
|
||||||
|
contents: [{ role: "user", parts: [{ text: request.prompt }, ...imageParts] }],
|
||||||
|
generationConfig: nativeOptions(request.options),
|
||||||
|
},
|
||||||
|
http?.body,
|
||||||
|
) as GoogleImageBody
|
||||||
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
|
const url = applyQuery(
|
||||||
|
`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}/models/${request.model.id}:generateContent`,
|
||||||
|
http?.query,
|
||||||
|
)
|
||||||
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: text,
|
||||||
|
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||||
|
})
|
||||||
|
const response = yield* execute(
|
||||||
|
HttpClientRequest.post(url).pipe(
|
||||||
|
HttpClientRequest.setHeaders(headers),
|
||||||
|
HttpClientRequest.bodyText(text, "application/json"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const payload = yield* response.json.pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Failed to read the Google Images response")),
|
||||||
|
)
|
||||||
|
const decoded = yield* Schema.decodeUnknownEffect(GoogleImageResponse)(payload).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Google Images returned an invalid response")),
|
||||||
|
)
|
||||||
|
const candidates = decoded.candidates ?? []
|
||||||
|
const candidateMetadata = candidates.map((candidate, candidateIndex) => ({
|
||||||
|
index: candidate.index ?? candidateIndex,
|
||||||
|
finishReason: candidate.finishReason,
|
||||||
|
finishMessage: candidate.finishMessage,
|
||||||
|
safetyRatings: candidate.safetyRatings,
|
||||||
|
citationMetadata: candidate.citationMetadata,
|
||||||
|
groundingMetadata: candidate.groundingMetadata,
|
||||||
|
parts: (candidate.content?.parts ?? []).map((part) =>
|
||||||
|
part.inlineData === undefined
|
||||||
|
? {
|
||||||
|
type: "text",
|
||||||
|
text: part.text,
|
||||||
|
thought: part.thought,
|
||||||
|
thoughtSignature: part.thoughtSignature,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "inlineData",
|
||||||
|
mediaType: part.inlineData.mimeType,
|
||||||
|
thought: part.thought,
|
||||||
|
thoughtSignature: part.thoughtSignature,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
const encoded = candidates.flatMap((candidate, candidateIndex) =>
|
||||||
|
(candidate.content?.parts ?? []).flatMap((part, partIndex) =>
|
||||||
|
part.inlineData === undefined || part.thought === true
|
||||||
|
? []
|
||||||
|
: [{ candidate, candidateIndex, partIndex, inlineData: part.inlineData }],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const images = yield* Effect.forEach(encoded, (item) =>
|
||||||
|
Effect.fromResult(Encoding.decodeBase64(item.inlineData.data)).pipe(
|
||||||
|
Effect.mapError(() =>
|
||||||
|
invalidOutput(
|
||||||
|
`Google Images candidate ${item.candidateIndex} part ${item.partIndex} contains invalid base64 data`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.map(
|
||||||
|
(data) =>
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType: item.inlineData.mimeType,
|
||||||
|
data,
|
||||||
|
providerMetadata: {
|
||||||
|
google: {
|
||||||
|
candidateIndex: item.candidate.index ?? item.candidateIndex,
|
||||||
|
partIndex: item.partIndex,
|
||||||
|
finishReason: item.candidate.finishReason,
|
||||||
|
safetyRatings: item.candidate.safetyRatings,
|
||||||
|
citationMetadata: item.candidate.citationMetadata,
|
||||||
|
groundingMetadata: item.candidate.groundingMetadata,
|
||||||
|
thoughtSignature: item.candidate.content?.parts[item.partIndex]?.thoughtSignature,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (images.length === 0) {
|
||||||
|
const finishReasons = candidates.flatMap((candidate) =>
|
||||||
|
candidate.finishReason === undefined ? [] : [candidate.finishReason],
|
||||||
|
)
|
||||||
|
return yield* invalidOutput(
|
||||||
|
`Google Images returned no final images${
|
||||||
|
finishReasons.length === 0 ? "" : ` (finish reasons: ${finishReasons.join(", ")})`
|
||||||
|
}; inspect reason.providerMetadata.google for prompt feedback and candidate details`,
|
||||||
|
{
|
||||||
|
google: {
|
||||||
|
promptFeedback: decoded.promptFeedback,
|
||||||
|
candidates: candidateMetadata,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const usage = decoded.usageMetadata
|
||||||
|
const outputTokens =
|
||||||
|
usage?.candidatesTokenCount === undefined
|
||||||
|
? undefined
|
||||||
|
: usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0)
|
||||||
|
return new ImageResponse({
|
||||||
|
images,
|
||||||
|
usage:
|
||||||
|
usage === undefined
|
||||||
|
? undefined
|
||||||
|
: new Usage({
|
||||||
|
inputTokens: usage.promptTokenCount,
|
||||||
|
outputTokens,
|
||||||
|
nonCachedInputTokens: ProviderShared.subtractTokens(
|
||||||
|
usage.promptTokenCount,
|
||||||
|
usage.cachedContentTokenCount,
|
||||||
|
),
|
||||||
|
cacheReadInputTokens: usage.cachedContentTokenCount,
|
||||||
|
reasoningTokens: usage.thoughtsTokenCount,
|
||||||
|
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
||||||
|
providerMetadata: { google: usage },
|
||||||
|
}),
|
||||||
|
providerMetadata: {
|
||||||
|
google: {
|
||||||
|
modelVersion: decoded.modelVersion,
|
||||||
|
responseId: decoded.responseId,
|
||||||
|
promptFeedback: decoded.promptFeedback,
|
||||||
|
candidates: candidateMetadata,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return ImageModel.make<GoogleImageOptions>({ id: input.id, provider: "google", route, http: input.http })
|
||||||
|
}
|
||||||
|
|
||||||
|
const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknown>, LLMError> => {
|
||||||
|
if (image.type === "bytes")
|
||||||
|
return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } })
|
||||||
|
if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } })
|
||||||
|
if (image.type === "url")
|
||||||
|
return ImageInputs.decodeDataUrl(image.url, ADAPTER).pipe(
|
||||||
|
Effect.flatMap((decoded) => {
|
||||||
|
if (decoded === undefined)
|
||||||
|
return Effect.fail(
|
||||||
|
ImageInputs.invalid(
|
||||||
|
ADAPTER,
|
||||||
|
"Google generateContent does not fetch public image URLs; use bytes, a data URL, or a Gemini file URI",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return Effect.succeed({
|
||||||
|
inlineData: { mimeType: decoded.mediaType, data: Encoding.encodeBase64(decoded.data) },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return Effect.fail(
|
||||||
|
ImageInputs.invalid(ADAPTER, "Google generateContent requires Gemini file URIs rather than provider file IDs"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GoogleImages = {
|
||||||
|
model,
|
||||||
|
} as const
|
||||||
@@ -1,42 +1,50 @@
|
|||||||
import { Effect, Encoding, Schema } from "effect"
|
import { Effect, Encoding, Schema } from "effect"
|
||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import {
|
import {
|
||||||
ImageModel,
|
ImageModel,
|
||||||
GeneratedImage,
|
GeneratedImage,
|
||||||
ImageResponse,
|
ImageResponse,
|
||||||
type ImageRequest,
|
type ImageInput,
|
||||||
type ImageModelDefaults,
|
type ImageRequestFor,
|
||||||
type ImageRoute,
|
type ImageRoute,
|
||||||
} from "../image"
|
} from "../image"
|
||||||
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||||
import { InvalidProviderOutputReason, LLMError, Usage, mergeHttpOptions, mergeJsonRecords } from "../schema"
|
import {
|
||||||
|
InvalidProviderOutputReason,
|
||||||
|
LLMError,
|
||||||
|
Usage,
|
||||||
|
mergeHttpOptions,
|
||||||
|
mergeJsonRecords,
|
||||||
|
type HttpOptions,
|
||||||
|
} from "../schema"
|
||||||
import { ProviderShared } from "./shared"
|
import { ProviderShared } from "./shared"
|
||||||
|
import { ImageInputs } from "./utils/image-input"
|
||||||
import { OpenAIImage } from "./utils/openai-image"
|
import { OpenAIImage } from "./utils/openai-image"
|
||||||
|
|
||||||
const ADAPTER = "openai-images"
|
const ADAPTER = "openai-images"
|
||||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||||
export const PATH = "/images/generations"
|
export const PATH = "/images/generations"
|
||||||
|
export const EDIT_PATH = "/images/edits"
|
||||||
|
|
||||||
export interface OpenAIImageOptions {
|
export type OpenAIImageString<Known extends string> = Known | (string & {})
|
||||||
readonly quality?: "auto" | "low" | "medium" | "high"
|
|
||||||
readonly background?: "auto" | "opaque" | "transparent"
|
export type OpenAIImageOptions = {
|
||||||
readonly moderation?: "auto" | "low"
|
readonly mask?: ImageInput
|
||||||
readonly outputFormat?: "png" | "jpeg" | "webp"
|
readonly n?: number
|
||||||
|
readonly size?: OpenAIImageString<
|
||||||
|
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
|
||||||
|
>
|
||||||
|
readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
|
||||||
|
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
|
||||||
|
readonly moderation?: OpenAIImageString<"auto" | "low">
|
||||||
|
readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
|
||||||
readonly outputCompression?: number
|
readonly outputCompression?: number
|
||||||
}
|
} & Record<string, unknown>
|
||||||
|
|
||||||
const OpenAIImageBody = Schema.Struct({
|
export type OpenAIImageBody = Record<string, unknown> & {
|
||||||
model: Schema.String,
|
readonly model: string
|
||||||
prompt: Schema.String,
|
readonly prompt: string
|
||||||
n: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
}
|
||||||
size: Schema.optional(Schema.String),
|
|
||||||
quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])),
|
|
||||||
background: Schema.optional(Schema.Literals(["auto", "opaque", "transparent"])),
|
|
||||||
moderation: Schema.optional(Schema.Literals(["auto", "low"])),
|
|
||||||
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
|
|
||||||
output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
|
|
||||||
})
|
|
||||||
export type OpenAIImageBody = Schema.Schema.Type<typeof OpenAIImageBody>
|
|
||||||
|
|
||||||
const OpenAIImageResponse = Schema.Struct({
|
const OpenAIImageResponse = Schema.Struct({
|
||||||
data: Schema.Array(
|
data: Schema.Array(
|
||||||
@@ -63,26 +71,16 @@ export interface ModelInput {
|
|||||||
readonly auth: AuthDefinition
|
readonly auth: AuthDefinition
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly headers?: Record<string, string>
|
readonly headers?: Record<string, string>
|
||||||
readonly defaults?: ImageModelDefaults
|
readonly http?: HttpOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerOptions = (request: ImageRequest): OpenAIImageOptions => ({
|
const nativeOptions = (options: OpenAIImageOptions | undefined) => {
|
||||||
...request.model.defaults?.providerOptions?.openai,
|
if (!options) return undefined
|
||||||
...request.providerOptions?.openai,
|
const { mask: _, outputFormat, outputCompression, ...native } = options
|
||||||
})
|
|
||||||
|
|
||||||
const body = (request: ImageRequest): OpenAIImageBody => {
|
|
||||||
const options = providerOptions(request)
|
|
||||||
return {
|
return {
|
||||||
model: request.model.id,
|
output_format: outputFormat,
|
||||||
prompt: request.prompt,
|
output_compression: outputCompression,
|
||||||
n: request.count,
|
...native,
|
||||||
size: request.size === undefined ? undefined : `${request.size.width}x${request.size.height}`,
|
|
||||||
quality: options.quality,
|
|
||||||
background: options.background,
|
|
||||||
moderation: options.moderation,
|
|
||||||
output_format: options.outputFormat,
|
|
||||||
output_compression: options.outputCompression,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,45 +98,93 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
|||||||
return next.toString()
|
return next.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROTOCOL_BODY_FIELDS = new Set([
|
|
||||||
"model",
|
|
||||||
"prompt",
|
|
||||||
"n",
|
|
||||||
"size",
|
|
||||||
"quality",
|
|
||||||
"background",
|
|
||||||
"moderation",
|
|
||||||
"output_format",
|
|
||||||
"output_compression",
|
|
||||||
])
|
|
||||||
|
|
||||||
const bodyWithOverlay = Effect.fn("OpenAIImages.bodyWithOverlay")(function* (
|
|
||||||
imageBody: OpenAIImageBody,
|
|
||||||
overlay: Record<string, unknown> | undefined,
|
|
||||||
) {
|
|
||||||
if (!overlay) return imageBody
|
|
||||||
const reserved = Object.keys(overlay).filter((key) => PROTOCOL_BODY_FIELDS.has(key))
|
|
||||||
if (reserved.length > 0)
|
|
||||||
return yield* ProviderShared.invalidRequest(
|
|
||||||
`http.body cannot overlay protocol-owned field(s): ${reserved.join(", ")}`,
|
|
||||||
)
|
|
||||||
return mergeJsonRecords(imageBody, overlay) ?? imageBody
|
|
||||||
})
|
|
||||||
|
|
||||||
export const model = (input: ModelInput) => {
|
export const model = (input: ModelInput) => {
|
||||||
const route: ImageRoute = {
|
const route: ImageRoute<OpenAIImageOptions> = {
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequest, execute) {
|
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor<OpenAIImageOptions>, execute) {
|
||||||
if (request.aspectRatio !== undefined)
|
const mask = request.options?.mask
|
||||||
return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common aspectRatio option")
|
if (mask !== undefined && (request.images?.length ?? 0) === 0)
|
||||||
if (request.seed !== undefined)
|
return yield* ImageInputs.invalid(ADAPTER, "An OpenAI image mask requires at least one input image")
|
||||||
return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common seed option")
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
|
const sourceImages = request.images ?? []
|
||||||
|
const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
|
||||||
|
if (image.type === "bytes") return Effect.succeed({ data: image.data, mediaType: image.mediaType })
|
||||||
|
if (image.type === "url") return ImageInputs.decodeDataUrl(image.url, ADAPTER)
|
||||||
|
return Effect.succeed(undefined)
|
||||||
|
})
|
||||||
|
const multipartMask =
|
||||||
|
mask === undefined
|
||||||
|
? undefined
|
||||||
|
: mask.type === "bytes"
|
||||||
|
? { data: mask.data, mediaType: mask.mediaType }
|
||||||
|
: mask.type === "url"
|
||||||
|
? yield* ImageInputs.decodeDataUrl(mask.url, ADAPTER)
|
||||||
|
: undefined
|
||||||
|
const useMultipart =
|
||||||
|
sourceImages.length > 0 &&
|
||||||
|
multipartImages.every((image) => image !== undefined) &&
|
||||||
|
(mask === undefined || multipartMask !== undefined)
|
||||||
|
const path = sourceImages.length === 0 ? PATH : EDIT_PATH
|
||||||
|
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${path}`, http?.query)
|
||||||
|
|
||||||
const requestBody = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIImageBody))(body(request))
|
if (useMultipart) {
|
||||||
const http = mergeHttpOptions(request.model.defaults?.http, request.http)
|
const form = new FormData()
|
||||||
const overlaidBody = yield* bodyWithOverlay(requestBody, http?.body)
|
form.append("model", request.model.id)
|
||||||
const text = ProviderShared.encodeJson(overlaidBody)
|
form.append("prompt", request.prompt)
|
||||||
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
|
Object.entries(mergeJsonRecords(nativeOptions(request.options), http?.body) ?? {}).forEach(([key, value]) => {
|
||||||
|
if (["model", "prompt", "image", "image[]", "images", "mask"].includes(key)) return
|
||||||
|
form.append(key, typeof value === "string" ? value : ProviderShared.encodeJson(value))
|
||||||
|
})
|
||||||
|
multipartImages.forEach((image, index) => {
|
||||||
|
if (image === undefined) return
|
||||||
|
form.append("image[]", imageBlob(image.data, image.mediaType), `image-${index}`)
|
||||||
|
})
|
||||||
|
if (multipartMask !== undefined)
|
||||||
|
form.append("mask", imageBlob(multipartMask.data, multipartMask.mediaType), "mask")
|
||||||
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: "[multipart/form-data]",
|
||||||
|
headers: Headers.remove(Headers.fromInput({ ...input.headers, ...http?.headers }), "content-type"),
|
||||||
|
})
|
||||||
|
const response = yield* execute(
|
||||||
|
HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.bodyFormData(form)),
|
||||||
|
)
|
||||||
|
return yield* parseResponse(response, request.options, http?.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const references = sourceImages.map((image) => {
|
||||||
|
if (image.type === "bytes") return { image_url: ImageInputs.dataUrl(image) }
|
||||||
|
if (image.type === "url") return { image_url: image.url }
|
||||||
|
if (image.type === "file-id") return { file_id: image.id }
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (references.some((image) => image === undefined))
|
||||||
|
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||||
|
const maskReference =
|
||||||
|
mask === undefined
|
||||||
|
? undefined
|
||||||
|
: mask.type === "bytes"
|
||||||
|
? { image_url: ImageInputs.dataUrl(mask) }
|
||||||
|
: mask.type === "url"
|
||||||
|
? { image_url: mask.url }
|
||||||
|
: mask.type === "file-id"
|
||||||
|
? { file_id: mask.id }
|
||||||
|
: undefined
|
||||||
|
if (mask !== undefined && maskReference === undefined)
|
||||||
|
return yield* ImageInputs.invalid(ADAPTER, "OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs")
|
||||||
|
const requestBody = mergeJsonRecords(
|
||||||
|
{
|
||||||
|
model: request.model.id,
|
||||||
|
prompt: request.prompt,
|
||||||
|
images: references.length === 0 ? undefined : references,
|
||||||
|
mask: maskReference,
|
||||||
|
},
|
||||||
|
nativeOptions(request.options),
|
||||||
|
http?.body,
|
||||||
|
) as OpenAIImageBody
|
||||||
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
const headers = yield* Auth.toEffect(input.auth)({
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
request,
|
request,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -152,55 +198,71 @@ export const model = (input: ModelInput) => {
|
|||||||
HttpClientRequest.bodyText(text, "application/json"),
|
HttpClientRequest.bodyText(text, "application/json"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const payload = yield* response.json.pipe(
|
return yield* parseResponse(response, request.options, http?.body)
|
||||||
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
|
}),
|
||||||
)
|
}
|
||||||
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
|
return ImageModel.make<OpenAIImageOptions>({ id: input.id, provider: "openai", route, http: input.http })
|
||||||
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
|
}
|
||||||
)
|
|
||||||
const format = decoded.output_format ?? providerOptions(request).outputFormat ?? "png"
|
const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (
|
||||||
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
response: HttpClientResponse.HttpClientResponse,
|
||||||
if (item.b64_json)
|
options: OpenAIImageOptions | undefined,
|
||||||
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
overlay: Record<string, unknown> | undefined,
|
||||||
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
|
) {
|
||||||
Effect.map(
|
const payload = yield* response.json.pipe(
|
||||||
(data) =>
|
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
|
||||||
new GeneratedImage({
|
)
|
||||||
mediaType: `image/${format}`,
|
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
|
||||||
data,
|
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
|
||||||
providerMetadata:
|
)
|
||||||
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
const requestBody = mergeJsonRecords(nativeOptions(options), overlay)
|
||||||
}),
|
const format =
|
||||||
),
|
decoded.output_format ?? (typeof requestBody?.output_format === "string" ? requestBody.output_format : "png")
|
||||||
)
|
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||||
if (item.url)
|
if (item.b64_json)
|
||||||
return Effect.succeed(
|
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
|
||||||
|
Effect.map(
|
||||||
|
(data) =>
|
||||||
new GeneratedImage({
|
new GeneratedImage({
|
||||||
mediaType: `image/${format}`,
|
mediaType: `image/${format}`,
|
||||||
data: item.url,
|
data,
|
||||||
providerMetadata:
|
providerMetadata:
|
||||||
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||||
}),
|
}),
|
||||||
)
|
),
|
||||||
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
|
)
|
||||||
})
|
if (item.url)
|
||||||
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
|
return Effect.succeed(
|
||||||
return new ImageResponse({
|
new GeneratedImage({
|
||||||
images,
|
mediaType: `image/${format}`,
|
||||||
usage:
|
data: item.url,
|
||||||
decoded.usage === undefined
|
providerMetadata:
|
||||||
? undefined
|
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||||
: new Usage({
|
}),
|
||||||
inputTokens: decoded.usage.input_tokens,
|
)
|
||||||
outputTokens: decoded.usage.output_tokens,
|
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
|
||||||
totalTokens: decoded.usage.total_tokens,
|
})
|
||||||
providerMetadata: { openai: decoded.usage },
|
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
|
||||||
}),
|
return new ImageResponse({
|
||||||
providerMetadata: { openai: { outputFormat: format } },
|
images,
|
||||||
})
|
usage:
|
||||||
}),
|
decoded.usage === undefined
|
||||||
}
|
? undefined
|
||||||
return ImageModel.make({ id: input.id, provider: "openai", route, defaults: input.defaults })
|
: new Usage({
|
||||||
|
inputTokens: decoded.usage.input_tokens,
|
||||||
|
outputTokens: decoded.usage.output_tokens,
|
||||||
|
totalTokens: decoded.usage.total_tokens,
|
||||||
|
providerMetadata: { openai: decoded.usage },
|
||||||
|
}),
|
||||||
|
providerMetadata: { openai: { outputFormat: format } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageBlob = (data: Uint8Array, mediaType: string) => {
|
||||||
|
const buffer = new ArrayBuffer(data.byteLength)
|
||||||
|
new Uint8Array(buffer).set(data)
|
||||||
|
return new Blob([buffer], { type: mediaType })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OpenAIImages = {
|
export const OpenAIImages = {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Effect, Encoding } from "effect"
|
||||||
|
import type { ImageInput } from "../../image"
|
||||||
|
import { InvalidRequestReason, LLMError } from "../../schema"
|
||||||
|
|
||||||
|
const invalid = (module: string, message: string) =>
|
||||||
|
new LLMError({
|
||||||
|
module,
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidRequestReason({ message }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>) =>
|
||||||
|
`data:${input.mediaType};base64,${Encoding.encodeBase64(input.data)}`
|
||||||
|
|
||||||
|
export const decodeDataUrl = (
|
||||||
|
url: string,
|
||||||
|
module: string,
|
||||||
|
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, LLMError> => {
|
||||||
|
if (!url.startsWith("data:")) return Effect.succeed(undefined)
|
||||||
|
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
|
||||||
|
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
|
||||||
|
return Effect.fromResult(Encoding.decodeBase64(match[2])).pipe(
|
||||||
|
Effect.mapError(() => invalid(module, "Image data URL contains invalid base64 data")),
|
||||||
|
Effect.map((data) => ({ mediaType: match[1], data })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invalidImageInput = invalid
|
||||||
|
|
||||||
|
export const ImageInputs = {
|
||||||
|
dataUrl,
|
||||||
|
decodeDataUrl,
|
||||||
|
invalid: invalidImageInput,
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { Effect, Encoding, Schema } from "effect"
|
||||||
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
|
||||||
|
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||||
|
import {
|
||||||
|
InvalidProviderOutputReason,
|
||||||
|
LLMError,
|
||||||
|
Usage,
|
||||||
|
mergeHttpOptions,
|
||||||
|
mergeJsonRecords,
|
||||||
|
type HttpOptions,
|
||||||
|
} from "../schema"
|
||||||
|
import { ProviderShared, optionalNull } from "./shared"
|
||||||
|
import { ImageInputs } from "./utils/image-input"
|
||||||
|
|
||||||
|
const ADAPTER = "xai-images"
|
||||||
|
export const DEFAULT_BASE_URL = "https://api.x.ai/v1"
|
||||||
|
export const PATH = "/images/generations"
|
||||||
|
export const EDIT_PATH = "/images/edits"
|
||||||
|
|
||||||
|
export type XAIImageString<Known extends string> = Known | (string & {})
|
||||||
|
|
||||||
|
export type XAIImageOptions = {
|
||||||
|
readonly n?: number
|
||||||
|
readonly aspectRatio?: XAIImageString<
|
||||||
|
| "1:1"
|
||||||
|
| "3:4"
|
||||||
|
| "4:3"
|
||||||
|
| "9:16"
|
||||||
|
| "16:9"
|
||||||
|
| "2:3"
|
||||||
|
| "3:2"
|
||||||
|
| "9:19.5"
|
||||||
|
| "19.5:9"
|
||||||
|
| "9:20"
|
||||||
|
| "20:9"
|
||||||
|
| "1:2"
|
||||||
|
| "2:1"
|
||||||
|
| "auto"
|
||||||
|
>
|
||||||
|
readonly aspect_ratio?: XAIImageString<
|
||||||
|
| "1:1"
|
||||||
|
| "3:4"
|
||||||
|
| "4:3"
|
||||||
|
| "9:16"
|
||||||
|
| "16:9"
|
||||||
|
| "2:3"
|
||||||
|
| "3:2"
|
||||||
|
| "9:19.5"
|
||||||
|
| "19.5:9"
|
||||||
|
| "9:20"
|
||||||
|
| "20:9"
|
||||||
|
| "1:2"
|
||||||
|
| "2:1"
|
||||||
|
| "auto"
|
||||||
|
>
|
||||||
|
readonly resolution?: XAIImageString<"1k" | "2k">
|
||||||
|
readonly responseFormat?: XAIImageString<"url" | "b64_json">
|
||||||
|
readonly response_format?: XAIImageString<"url" | "b64_json">
|
||||||
|
} & Record<string, unknown>
|
||||||
|
|
||||||
|
type XAIImageBody = Record<string, unknown> & {
|
||||||
|
readonly model: string
|
||||||
|
readonly prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const XAIImageResponse = Schema.Struct({
|
||||||
|
data: Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
b64_json: optionalNull(Schema.String),
|
||||||
|
url: optionalNull(Schema.String),
|
||||||
|
revised_prompt: optionalNull(Schema.String),
|
||||||
|
mime_type: optionalNull(Schema.String),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
usage: Schema.optional(Schema.Unknown),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ModelInput {
|
||||||
|
readonly id: string
|
||||||
|
readonly auth: AuthDefinition
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeOptions = (options: XAIImageOptions | undefined) => {
|
||||||
|
if (!options) return undefined
|
||||||
|
const { aspectRatio, responseFormat, ...native } = options
|
||||||
|
return {
|
||||||
|
aspect_ratio: aspectRatio,
|
||||||
|
response_format: responseFormat,
|
||||||
|
...native,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidOutput = (message: string) =>
|
||||||
|
new LLMError({
|
||||||
|
module: ADAPTER,
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
|
if (!query) return url
|
||||||
|
const next = new URL(url)
|
||||||
|
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||||
|
return next.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model = (input: ModelInput) => {
|
||||||
|
const route: ImageRoute<XAIImageOptions> = {
|
||||||
|
id: ADAPTER,
|
||||||
|
generate: Effect.fn("XAIImages.generate")(function* (request: ImageRequestFor<XAIImageOptions>, execute) {
|
||||||
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
|
const imageReferences = (request.images ?? []).map((image) => {
|
||||||
|
if (image.type === "bytes") return { url: ImageInputs.dataUrl(image), type: "image_url" as const }
|
||||||
|
if (image.type === "url") return { url: image.url, type: "image_url" as const }
|
||||||
|
if (image.type === "file-id") return { file_id: image.id }
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (imageReferences.some((image) => image === undefined))
|
||||||
|
return yield* ImageInputs.invalid(ADAPTER, "xAI Images accepts image URLs, data URLs, bytes, and file IDs")
|
||||||
|
const requestBody = mergeJsonRecords(
|
||||||
|
{
|
||||||
|
model: request.model.id,
|
||||||
|
prompt: request.prompt,
|
||||||
|
image: imageReferences.length === 1 ? imageReferences[0] : undefined,
|
||||||
|
images: imageReferences.length > 1 ? imageReferences : undefined,
|
||||||
|
},
|
||||||
|
nativeOptions(request.options),
|
||||||
|
http?.body,
|
||||||
|
) as XAIImageBody
|
||||||
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
|
const url = applyQuery(
|
||||||
|
`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${imageReferences.length === 0 ? PATH : EDIT_PATH}`,
|
||||||
|
http?.query,
|
||||||
|
)
|
||||||
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: text,
|
||||||
|
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||||
|
})
|
||||||
|
const response = yield* execute(
|
||||||
|
HttpClientRequest.post(url).pipe(
|
||||||
|
HttpClientRequest.setHeaders(headers),
|
||||||
|
HttpClientRequest.bodyText(text, "application/json"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const payload = yield* response.json.pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Failed to read the xAI Images response")),
|
||||||
|
)
|
||||||
|
const decoded = yield* Schema.decodeUnknownEffect(XAIImageResponse)(payload).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("xAI Images returned an invalid response")),
|
||||||
|
)
|
||||||
|
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||||
|
const mediaType = item.mime_type ?? "application/octet-stream"
|
||||||
|
if (item.b64_json)
|
||||||
|
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput(`xAI Images result ${index} contains invalid base64 data`)),
|
||||||
|
Effect.map(
|
||||||
|
(data) =>
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType,
|
||||||
|
data,
|
||||||
|
providerMetadata:
|
||||||
|
item.revised_prompt === undefined || item.revised_prompt === null
|
||||||
|
? undefined
|
||||||
|
: { xai: { revisedPrompt: item.revised_prompt } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (item.url)
|
||||||
|
return Effect.succeed(
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType,
|
||||||
|
data: item.url,
|
||||||
|
providerMetadata:
|
||||||
|
item.revised_prompt === undefined || item.revised_prompt === null
|
||||||
|
? undefined
|
||||||
|
: { xai: { revisedPrompt: item.revised_prompt } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return Effect.fail(invalidOutput(`xAI Images result ${index} has neither image data nor a URL`))
|
||||||
|
})
|
||||||
|
if (images.length === 0) return yield* invalidOutput("xAI Images returned no images")
|
||||||
|
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
|
||||||
|
return new ImageResponse({
|
||||||
|
images,
|
||||||
|
usage: usage === undefined ? undefined : new Usage({ providerMetadata: { xai: usage } }),
|
||||||
|
providerMetadata: usage === undefined ? undefined : { xai: { usage } },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return ImageModel.make<XAIImageOptions>({ id: input.id, provider: "xai", route, http: input.http })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const XAIImages = {
|
||||||
|
model,
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
|
||||||
|
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||||
|
import { InvalidProviderOutputReason, LLMError, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema"
|
||||||
|
import { ProviderShared } from "./shared"
|
||||||
|
import { ImageInputs } from "./utils/image-input"
|
||||||
|
|
||||||
|
const ADAPTER = "zai-images"
|
||||||
|
export const DEFAULT_BASE_URL = "https://api.z.ai/api/paas/v4"
|
||||||
|
export const PATH = "/images/generations"
|
||||||
|
|
||||||
|
export type ZAIImageString<Known extends string> = Known | (string & {})
|
||||||
|
|
||||||
|
export type ZAIImageOptions = {
|
||||||
|
readonly size?: ZAIImageString<
|
||||||
|
"1024x1024" | "768x1344" | "864x1152" | "1344x768" | "1152x864" | "1440x720" | "720x1440"
|
||||||
|
>
|
||||||
|
readonly quality?: ZAIImageString<"hd" | "standard">
|
||||||
|
readonly userID?: string
|
||||||
|
} & Record<string, unknown>
|
||||||
|
|
||||||
|
type ZAIImageBody = Record<string, unknown> & {
|
||||||
|
readonly model: string
|
||||||
|
readonly prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZAIImageResponse = Schema.Struct({
|
||||||
|
created: Schema.optional(Schema.Int),
|
||||||
|
id: Schema.optional(Schema.String),
|
||||||
|
request_id: Schema.optional(Schema.String),
|
||||||
|
data: Schema.Array(Schema.Struct({ url: Schema.String })),
|
||||||
|
content_filter: Schema.optional(
|
||||||
|
Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
role: Schema.optional(Schema.String),
|
||||||
|
level: Schema.optional(Schema.Number),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ModelInput {
|
||||||
|
readonly id: string
|
||||||
|
readonly auth: AuthDefinition
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeOptions = (options: ZAIImageOptions | undefined) => {
|
||||||
|
if (!options) return undefined
|
||||||
|
const { userID, ...native } = options
|
||||||
|
return {
|
||||||
|
user_id: userID,
|
||||||
|
...native,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidOutput = (message: string) =>
|
||||||
|
new LLMError({
|
||||||
|
module: ADAPTER,
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
|
if (!query) return url
|
||||||
|
const next = new URL(url)
|
||||||
|
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||||
|
return next.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model = (input: ModelInput) => {
|
||||||
|
const route: ImageRoute<ZAIImageOptions> = {
|
||||||
|
id: ADAPTER,
|
||||||
|
generate: Effect.fn("ZAIImages.generate")(function* (request: ImageRequestFor<ZAIImageOptions>, execute) {
|
||||||
|
if ((request.images?.length ?? 0) > 0)
|
||||||
|
return yield* ImageInputs.invalid(ADAPTER, "Z.ai hosted image generation does not support image inputs")
|
||||||
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
|
const requestBody = mergeJsonRecords(
|
||||||
|
{ model: request.model.id, prompt: request.prompt },
|
||||||
|
nativeOptions(request.options),
|
||||||
|
http?.body,
|
||||||
|
) as ZAIImageBody
|
||||||
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
|
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
|
||||||
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: text,
|
||||||
|
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||||
|
})
|
||||||
|
const response = yield* execute(
|
||||||
|
HttpClientRequest.post(url).pipe(
|
||||||
|
HttpClientRequest.setHeaders(headers),
|
||||||
|
HttpClientRequest.bodyText(text, "application/json"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const payload = yield* response.json.pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Failed to read the Z.ai Images response")),
|
||||||
|
)
|
||||||
|
const decoded = yield* Schema.decodeUnknownEffect(ZAIImageResponse)(payload).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Z.ai Images returned an invalid response")),
|
||||||
|
)
|
||||||
|
if (decoded.data.length === 0) return yield* invalidOutput("Z.ai Images returned no images")
|
||||||
|
return new ImageResponse({
|
||||||
|
images: decoded.data.map(
|
||||||
|
(item) =>
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType: "application/octet-stream",
|
||||||
|
data: item.url,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
providerMetadata: {
|
||||||
|
zai: {
|
||||||
|
created: decoded.created,
|
||||||
|
id: decoded.id,
|
||||||
|
requestID: decoded.request_id,
|
||||||
|
contentFilter: decoded.content_filter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return ImageModel.make<ZAIImageOptions>({ id: input.id, provider: "zai", route, http: input.http })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ZAIImages = {
|
||||||
|
model,
|
||||||
|
} as const
|
||||||
@@ -2,14 +2,20 @@ import type { RouteDefaultsInput } from "../route/client"
|
|||||||
import { Auth } from "../route/auth"
|
import { Auth } from "../route/auth"
|
||||||
import type { ProviderAuthOption } from "../route/auth-options"
|
import type { ProviderAuthOption } from "../route/auth-options"
|
||||||
import type { ProviderPackage } from "../provider-package"
|
import type { ProviderPackage } from "../provider-package"
|
||||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID, type ProviderOptions } from "../schema"
|
||||||
import * as Gemini from "../protocols/gemini"
|
import { Gemini } from "../protocols/gemini"
|
||||||
|
import { GoogleImages } from "../protocols/google-images"
|
||||||
|
|
||||||
|
export type { GoogleImageOptions } from "../protocols/google-images"
|
||||||
|
|
||||||
export const id = ProviderID.make("google")
|
export const id = ProviderID.make("google")
|
||||||
|
|
||||||
export const routes = [Gemini.route]
|
export const routes = [Gemini.route]
|
||||||
|
|
||||||
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
|
export type Config = RouteDefaultsInput &
|
||||||
|
ProviderAuthOption<"optional"> & {
|
||||||
|
readonly baseURL?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings extends ProviderPackage.Settings {
|
export interface Settings extends ProviderPackage.Settings {
|
||||||
readonly apiKey?: string
|
readonly apiKey?: string
|
||||||
@@ -31,9 +37,18 @@ const configuredRoute = (input: Config) => {
|
|||||||
|
|
||||||
export const configure = (input: Config = {}) => {
|
export const configure = (input: Config = {}) => {
|
||||||
const route = configuredRoute(input)
|
const route = configuredRoute(input)
|
||||||
|
const image = (modelID: string | ModelID) =>
|
||||||
|
GoogleImages.model({
|
||||||
|
id: modelID,
|
||||||
|
auth: auth(input),
|
||||||
|
baseURL: input.baseURL,
|
||||||
|
headers: input.headers,
|
||||||
|
http: mergeHttpOptions(input.http === undefined ? undefined : HttpOptions.make(input.http)),
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||||
|
image,
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,3 +63,5 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
|
|||||||
limits: settings.limits,
|
limits: settings.limits,
|
||||||
providerOptions: settings.providerOptions,
|
providerOptions: settings.providerOptions,
|
||||||
}).model(modelID)
|
}).model(modelID)
|
||||||
|
|
||||||
|
export const image = provider.image
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ export * as OpenAICompatible from "./openai-compatible"
|
|||||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
||||||
export * as OpenRouter from "./openrouter"
|
export * as OpenRouter from "./openrouter"
|
||||||
export * as XAI from "./xai"
|
export * as XAI from "./xai"
|
||||||
|
export * as ZAI from "./zai"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID
|
|||||||
import * as OpenAIChat from "../protocols/openai-chat"
|
import * as OpenAIChat from "../protocols/openai-chat"
|
||||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
||||||
import { OpenAIImages, type OpenAIImageOptions } from "../protocols/openai-images"
|
import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images"
|
||||||
|
|
||||||
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
|
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
|
||||||
export type { OpenAIImageOptions } from "../protocols/openai-images"
|
export type { OpenAIImageOptions } from "../protocols/openai-images"
|
||||||
@@ -22,22 +22,19 @@ export type Config = RouteDefaultsInput &
|
|||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly queryParams?: Record<string, string>
|
readonly queryParams?: Record<string, string>
|
||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
readonly image?: ImageConfig
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageConfig {
|
|
||||||
readonly providerOptions?: OpenAIImageOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImageGenerationOptions {
|
export interface ImageGenerationOptions {
|
||||||
readonly action?: "auto" | "generate" | "edit"
|
readonly action?: OpenAIImageString<"auto" | "generate" | "edit">
|
||||||
readonly background?: "auto" | "opaque" | "transparent"
|
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
|
||||||
readonly inputFidelity?: "low" | "high"
|
readonly inputFidelity?: OpenAIImageString<"low" | "high">
|
||||||
readonly outputCompression?: number
|
readonly outputCompression?: number
|
||||||
readonly outputFormat?: "png" | "jpeg" | "webp"
|
readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
|
||||||
readonly partialImages?: number
|
readonly partialImages?: number
|
||||||
readonly quality?: "auto" | "low" | "medium" | "high"
|
readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
|
||||||
readonly size?: string
|
readonly size?: OpenAIImageString<
|
||||||
|
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
|
||||||
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const imageGeneration = (options: ImageGenerationOptions = {}) =>
|
export const imageGeneration = (options: ImageGenerationOptions = {}) =>
|
||||||
@@ -73,7 +70,7 @@ export interface Settings extends ProviderPackage.Settings {
|
|||||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
|
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
|
||||||
|
|
||||||
const defaults = (input: Config) => {
|
const defaults = (input: Config) => {
|
||||||
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, image: _image, ...rest } = input
|
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, ...rest } = input
|
||||||
return rest
|
return rest
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,14 +96,10 @@ export const configure = (input: Config = {}) => {
|
|||||||
auth: auth(input),
|
auth: auth(input),
|
||||||
baseURL: input.baseURL,
|
baseURL: input.baseURL,
|
||||||
headers: input.headers,
|
headers: input.headers,
|
||||||
defaults: {
|
http: mergeHttpOptions(
|
||||||
providerOptions:
|
input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
input.image?.providerOptions === undefined ? undefined : { openai: { ...input.image.providerOptions } },
|
input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
|
||||||
http: mergeHttpOptions(
|
),
|
||||||
input.http === undefined ? undefined : HttpOptions.make(input.http),
|
|
||||||
input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||||
import type { RouteDefaultsInput } from "../route/client"
|
import type { RouteDefaultsInput } from "../route/client"
|
||||||
import { ProviderID, type ModelID } from "../schema"
|
import { HttpOptions, ProviderID, type ModelID } from "../schema"
|
||||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
||||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
|
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
|
||||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||||
|
import { XAIImages } from "../protocols/xai-images"
|
||||||
|
|
||||||
export const id = ProviderID.make("xai")
|
export const id = ProviderID.make("xai")
|
||||||
|
|
||||||
@@ -12,6 +13,8 @@ export type ModelOptions = RouteDefaultsInput &
|
|||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { XAIImageOptions } from "../protocols/xai-images"
|
||||||
|
|
||||||
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
|
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
|
||||||
|
|
||||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
|
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
|
||||||
@@ -41,11 +44,20 @@ export const configure = (input: ModelOptions = {}) => {
|
|||||||
const chatRoute = configuredChatRoute(input)
|
const chatRoute = configuredChatRoute(input)
|
||||||
const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID })
|
const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID })
|
||||||
const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID })
|
const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID })
|
||||||
|
const image = (modelID: string | ModelID) =>
|
||||||
|
XAIImages.model({
|
||||||
|
id: modelID,
|
||||||
|
auth: auth(input),
|
||||||
|
baseURL: input.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL,
|
||||||
|
headers: input.headers,
|
||||||
|
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
model: responses,
|
model: responses,
|
||||||
responses,
|
responses,
|
||||||
chat,
|
chat,
|
||||||
|
image,
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,3 +66,4 @@ export const provider = configure()
|
|||||||
export const model = provider.model
|
export const model = provider.model
|
||||||
export const responses = provider.responses
|
export const responses = provider.responses
|
||||||
export const chat = provider.chat
|
export const chat = provider.chat
|
||||||
|
export const image = provider.image
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ZAIImages } from "../protocols/zai-images"
|
||||||
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||||
|
import { HttpOptions, ProviderID, type ModelID } from "../schema"
|
||||||
|
|
||||||
|
export const id = ProviderID.make("zai")
|
||||||
|
|
||||||
|
export type Config = ProviderAuthOption<"optional"> & {
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions.Input
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ZAIImageOptions } from "../protocols/zai-images"
|
||||||
|
|
||||||
|
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "ZAI_API_KEY")
|
||||||
|
|
||||||
|
export const configure = (input: Config = {}) => {
|
||||||
|
const image = (modelID: string | ModelID) =>
|
||||||
|
ZAIImages.model({
|
||||||
|
id: modelID,
|
||||||
|
auth: auth(input),
|
||||||
|
baseURL: input.baseURL,
|
||||||
|
headers: input.headers,
|
||||||
|
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
image,
|
||||||
|
configure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const provider = configure()
|
||||||
|
export const image = provider.image
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Config } from "effect"
|
import { Config } from "effect"
|
||||||
import type { Auth } from "../src/route/auth"
|
import { Auth } from "../src/route"
|
||||||
import type { ModelFactory } from "../src/route/auth-options"
|
import type { ModelFactory } from "../src/route/auth-options"
|
||||||
import { Auth as RuntimeAuth } from "../src/route/auth"
|
|
||||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||||
import * as AmazonBedrock from "../src/providers/amazon-bedrock"
|
import * as AmazonBedrock from "../src/providers/amazon-bedrock"
|
||||||
import * as Anthropic from "../src/providers/anthropic"
|
import * as Anthropic from "../src/providers/anthropic"
|
||||||
@@ -28,7 +27,7 @@ type Model = {
|
|||||||
readonly id: string
|
readonly id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
declare const auth: Auth
|
declare const auth: Auth.Definition
|
||||||
declare const optionalAuthModel: ModelFactory<BaseOptions, "optional", Model>
|
declare const optionalAuthModel: ModelFactory<BaseOptions, "optional", Model>
|
||||||
declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model>
|
declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model>
|
||||||
const configApiKey = Config.redacted("OPENAI_API_KEY")
|
const configApiKey = Config.redacted("OPENAI_API_KEY")
|
||||||
@@ -76,9 +75,9 @@ OpenAI.responses("gpt-4.1-mini")
|
|||||||
OpenAI.configure({}).responses("gpt-4.1-mini")
|
OpenAI.configure({}).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini")
|
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({ apiKey: configApiKey }).responses("gpt-4.1-mini")
|
OpenAI.configure({ apiKey: configApiKey }).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).responses("gpt-4.1-mini")
|
OpenAI.configure({ auth: Auth.bearer("oauth-token") }).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({
|
OpenAI.configure({
|
||||||
auth: RuntimeAuth.headers({ authorization: "Bearer gateway" }),
|
auth: Auth.headers({ authorization: "Bearer gateway" }),
|
||||||
baseURL: "https://gateway.example.com/v1",
|
baseURL: "https://gateway.example.com/v1",
|
||||||
}).responses("gpt-4.1-mini")
|
}).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({
|
OpenAI.configure({
|
||||||
@@ -102,40 +101,40 @@ OpenAI.configure({ generation: { maxTokens: "many" } })
|
|||||||
OpenAI.configure({ providerOptions: { openai: { store: "false" } } })
|
OpenAI.configure({ providerOptions: { openai: { store: "false" } } })
|
||||||
|
|
||||||
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
|
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
|
||||||
OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
|
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
|
||||||
|
|
||||||
OpenAI.chat("gpt-4.1-mini")
|
OpenAI.chat("gpt-4.1-mini")
|
||||||
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini")
|
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini")
|
||||||
OpenAI.configure({ apiKey: configApiKey }).chat("gpt-4.1-mini")
|
OpenAI.configure({ apiKey: configApiKey }).chat("gpt-4.1-mini")
|
||||||
OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).chat("gpt-4.1-mini")
|
OpenAI.configure({ auth: Auth.bearer("oauth-token") }).chat("gpt-4.1-mini")
|
||||||
|
|
||||||
// @ts-expect-error OpenAI chat selectors only accept model ids.
|
// @ts-expect-error OpenAI chat selectors only accept model ids.
|
||||||
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini", {})
|
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini", {})
|
||||||
|
|
||||||
// @ts-expect-error auth is an override, so OpenAI Chat rejects apiKey with auth.
|
// @ts-expect-error auth is an override, so OpenAI Chat rejects apiKey with auth.
|
||||||
OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
|
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
|
||||||
|
|
||||||
// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`.
|
// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`.
|
||||||
Azure.configure()
|
Azure.configure()
|
||||||
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment")
|
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment")
|
||||||
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).responses("deployment")
|
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).responses("deployment")
|
||||||
Azure.configure({ auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }).responses("deployment")
|
Azure.configure({ auth: Auth.header("api-key", "azure-key"), resourceName: "resource" }).responses("deployment")
|
||||||
|
|
||||||
// @ts-expect-error Azure model selectors only accept deployment ids.
|
// @ts-expect-error Azure model selectors only accept deployment ids.
|
||||||
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment", {})
|
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment", {})
|
||||||
|
|
||||||
// @ts-expect-error auth is an override, so Azure rejects apiKey with auth.
|
// @ts-expect-error auth is an override, so Azure rejects apiKey with auth.
|
||||||
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") })
|
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: Auth.header("api-key", "override") })
|
||||||
|
|
||||||
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment")
|
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment")
|
||||||
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).chat("deployment")
|
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).chat("deployment")
|
||||||
Azure.configure({ auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }).chat("deployment")
|
Azure.configure({ auth: Auth.header("api-key", "azure-key"), resourceName: "resource" }).chat("deployment")
|
||||||
|
|
||||||
// @ts-expect-error Azure chat model selectors only accept deployment ids.
|
// @ts-expect-error Azure chat model selectors only accept deployment ids.
|
||||||
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment", {})
|
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment", {})
|
||||||
|
|
||||||
// @ts-expect-error auth is an override, so Azure Chat rejects apiKey with auth.
|
// @ts-expect-error auth is an override, so Azure Chat rejects apiKey with auth.
|
||||||
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") })
|
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: Auth.header("api-key", "override") })
|
||||||
|
|
||||||
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku")
|
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku")
|
||||||
// @ts-expect-error Anthropic model selectors only accept model ids.
|
// @ts-expect-error Anthropic model selectors only accept model ids.
|
||||||
@@ -165,7 +164,7 @@ Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {})
|
|||||||
|
|
||||||
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash")
|
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash")
|
||||||
GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash")
|
GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash")
|
||||||
GoogleVertex.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
|
GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
|
||||||
// @ts-expect-error Vertex Gemini model selectors only accept model ids.
|
// @ts-expect-error Vertex Gemini model selectors only accept model ids.
|
||||||
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
|
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
|
||||||
// @ts-expect-error Vertex Gemini config accepts only one auth source.
|
// @ts-expect-error Vertex Gemini config accepts only one auth source.
|
||||||
@@ -174,7 +173,7 @@ GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", proj
|
|||||||
GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
|
GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
|
||||||
|
|
||||||
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
|
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
|
||||||
GoogleVertexChat.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
|
GoogleVertexChat.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
|
||||||
"deepseek-ai/deepseek-v3.2-maas",
|
"deepseek-ai/deepseek-v3.2-maas",
|
||||||
)
|
)
|
||||||
// @ts-expect-error Vertex Chat package settings do not accept API keys.
|
// @ts-expect-error Vertex Chat package settings do not accept API keys.
|
||||||
@@ -187,12 +186,12 @@ GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).
|
|||||||
GoogleVertexChat.configure({
|
GoogleVertexChat.configure({
|
||||||
accessToken: "vertex-token",
|
accessToken: "vertex-token",
|
||||||
// @ts-expect-error Vertex Chat config accepts only one auth source.
|
// @ts-expect-error Vertex Chat config accepts only one auth source.
|
||||||
auth: RuntimeAuth.bearer("vertex-token"),
|
auth: Auth.bearer("vertex-token"),
|
||||||
project: "project",
|
project: "project",
|
||||||
})
|
})
|
||||||
|
|
||||||
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model("xai/grok-4.20-reasoning")
|
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model("xai/grok-4.20-reasoning")
|
||||||
GoogleVertexResponses.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
|
GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
|
||||||
"xai/grok-4.20-reasoning",
|
"xai/grok-4.20-reasoning",
|
||||||
)
|
)
|
||||||
// @ts-expect-error Vertex Responses package settings do not accept API keys.
|
// @ts-expect-error Vertex Responses package settings do not accept API keys.
|
||||||
@@ -205,16 +204,14 @@ GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project
|
|||||||
GoogleVertexResponses.configure({
|
GoogleVertexResponses.configure({
|
||||||
accessToken: "vertex-token",
|
accessToken: "vertex-token",
|
||||||
// @ts-expect-error Vertex Responses config accepts only one auth source.
|
// @ts-expect-error Vertex Responses config accepts only one auth source.
|
||||||
auth: RuntimeAuth.bearer("vertex-token"),
|
auth: Auth.bearer("vertex-token"),
|
||||||
project: "project",
|
project: "project",
|
||||||
})
|
})
|
||||||
|
|
||||||
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
|
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
|
||||||
// @ts-expect-error Vertex Messages package settings do not accept API keys.
|
// @ts-expect-error Vertex Messages package settings do not accept API keys.
|
||||||
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
|
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
|
||||||
GoogleVertexMessages.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
|
GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6")
|
||||||
"claude-sonnet-4-6",
|
|
||||||
)
|
|
||||||
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model(
|
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model(
|
||||||
"claude-sonnet-4-6",
|
"claude-sonnet-4-6",
|
||||||
// @ts-expect-error Vertex Messages model selectors only accept model ids.
|
// @ts-expect-error Vertex Messages model selectors only accept model ids.
|
||||||
@@ -223,7 +220,7 @@ GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project"
|
|||||||
GoogleVertexMessages.configure({
|
GoogleVertexMessages.configure({
|
||||||
accessToken: "vertex-token",
|
accessToken: "vertex-token",
|
||||||
// @ts-expect-error Vertex Messages config accepts only one auth source.
|
// @ts-expect-error Vertex Messages config accepts only one auth source.
|
||||||
auth: RuntimeAuth.bearer("vertex-token"),
|
auth: Auth.bearer("vertex-token"),
|
||||||
project: "project",
|
project: "project",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { LLM, LLMClient, Provider } from "@opencode-ai/ai"
|
import { ImageInput, LLM, LLMClient, Provider } from "@opencode-ai/ai"
|
||||||
import { Route, Protocol } from "@opencode-ai/ai/route"
|
import { Route, Protocol } from "@opencode-ai/ai/route"
|
||||||
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
|
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
|
||||||
import {
|
import {
|
||||||
@@ -11,12 +11,7 @@ import {
|
|||||||
XAI,
|
XAI,
|
||||||
} from "@opencode-ai/ai/providers"
|
} from "@opencode-ai/ai/providers"
|
||||||
import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot"
|
import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot"
|
||||||
import {
|
import { OpenAIChat, OpenAICompatibleChat, OpenAICompatibleResponses, OpenAIResponses } from "@opencode-ai/ai/protocols"
|
||||||
OpenAIChat,
|
|
||||||
OpenAICompatibleChat,
|
|
||||||
OpenAICompatibleResponses,
|
|
||||||
OpenAIResponses,
|
|
||||||
} from "@opencode-ai/ai/protocols"
|
|
||||||
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
||||||
|
|
||||||
describe("public exports", () => {
|
describe("public exports", () => {
|
||||||
@@ -24,6 +19,7 @@ describe("public exports", () => {
|
|||||||
expect(LLM.request).toBeFunction()
|
expect(LLM.request).toBeFunction()
|
||||||
expect(LLMClient.Service).toBeFunction()
|
expect(LLMClient.Service).toBeFunction()
|
||||||
expect(LLMClient.layer).toBeDefined()
|
expect(LLMClient.layer).toBeDefined()
|
||||||
|
expect(ImageInput.bytes).toBeFunction()
|
||||||
expect(Provider.make).toBeFunction()
|
expect(Provider.make).toBeFunction()
|
||||||
expect(ProviderSubpath.make).toBe(Provider.make)
|
expect(ProviderSubpath.make).toBe(Provider.make)
|
||||||
})
|
})
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 896 B |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"tags": ["prefix:zai-images", "provider:zai", "protocol:zai-images"],
|
||||||
|
"name": "zai-images/generates-an-image",
|
||||||
|
"recordedAt": "2026-07-19T16:03:55.761Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.z.ai/api/paas/v4/images/generations",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"cogview-4-250304\",\"prompt\":\"A simple flat red circle centered on a plain white background.\",\"size\":\"1024x1024\",\"quality\":\"standard\",\"user_id\":\"opencode-image-test\"}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json; charset=UTF-8"
|
||||||
|
},
|
||||||
|
"body": "{\"created\":1784477028,\"data\":[{\"url\":\"https://mfile.z.ai/1784477035500-43574eab2b6e402da9063d6ac22dfefb.png?ufileattname=202607200003482062c3bba9b04f7d_watermark.png\"}],\"id\":\"202607200003482062c3bba9b04f7d\",\"request_id\":\"202607200003482062c3bba9b04f7d\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+502
-19
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { HttpClientRequest } from "effect/unstable/http"
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
import { Image, ImageClient } from "../src"
|
import { Image, ImageClient, ImageInput } from "../src"
|
||||||
import { OpenAI } from "../src/providers"
|
import { Google, OpenAI, XAI, ZAI } from "../src/providers"
|
||||||
import { it } from "./lib/effect"
|
import { it } from "./lib/effect"
|
||||||
import { dynamicResponse } from "./lib/http"
|
import { dynamicResponse } from "./lib/http"
|
||||||
|
|
||||||
@@ -17,13 +17,20 @@ describe("Image", () => {
|
|||||||
http: { body: { deployment: "test" }, headers: { "x-default": "yes" } },
|
http: { body: { deployment: "test" }, headers: { "x-default": "yes" } },
|
||||||
}).image("gpt-image-2"),
|
}).image("gpt-image-2"),
|
||||||
prompt: "A robot tending a rooftop garden",
|
prompt: "A robot tending a rooftop garden",
|
||||||
count: 2,
|
options: {
|
||||||
size: { width: 1024, height: 1024 },
|
n: 2,
|
||||||
providerOptions: {
|
size: "2048x2048",
|
||||||
openai: { quality: "high", outputFormat: "webp" },
|
quality: "future-quality",
|
||||||
|
outputFormat: "jpeg",
|
||||||
|
output_format: "avif",
|
||||||
|
outputCompression: 30,
|
||||||
|
output_compression: 40,
|
||||||
|
background: "opaque",
|
||||||
|
native_default: true,
|
||||||
|
future_option: true,
|
||||||
},
|
},
|
||||||
http: {
|
http: {
|
||||||
body: { request_metadata: "value" },
|
body: { output_format: "webp", output_compression: 50, future_option: "http", request_metadata: "value" },
|
||||||
headers: { "x-request": "yes" },
|
headers: { "x-request": "yes" },
|
||||||
query: { trace: "1" },
|
query: { trace: "1" },
|
||||||
},
|
},
|
||||||
@@ -49,9 +56,13 @@ describe("Image", () => {
|
|||||||
model: "gpt-image-2",
|
model: "gpt-image-2",
|
||||||
prompt: "A robot tending a rooftop garden",
|
prompt: "A robot tending a rooftop garden",
|
||||||
n: 2,
|
n: 2,
|
||||||
size: "1024x1024",
|
size: "2048x2048",
|
||||||
quality: "high",
|
quality: "future-quality",
|
||||||
|
background: "opaque",
|
||||||
output_format: "webp",
|
output_format: "webp",
|
||||||
|
output_compression: 50,
|
||||||
|
native_default: true,
|
||||||
|
future_option: "http",
|
||||||
deployment: "test",
|
deployment: "test",
|
||||||
request_metadata: "value",
|
request_metadata: "value",
|
||||||
})
|
})
|
||||||
@@ -71,23 +82,495 @@ describe("Image", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rejects invalid common and OpenAI image options locally", () =>
|
it.effect("preserves native snake_case and unknown request options", () =>
|
||||||
Image.generate({
|
Image.generate({
|
||||||
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("gpt-image-2"),
|
model: OpenAI.configure({
|
||||||
prompt: "A robot tending a rooftop garden",
|
apiKey: "test",
|
||||||
count: -1,
|
baseURL: "https://api.openai.test/v1",
|
||||||
size: { width: -1, height: 0.5 },
|
}).image("future-image-model"),
|
||||||
providerOptions: { openai: { outputCompression: 101 } },
|
prompt: "A lighthouse in fog",
|
||||||
|
options: {
|
||||||
|
outputFormat: "jpeg",
|
||||||
|
output_format: "avif",
|
||||||
|
outputCompression: 30,
|
||||||
|
output_compression: 40,
|
||||||
|
provider_future_option: { enabled: true },
|
||||||
|
},
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.flip,
|
Effect.tap((response) =>
|
||||||
Effect.tap((error) =>
|
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expect(error.reason._tag).toBe("InvalidRequest")
|
expect(response.image?.mediaType).toBe("image/avif")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
ImageClient.layer.pipe(
|
ImageClient.layer.pipe(
|
||||||
Layer.provide(dynamicResponse(() => Effect.die("invalid request should not reach the provider"))),
|
Layer.provide(
|
||||||
|
dynamicResponse((input) => {
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
model: "future-image-model",
|
||||||
|
prompt: "A lighthouse in fog",
|
||||||
|
output_format: "avif",
|
||||||
|
output_compression: 40,
|
||||||
|
provider_future_option: { enabled: true },
|
||||||
|
})
|
||||||
|
return Effect.succeed(
|
||||||
|
input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("routes OpenAI byte inputs and masks through multipart edits", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"),
|
||||||
|
prompt: "Combine these images",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
|
||||||
|
ImageInput.url("data:image/jpeg;base64,BAUG"),
|
||||||
|
],
|
||||||
|
options: {
|
||||||
|
mask: ImageInput.bytes(Uint8Array.from([7, 8, 9]), "image/png"),
|
||||||
|
quality: "high",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
body: { quality: "low", model: "corrupt", prompt: "corrupt", image: "corrupt", "image[]": "corrupt" },
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
},
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(request.url).toBe("https://api.openai.test/v1/images/edits")
|
||||||
|
expect(request.headers.get("content-type")).toStartWith("multipart/form-data; boundary=")
|
||||||
|
expect(input.text).toContain('name="model"\r\n\r\nfuture-model')
|
||||||
|
expect(input.text).toContain('name="prompt"\r\n\r\nCombine these images')
|
||||||
|
expect(input.text.match(/name="image\[\]"/g)).toHaveLength(2)
|
||||||
|
expect(input.text).toContain('name="mask"')
|
||||||
|
expect(input.text).toContain('name="quality"\r\n\r\nlow')
|
||||||
|
expect(input.text).not.toContain("corrupt")
|
||||||
|
return input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("routes OpenAI URL and file inputs through JSON edits", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"),
|
||||||
|
prompt: "Combine these images",
|
||||||
|
images: [ImageInput.url("https://example.test/source.png"), ImageInput.file("file_123")],
|
||||||
|
options: { mask: ImageInput.file("file_mask") },
|
||||||
|
http: { body: { future_option: true } },
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) => {
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
model: "future-model",
|
||||||
|
prompt: "Combine these images",
|
||||||
|
images: [{ image_url: "https://example.test/source.png" }, { file_id: "file_123" }],
|
||||||
|
mask: { file_id: "file_mask" },
|
||||||
|
future_option: true,
|
||||||
|
})
|
||||||
|
return Effect.succeed(
|
||||||
|
input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("routes ordered xAI image inputs through JSON edits", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"),
|
||||||
|
prompt: "Combine these images",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
|
||||||
|
ImageInput.url("https://example.test/source.jpg"),
|
||||||
|
ImageInput.file("file_123"),
|
||||||
|
],
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) => {
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
model: "future-model",
|
||||||
|
prompt: "Combine these images",
|
||||||
|
images: [
|
||||||
|
{ url: "data:image/png;base64,AQID", type: "image_url" },
|
||||||
|
{ url: "https://example.test/source.jpg", type: "image_url" },
|
||||||
|
{ file_id: "file_123" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return Effect.succeed(
|
||||||
|
input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("uses xAI's singular image field for one input", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"),
|
||||||
|
prompt: "Edit this image",
|
||||||
|
images: [ImageInput.file("file_123")],
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) => {
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
model: "future-model",
|
||||||
|
prompt: "Edit this image",
|
||||||
|
image: { file_id: "file_123" },
|
||||||
|
})
|
||||||
|
return Effect.succeed(
|
||||||
|
input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lowers ordered Google image inputs into generateContent parts", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).image("future-model"),
|
||||||
|
prompt: "Combine these images",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
|
||||||
|
ImageInput.url("data:image/jpeg;base64,BAUG"),
|
||||||
|
ImageInput.fileUri("https://generativelanguage.googleapis.com/v1beta/files/123", "image/webp"),
|
||||||
|
],
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) => {
|
||||||
|
expect(JSON.parse(input.text).contents[0].parts).toEqual([
|
||||||
|
{ text: "Combine these images" },
|
||||||
|
{ inlineData: { mimeType: "image/png", data: "AQID" } },
|
||||||
|
{ inlineData: { mimeType: "image/jpeg", data: "BAUG" } },
|
||||||
|
{
|
||||||
|
fileData: {
|
||||||
|
mimeType: "image/webp",
|
||||||
|
fileUri: "https://generativelanguage.googleapis.com/v1beta/files/123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
return Effect.succeed(
|
||||||
|
input.respond(
|
||||||
|
JSON.stringify({
|
||||||
|
candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "AQID" } }] } }],
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects unsupported provider inputs before sending", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const cases = [
|
||||||
|
Image.generate({
|
||||||
|
model: Google.configure({ apiKey: "test" }).image("model"),
|
||||||
|
prompt: "edit",
|
||||||
|
images: [ImageInput.url("https://example.test/image.png")],
|
||||||
|
}),
|
||||||
|
Image.generate({
|
||||||
|
model: ZAI.configure({ apiKey: "test" }).image("model"),
|
||||||
|
prompt: "edit",
|
||||||
|
images: [ImageInput.bytes(Uint8Array.from([1]), "image/png")],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
yield* Effect.forEach(cases, (program) =>
|
||||||
|
program.pipe(
|
||||||
|
Effect.flip,
|
||||||
|
Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidRequest"))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(dynamicResponse(() => Effect.die("unsupported input reached the network"))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("generates images through the Google generateContent API", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model: Google.configure({
|
||||||
|
apiKey: "test",
|
||||||
|
baseURL: "https://generativelanguage.test/v1beta/",
|
||||||
|
headers: { "x-default": "yes" },
|
||||||
|
http: { body: { labels: { deployment: "test" } }, query: { api: "v1" } },
|
||||||
|
}).image("any-model-id"),
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
options: {
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
imageSize: "2K",
|
||||||
|
seed: 42,
|
||||||
|
thinkingLevel: "HIGH",
|
||||||
|
includeThoughts: true,
|
||||||
|
futureOption: true,
|
||||||
|
imageConfig: { aspectRatio: "4:3", nativeImageOption: true },
|
||||||
|
thinkingConfig: { thinkingLevel: "LOW", nativeThinkingOption: true },
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
body: {
|
||||||
|
safetySettings: [],
|
||||||
|
generationConfig: {
|
||||||
|
imageConfig: { aspectRatio: "3:2", httpImageOption: true },
|
||||||
|
thinkingConfig: { includeThoughts: false, httpThinkingOption: true },
|
||||||
|
futureOption: "http",
|
||||||
|
httpOption: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers: { "x-request": "yes" },
|
||||||
|
query: { trace: "1" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(3)
|
||||||
|
expect(response.images.map((image) => image.data)).toEqual([
|
||||||
|
Uint8Array.from([1, 2, 3]),
|
||||||
|
Uint8Array.from([4, 5, 6]),
|
||||||
|
Uint8Array.from([7, 8, 9]),
|
||||||
|
])
|
||||||
|
expect(response.images.map((image) => image.mediaType)).toEqual(["image/png", "image/jpeg", "image/webp"])
|
||||||
|
expect(response.images[0].providerMetadata).toMatchObject({ google: { thoughtSignature: "signature-1" } })
|
||||||
|
expect(response.images[1].providerMetadata).toMatchObject({
|
||||||
|
google: { candidateIndex: 0, partIndex: 3, finishReason: "STOP" },
|
||||||
|
})
|
||||||
|
expect(response.images[2].providerMetadata).toMatchObject({ google: { candidateIndex: 7, partIndex: 0 } })
|
||||||
|
expect(response.usage?.inputTokens).toBe(5)
|
||||||
|
expect(response.usage?.outputTokens).toBe(10)
|
||||||
|
expect(response.usage?.reasoningTokens).toBe(3)
|
||||||
|
expect(response.usage?.providerMetadata).toMatchObject({ google: { serviceTier: "STANDARD" } })
|
||||||
|
expect(response.providerMetadata).toEqual({
|
||||||
|
google: {
|
||||||
|
modelVersion: "gemini-3.1-flash-image",
|
||||||
|
responseId: "response-1",
|
||||||
|
promptFeedback: undefined,
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: "STOP",
|
||||||
|
finishMessage: undefined,
|
||||||
|
safetyRatings: [{ category: "safe" }],
|
||||||
|
citationMetadata: undefined,
|
||||||
|
groundingMetadata: undefined,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "inlineData",
|
||||||
|
mediaType: "image/png",
|
||||||
|
thought: undefined,
|
||||||
|
thoughtSignature: "signature-1",
|
||||||
|
},
|
||||||
|
{ type: "text", text: "planning", thought: true, thoughtSignature: "text-signature" },
|
||||||
|
{
|
||||||
|
type: "inlineData",
|
||||||
|
mediaType: "image/png",
|
||||||
|
thought: true,
|
||||||
|
thoughtSignature: "draft-signature",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "inlineData",
|
||||||
|
mediaType: "image/jpeg",
|
||||||
|
thought: undefined,
|
||||||
|
thoughtSignature: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 7,
|
||||||
|
finishReason: undefined,
|
||||||
|
finishMessage: undefined,
|
||||||
|
safetyRatings: undefined,
|
||||||
|
citationMetadata: undefined,
|
||||||
|
groundingMetadata: undefined,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "inlineData",
|
||||||
|
mediaType: "image/webp",
|
||||||
|
thought: undefined,
|
||||||
|
thoughtSignature: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(request.url).toBe(
|
||||||
|
"https://generativelanguage.test/v1beta/models/any-model-id:generateContent?api=v1&trace=1",
|
||||||
|
)
|
||||||
|
expect(request.headers.get("x-goog-api-key")).toBe("test")
|
||||||
|
expect(request.headers.get("x-default")).toBe("yes")
|
||||||
|
expect(request.headers.get("x-request")).toBe("yes")
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
contents: [{ role: "user", parts: [{ text: "A robot tending a rooftop garden" }] }],
|
||||||
|
generationConfig: {
|
||||||
|
responseModalities: ["IMAGE"],
|
||||||
|
imageConfig: {
|
||||||
|
aspectRatio: "3:2",
|
||||||
|
imageSize: "2K",
|
||||||
|
nativeImageOption: true,
|
||||||
|
httpImageOption: true,
|
||||||
|
},
|
||||||
|
seed: 42,
|
||||||
|
thinkingConfig: {
|
||||||
|
thinkingLevel: "LOW",
|
||||||
|
includeThoughts: false,
|
||||||
|
nativeThinkingOption: true,
|
||||||
|
httpThinkingOption: true,
|
||||||
|
},
|
||||||
|
futureOption: "http",
|
||||||
|
httpOption: true,
|
||||||
|
},
|
||||||
|
labels: { deployment: "test" },
|
||||||
|
safetySettings: [],
|
||||||
|
})
|
||||||
|
return input.respond(
|
||||||
|
JSON.stringify({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
inlineData: { mimeType: "image/png", data: "AQID" },
|
||||||
|
thoughtSignature: "signature-1",
|
||||||
|
},
|
||||||
|
{ text: "planning", thought: true, thoughtSignature: "text-signature" },
|
||||||
|
{
|
||||||
|
inlineData: { mimeType: "image/png", data: "CgsM" },
|
||||||
|
thought: true,
|
||||||
|
thoughtSignature: "draft-signature",
|
||||||
|
},
|
||||||
|
{ inlineData: { mimeType: "image/jpeg", data: "BAUG" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finishReason: "STOP",
|
||||||
|
safetyRatings: [{ category: "safe" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 7,
|
||||||
|
content: { parts: [{ inlineData: { mimeType: "image/webp", data: "BwgJ" } }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 5,
|
||||||
|
candidatesTokenCount: 7,
|
||||||
|
thoughtsTokenCount: 3,
|
||||||
|
totalTokenCount: 15,
|
||||||
|
serviceTier: "STANDARD",
|
||||||
|
},
|
||||||
|
modelVersion: "gemini-3.1-flash-image",
|
||||||
|
responseId: "response-1",
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "application/json" } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("includes Google diagnostics when no final image is returned", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: Google.configure({ apiKey: "test", baseURL: "https://generativelanguage.test/v1beta" }).image(
|
||||||
|
"gemini-3.1-flash-image",
|
||||||
|
),
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
}).pipe(
|
||||||
|
Effect.flip,
|
||||||
|
Effect.tap((error) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||||
|
if (error.reason._tag !== "InvalidProviderOutput") return
|
||||||
|
expect(error.reason.message).toContain("finish reasons: IMAGE_SAFETY")
|
||||||
|
expect(error.reason.providerMetadata).toEqual({
|
||||||
|
google: {
|
||||||
|
promptFeedback: { blockReason: "SAFETY" },
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
finishReason: "IMAGE_SAFETY",
|
||||||
|
finishMessage: "The generated image was blocked by safety filters.",
|
||||||
|
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
|
||||||
|
citationMetadata: undefined,
|
||||||
|
groundingMetadata: undefined,
|
||||||
|
parts: [{ type: "text", text: "blocked", thought: false, thoughtSignature: undefined }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.succeed(
|
||||||
|
input.respond(
|
||||||
|
JSON.stringify({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { parts: [{ text: "blocked", thought: false }] },
|
||||||
|
finishReason: "IMAGE_SAFETY",
|
||||||
|
finishMessage: "The generated image was blocked by safety filters.",
|
||||||
|
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
promptFeedback: { blockReason: "SAFETY" },
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
Image,
|
||||||
|
ImageInput,
|
||||||
|
ImageModel,
|
||||||
|
type ImageModelOptions,
|
||||||
|
type ImageOptions,
|
||||||
|
type ImageRequestFor,
|
||||||
|
type ImageRoute,
|
||||||
|
} from "../src"
|
||||||
|
import { Google, OpenAI, XAI, ZAI } from "../src/providers"
|
||||||
|
|
||||||
|
type GoogleLikeOptions = {
|
||||||
|
readonly aspectRatio?: "1:1" | "16:9"
|
||||||
|
readonly imageSize?: "1K" | "2K"
|
||||||
|
} & Record<string, unknown>
|
||||||
|
|
||||||
|
declare const route: ImageRoute<GoogleLikeOptions>
|
||||||
|
const google = ImageModel.make<GoogleLikeOptions>({ id: "gemini-image", provider: "google", route })
|
||||||
|
// @ts-expect-error Extracted model options retain known provider fields.
|
||||||
|
const invalidGoogleOptions: ImageModelOptions<typeof google> = { aspectRatio: "wide" }
|
||||||
|
void invalidGoogleOptions
|
||||||
|
|
||||||
|
Image.generate({
|
||||||
|
model: google,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
|
||||||
|
ImageInput.url("data:image/jpeg;base64,AQID"),
|
||||||
|
ImageInput.fileUri("https://generativelanguage.googleapis.com/v1beta/files/example", "image/webp"),
|
||||||
|
],
|
||||||
|
options: { aspectRatio: "16:9", imageSize: "2K", futureOption: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const googleProvider = Google.configure({ apiKey: "test" }).image("any-model-id")
|
||||||
|
Image.generate({
|
||||||
|
model: googleProvider,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: {
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
imageSize: "2K",
|
||||||
|
seed: 42,
|
||||||
|
thinkingLevel: "HIGH",
|
||||||
|
includeThoughts: true,
|
||||||
|
futureOption: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
Image.generate({
|
||||||
|
model: googleProvider,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: { aspectRatio: "future-ratio", imageSize: "8K", thinkingLevel: "FUTURE" },
|
||||||
|
})
|
||||||
|
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
||||||
|
Google.configure({ image: { providerOptions: { imageSize: "2K" } } })
|
||||||
|
// @ts-expect-error Known Google string options retain their value kind.
|
||||||
|
Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { imageSize: 2 } })
|
||||||
|
// @ts-expect-error Known Google numeric options retain their value kind.
|
||||||
|
Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { seed: "42" } })
|
||||||
|
// @ts-expect-error Known Google boolean options retain their value kind.
|
||||||
|
Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { includeThoughts: "yes" } })
|
||||||
|
|
||||||
|
const openai = OpenAI.image("gpt-image-2")
|
||||||
|
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
||||||
|
OpenAI.configure({ image: { options: { quality: "medium" } } })
|
||||||
|
const futureOpenAIOptions: ImageModelOptions<typeof openai> = { quality: "future-quality" }
|
||||||
|
void futureOpenAIOptions
|
||||||
|
Image.generate({
|
||||||
|
model: openai,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
images: [ImageInput.url("https://example.com/source.png"), ImageInput.file("file_123")],
|
||||||
|
options: {
|
||||||
|
mask: ImageInput.bytes(Uint8Array.from([1]), "image/png"),
|
||||||
|
quality: "hd",
|
||||||
|
outputFormat: "webp",
|
||||||
|
size: "2048x2048",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: "future-quality", size: "256x256" } })
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { size: "1792x1024" } })
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { native_future_option: true } })
|
||||||
|
// @ts-expect-error Known OpenAI string options retain their value kind.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: 1 } })
|
||||||
|
// @ts-expect-error Known OpenAI numeric options retain their value kind.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { outputCompression: "80" } })
|
||||||
|
OpenAI.imageGeneration({ action: "future-action", quality: "future-quality", size: "2048x2048" })
|
||||||
|
// @ts-expect-error Hosted image generation numeric options retain their value kind.
|
||||||
|
OpenAI.imageGeneration({ partialImages: "2" })
|
||||||
|
// @ts-expect-error Known Google-like options are inferred from the selected model.
|
||||||
|
Image.generate({ model: google, prompt: "A lighthouse", options: { aspectRatio: "wide" } })
|
||||||
|
|
||||||
|
const xai = XAI.configure({ apiKey: "test" }).image("any-model-id")
|
||||||
|
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
||||||
|
XAI.configure({ image: { options: { resolution: "1k" } } })
|
||||||
|
Image.generate({
|
||||||
|
model: xai,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
images: [ImageInput.url("data:image/png;base64,AQID"), ImageInput.file("file_123")],
|
||||||
|
options: {
|
||||||
|
n: 2,
|
||||||
|
aspectRatio: "future-ratio",
|
||||||
|
resolution: "future-resolution",
|
||||||
|
responseFormat: "future-format",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
Image.generate({
|
||||||
|
model: xai,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: { aspect_ratio: "16:9", response_format: "b64_json", native_future_option: true },
|
||||||
|
})
|
||||||
|
// @ts-expect-error Known xAI numeric options retain their value kind.
|
||||||
|
Image.generate({ model: xai, prompt: "A lighthouse", options: { n: "2" } })
|
||||||
|
// @ts-expect-error Known xAI string options retain their value kind.
|
||||||
|
Image.generate({ model: xai, prompt: "A lighthouse", options: { resolution: 2 } })
|
||||||
|
|
||||||
|
const zai = ZAI.configure({ apiKey: "test" }).image("any-model-id")
|
||||||
|
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
||||||
|
ZAI.configure({ image: { options: { quality: "hd" } } })
|
||||||
|
Image.generate({
|
||||||
|
model: zai,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: { quality: "future-quality", userID: "user-123", future_option: true },
|
||||||
|
})
|
||||||
|
Image.generate({ model: zai, prompt: "A lighthouse", options: { user_id: "raw-user" } })
|
||||||
|
// @ts-expect-error Known Z.ai string options retain their value kind.
|
||||||
|
Image.generate({ model: zai, prompt: "A lighthouse", options: { quality: 1 } })
|
||||||
|
// @ts-expect-error Known Z.ai user IDs retain their value kind.
|
||||||
|
Image.generate({ model: zai, prompt: "A lighthouse", options: { userID: 1 } })
|
||||||
|
|
||||||
|
declare const generic: ImageModel<ImageOptions>
|
||||||
|
Image.generate({ model: generic, prompt: "A lighthouse", options: { arbitrary: true } })
|
||||||
|
const explicitImageInput: ImageInput = ImageInput.url("https://example.com/image.png")
|
||||||
|
void explicitImageInput
|
||||||
|
|
||||||
|
// @ts-expect-error Raw strings are ambiguous and are not image inputs.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", images: ["AQID"] })
|
||||||
|
// @ts-expect-error Byte image inputs require an explicit MIME type.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", images: [{ type: "bytes", data: new Uint8Array() }] })
|
||||||
|
// @ts-expect-error File URIs require an explicit MIME type for Gemini fileData.
|
||||||
|
Image.generate({ model: google, prompt: "A lighthouse", images: [{ type: "file-uri", uri: "files/123" }] })
|
||||||
|
|
||||||
|
const request = Image.request({
|
||||||
|
model: google,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: { aspectRatio: "1:1", futureOption: true },
|
||||||
|
})
|
||||||
|
const typedRequest: ImageRequestFor<GoogleLikeOptions> = request
|
||||||
|
void typedRequest
|
||||||
|
|
||||||
|
// @ts-expect-error Image requests no longer expose a common count option.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", count: 2 })
|
||||||
|
// @ts-expect-error Image requests no longer expose a common size option.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", size: { width: 1024, height: 1024 } })
|
||||||
|
// @ts-expect-error Image requests no longer expose a common aspectRatio option.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", aspectRatio: "16:9" })
|
||||||
|
// @ts-expect-error Image requests no longer expose a common seed option.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", seed: 1 })
|
||||||
|
// @ts-expect-error Image requests do not expose metadata.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", metadata: { trace: true } })
|
||||||
|
// @ts-expect-error Masks are provider options, not a common image request field.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", mask: ImageInput.url("https://example.com/mask.png") })
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export const dimensions = (data: Uint8Array) => {
|
||||||
|
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
|
||||||
|
return {
|
||||||
|
width: readUint32(data, 16),
|
||||||
|
height: readUint32(data, 20),
|
||||||
|
}
|
||||||
|
if (data[0] === 0xff && data[1] === 0xd8) {
|
||||||
|
for (let offset = 2; offset + 8 < data.length; ) {
|
||||||
|
if (data[offset] !== 0xff) {
|
||||||
|
offset++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const marker = data[offset + 1]
|
||||||
|
if (
|
||||||
|
marker !== undefined &&
|
||||||
|
[0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
width: (data[offset + 7] << 8) | data[offset + 8],
|
||||||
|
height: (data[offset + 5] << 8) | data[offset + 6],
|
||||||
|
}
|
||||||
|
offset += 2 + ((data[offset + 2] << 8) | data[offset + 3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported image fixture format")
|
||||||
|
}
|
||||||
|
|
||||||
|
const readUint32 = (data: Uint8Array, offset: number) =>
|
||||||
|
((data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]) >>> 0
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Image, ImageInput } from "../../src"
|
||||||
|
import { Google } from "../../src/providers"
|
||||||
|
import { dimensions } from "../lib/image"
|
||||||
|
import { recordedTests } from "../recorded-test"
|
||||||
|
|
||||||
|
const model = Google.configure({
|
||||||
|
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture",
|
||||||
|
}).image("gemini-3.1-flash-image")
|
||||||
|
|
||||||
|
const recorded = recordedTests({
|
||||||
|
prefix: "google-images",
|
||||||
|
provider: "google",
|
||||||
|
protocol: "google-images",
|
||||||
|
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Google Images recorded", () => {
|
||||||
|
recorded.effect("generates an image", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model,
|
||||||
|
prompt: "A simple flat blue circle centered on a plain white background.",
|
||||||
|
options: { aspectRatio: "1:1" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(1)
|
||||||
|
expect(response.image?.mediaType).toMatch(/^image\//)
|
||||||
|
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||||
|
expect(response.image?.data.length).toBeGreaterThan(0)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded.effect("edits an image", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model,
|
||||||
|
prompt:
|
||||||
|
"Transform this minimal source into a bright orange sun icon with eight rounded rays on a pale blue background.",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(
|
||||||
|
yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
|
||||||
|
"image/jpeg",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options: { aspectRatio: "1:1" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.image?.mediaType).toBe("image/jpeg")
|
||||||
|
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||||
|
if (!(response.image?.data instanceof Uint8Array)) throw new Error("Expected owned Google image bytes")
|
||||||
|
expect(dimensions(response.image.data)).toEqual({ width: 1024, height: 1024 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Image } from "../../src"
|
import { Image, ImageInput } from "../../src"
|
||||||
import { OpenAI } from "../../src/providers"
|
import { OpenAI } from "../../src/providers"
|
||||||
|
import { dimensions } from "../lib/image"
|
||||||
import { recordedTests } from "../recorded-test"
|
import { recordedTests } from "../recorded-test"
|
||||||
|
|
||||||
const model = OpenAI.configure({
|
const model = OpenAI.configure({
|
||||||
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
|
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
|
||||||
image: {
|
|
||||||
providerOptions: {
|
|
||||||
quality: "low",
|
|
||||||
outputFormat: "jpeg",
|
|
||||||
outputCompression: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}).image("gpt-image-1-mini")
|
}).image("gpt-image-1-mini")
|
||||||
|
|
||||||
const recorded = recordedTests({
|
const recorded = recordedTests({
|
||||||
@@ -28,7 +22,7 @@ describe("OpenAI Images recorded", () => {
|
|||||||
const response = yield* Image.generate({
|
const response = yield* Image.generate({
|
||||||
model,
|
model,
|
||||||
prompt: "A simple flat black circle centered on a plain white background.",
|
prompt: "A simple flat black circle centered on a plain white background.",
|
||||||
size: { width: 1024, height: 1024 },
|
options: { quality: "low", outputFormat: "jpeg", outputCompression: 10, size: "1024x1024" },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(response.images).toHaveLength(1)
|
expect(response.images).toHaveLength(1)
|
||||||
@@ -37,4 +31,32 @@ describe("OpenAI Images recorded", () => {
|
|||||||
expect(response.image?.data.length).toBeGreaterThan(0)
|
expect(response.image?.data.length).toBeGreaterThan(0)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
recorded.effect.with(
|
||||||
|
"edits an image",
|
||||||
|
{
|
||||||
|
options: {
|
||||||
|
match: (incoming, recorded) => incoming.method === recorded.method && incoming.url === recorded.url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model,
|
||||||
|
prompt: "Keep the simple shape and change it from black to bright green.",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(
|
||||||
|
yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
|
||||||
|
"image/jpeg",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options: { quality: "low", outputFormat: "jpeg", outputCompression: 10, size: "1024x1024" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.image?.mediaType).toBe("image/jpeg")
|
||||||
|
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||||
|
if (!(response.image?.data instanceof Uint8Array)) throw new Error("Expected owned OpenAI image bytes")
|
||||||
|
expect(dimensions(response.image.data)).toEqual({ width: 1024, height: 1024 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Image, ImageInput } from "../../src"
|
||||||
|
import { XAI } from "../../src/providers"
|
||||||
|
import { dimensions } from "../lib/image"
|
||||||
|
import { recordedTests } from "../recorded-test"
|
||||||
|
|
||||||
|
const model = XAI.configure({
|
||||||
|
apiKey: process.env.XAI_API_KEY ?? "fixture",
|
||||||
|
}).image("grok-imagine-image")
|
||||||
|
|
||||||
|
const recorded = recordedTests({
|
||||||
|
prefix: "xai-images",
|
||||||
|
provider: "xai",
|
||||||
|
protocol: "xai-images",
|
||||||
|
requires: ["XAI_API_KEY"],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("xAI Images recorded", () => {
|
||||||
|
recorded.effect("generates an image", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model,
|
||||||
|
prompt: "A simple flat black diamond centered on a plain white background.",
|
||||||
|
options: { aspectRatio: "1:1", resolution: "1k", responseFormat: "b64_json" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(1)
|
||||||
|
expect(response.image?.mediaType.startsWith("image/")).toBe(true)
|
||||||
|
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||||
|
expect(response.image?.data.length).toBeGreaterThan(0)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded.effect("edits an image", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model,
|
||||||
|
prompt: "Keep the simple shape and change it from black to bright purple.",
|
||||||
|
images: [
|
||||||
|
ImageInput.bytes(
|
||||||
|
yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
|
||||||
|
"image/jpeg",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options: { aspectRatio: "1:1", resolution: "1k", responseFormat: "b64_json" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.image?.mediaType).toMatch(/^image\/(jpeg|png)$/)
|
||||||
|
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||||
|
if (!(response.image?.data instanceof Uint8Array)) throw new Error("Expected owned xAI image bytes")
|
||||||
|
expect(dimensions(response.image.data)).toEqual({ width: 1024, height: 1024 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { Image, ImageClient } from "../../src"
|
||||||
|
import { XAI } from "../../src/providers"
|
||||||
|
import { Auth } from "../../src/route"
|
||||||
|
import { it } from "../lib/effect"
|
||||||
|
import { dynamicResponse } from "../lib/http"
|
||||||
|
|
||||||
|
describe("xAI Images", () => {
|
||||||
|
it.effect("generates through the OpenAI-compatible Images API", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model: XAI.configure({
|
||||||
|
apiKey: "test",
|
||||||
|
baseURL: "https://api.xai.test/v1",
|
||||||
|
http: { body: { configured: true }, headers: { "x-default": "yes" } },
|
||||||
|
}).image("grok-imagine-image"),
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
options: {
|
||||||
|
n: 2,
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
aspect_ratio: "4:3",
|
||||||
|
resolution: "1k",
|
||||||
|
responseFormat: "url",
|
||||||
|
response_format: "b64_json",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
body: { resolution: "2k", future_option: "http" },
|
||||||
|
headers: { "x-request": "yes" },
|
||||||
|
query: { trace: "1" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(2)
|
||||||
|
expect(response.image?.mediaType).toBe("image/jpeg")
|
||||||
|
expect(response.image?.data).toEqual(Uint8Array.from([1, 2, 3]))
|
||||||
|
expect(response.images[1]?.mediaType).toBe("application/octet-stream")
|
||||||
|
expect(response.images[1]?.data).toBe("https://api.xai.test/image.jpg")
|
||||||
|
expect(response.usage?.providerMetadata).toEqual({ xai: { num_images: 2 } })
|
||||||
|
expect(response.providerMetadata).toEqual({ xai: { usage: { num_images: 2 } } })
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(request.url).toBe("https://api.xai.test/v1/images/generations?trace=1")
|
||||||
|
expect(request.headers.get("authorization")).toBe("Bearer test")
|
||||||
|
expect(request.headers.get("x-default")).toBe("yes")
|
||||||
|
expect(request.headers.get("x-request")).toBe("yes")
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
model: "grok-imagine-image",
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
n: 2,
|
||||||
|
aspect_ratio: "4:3",
|
||||||
|
resolution: "2k",
|
||||||
|
response_format: "b64_json",
|
||||||
|
future_option: "http",
|
||||||
|
configured: true,
|
||||||
|
})
|
||||||
|
return input.respond(
|
||||||
|
JSON.stringify({
|
||||||
|
data: [
|
||||||
|
{ b64_json: "AQID", url: null, mime_type: "image/jpeg" },
|
||||||
|
{ b64_json: null, url: "https://api.xai.test/image.jpg", mime_type: null },
|
||||||
|
],
|
||||||
|
usage: { num_images: 2 },
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "application/json" } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("supports request-level custom auth", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: XAI.configure({
|
||||||
|
baseURL: "https://api.xai.test/v1",
|
||||||
|
auth: Auth.custom((input) =>
|
||||||
|
Effect.succeed(Headers.set(input.headers, "x-custom-auth", new URL(input.url).hostname)),
|
||||||
|
),
|
||||||
|
}).image("grok-imagine-image"),
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(request.headers.get("x-custom-auth")).toBe("api.xai.test")
|
||||||
|
return input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Image } from "../../src"
|
||||||
|
import { ZAI } from "../../src/providers"
|
||||||
|
import { recordedTests } from "../recorded-test"
|
||||||
|
|
||||||
|
const model = ZAI.configure({ apiKey: process.env.ZAI_API_KEY ?? "fixture" }).image("cogview-4-250304")
|
||||||
|
|
||||||
|
const recorded = recordedTests({
|
||||||
|
prefix: "zai-images",
|
||||||
|
provider: "zai",
|
||||||
|
protocol: "zai-images",
|
||||||
|
requires: ["ZAI_API_KEY"],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Z.ai Images recorded", () => {
|
||||||
|
recorded.effect("generates an image", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model,
|
||||||
|
prompt: "A simple flat red circle centered on a plain white background.",
|
||||||
|
options: { size: "1024x1024", quality: "standard", userID: "opencode-image-test" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(1)
|
||||||
|
expect(response.image?.mediaType).toBe("application/octet-stream")
|
||||||
|
expect(response.image?.data).toBeString()
|
||||||
|
expect(response.image?.data).toStartWith("https://")
|
||||||
|
expect(response.providerMetadata?.zai).toBeDefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { Image, ImageClient } from "../../src"
|
||||||
|
import { ZAI } from "../../src/providers"
|
||||||
|
import { it } from "../lib/effect"
|
||||||
|
import { dynamicResponse, fixedResponse } from "../lib/http"
|
||||||
|
|
||||||
|
describe("Z.ai Images", () => {
|
||||||
|
it.effect("generates through the Z.ai Images API", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model: ZAI.configure({
|
||||||
|
apiKey: "test",
|
||||||
|
baseURL: "https://api.z.ai.test/api/paas/v4",
|
||||||
|
headers: { "x-default": "yes" },
|
||||||
|
http: { body: { configured: true, quality: "configured" }, query: { trace: "default" } },
|
||||||
|
}).image("glm-image"),
|
||||||
|
prompt: "A red circle on a white background",
|
||||||
|
options: {
|
||||||
|
quality: "hd",
|
||||||
|
userID: "alias-user",
|
||||||
|
user_id: "raw-user",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
headers: { "x-request": "yes" },
|
||||||
|
query: { trace: "request" },
|
||||||
|
body: { quality: "final", user_id: "final-user" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(1)
|
||||||
|
expect(response.image?.mediaType).toBe("application/octet-stream")
|
||||||
|
expect(response.image?.data).toBe("https://cdn.z.ai/generated.png")
|
||||||
|
expect(response.providerMetadata).toEqual({
|
||||||
|
zai: {
|
||||||
|
created: 1_760_335_349,
|
||||||
|
id: "generation-1",
|
||||||
|
requestID: "request-1",
|
||||||
|
contentFilter: [{ role: "future-role", level: 4.5 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(request.url).toBe("https://api.z.ai.test/api/paas/v4/images/generations?trace=request")
|
||||||
|
expect(request.headers.get("authorization")).toBe("Bearer test")
|
||||||
|
expect(request.headers.get("x-default")).toBe("yes")
|
||||||
|
expect(request.headers.get("x-request")).toBe("yes")
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
model: "glm-image",
|
||||||
|
prompt: "A red circle on a white background",
|
||||||
|
quality: "final",
|
||||||
|
user_id: "final-user",
|
||||||
|
future_option: true,
|
||||||
|
configured: true,
|
||||||
|
})
|
||||||
|
return input.respond(
|
||||||
|
JSON.stringify({
|
||||||
|
created: 1_760_335_349,
|
||||||
|
id: "generation-1",
|
||||||
|
request_id: "request-1",
|
||||||
|
data: [{ url: "https://cdn.z.ai/generated.png" }],
|
||||||
|
content_filter: [{ role: "future-role", level: 4.5 }],
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "application/json" } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("lets raw native options override aliases", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: ZAI.configure({ apiKey: "test" }).image("model"),
|
||||||
|
prompt: "test",
|
||||||
|
options: { quality: "future-quality", userID: "x", user_id: "raw-user" },
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) => {
|
||||||
|
expect(JSON.parse(input.text)).toMatchObject({ quality: "future-quality", user_id: "raw-user" })
|
||||||
|
return Effect.succeed(
|
||||||
|
input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects invalid response structures", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const model = ZAI.configure({ apiKey: "test" }).image("model")
|
||||||
|
const payloads = [
|
||||||
|
{},
|
||||||
|
{ data: [] },
|
||||||
|
{ data: [{ b64_json: "image" }] },
|
||||||
|
{ data: [{ url: 1 }] },
|
||||||
|
{ data: [{ url: "https://example.test/image.jpg" }], content_filter: [{ role: 1, level: "high" }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
yield* Effect.forEach(payloads, (payload) =>
|
||||||
|
Image.generate({ model, prompt: "test" }).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
fixedResponse(JSON.stringify(payload), { headers: { "content-type": "application/json" } }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.flip,
|
||||||
|
Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidProviderOutput"))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["test/**/*.types.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/PromptInputV2Editing"
|
||||||
|
const projectID = "proj_prompt_input_v2_editing"
|
||||||
|
const sessionID = "ses_prompt_input_v2_editing"
|
||||||
|
|
||||||
|
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "prompt-input-v2-editing",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: { all: [], connected: [], default: {} },
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: "prompt-input-v2-editing",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title: "Prompt input V2 editing",
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||||
|
const input = composer.locator('[data-component="prompt-input"]')
|
||||||
|
await expectAppVisible(composer)
|
||||||
|
|
||||||
|
await input.fill("keep me")
|
||||||
|
await composer.getByRole("button", { name: "Add images and files" }).click()
|
||||||
|
await page.getByRole("menuitem", { name: "Commands" }).click()
|
||||||
|
await page.locator('[data-suggestion-id="model.choose"]').click()
|
||||||
|
|
||||||
|
await expect(input).toHaveText("keep me")
|
||||||
|
})
|
||||||
@@ -54,18 +54,15 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
const composer = page.locator('[data-component="session-composer"]')
|
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||||
const input = composer.locator('[data-component="prompt-input"]')
|
const input = composer.locator('[data-component="prompt-input"]')
|
||||||
const control = composer.locator('[data-component="prompt-variant-control"]')
|
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||||
await expectAppVisible(composer)
|
await expectAppVisible(composer)
|
||||||
|
|
||||||
await idleComposer(page)
|
await idleComposer(page)
|
||||||
await expect(control).toBeHidden()
|
|
||||||
|
|
||||||
await composer.hover()
|
|
||||||
await expect(control).toBeVisible()
|
await expect(control).toBeVisible()
|
||||||
|
|
||||||
await control.locator('[data-action="prompt-model-variant"]').click()
|
await control.click()
|
||||||
const high = page.getByRole("menuitemradio", { name: "high" })
|
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||||
await expect(high).toBeVisible()
|
await expect(high).toBeVisible()
|
||||||
await page.mouse.move(0, 0)
|
await page.mouse.move(0, 0)
|
||||||
|
|||||||
@@ -736,5 +736,5 @@ async function switchTitlebarSession(page: Page, sessionID: string, title: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function expectSessionReady(page: Page) {
|
async function expectSessionReady(page: Page) {
|
||||||
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
|
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.3",
|
"version": "1.18.4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("SerializeAddon", () => {
|
describe("SerializeAddon", () => {
|
||||||
|
test("preserves color scheme reporting mode", async () => {
|
||||||
|
const { term, addon } = createTerminal()
|
||||||
|
await writeAndWait(term, "\x1b[?2031h")
|
||||||
|
|
||||||
|
expect(addon.serialize().startsWith("\x1b[?2031h")).toBe(true)
|
||||||
|
expect(addon.serialize({ excludeModes: true }).startsWith("\x1b[?2031h")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
describe("ANSI color preservation", () => {
|
describe("ANSI color preservation", () => {
|
||||||
test("should preserve text attributes (bold, italic, underline)", async () => {
|
test("should preserve text attributes (bold, italic, underline)", async () => {
|
||||||
const { term, addon } = createTerminal()
|
const { term, addon } = createTerminal()
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined =
|
|||||||
return { active, normal, alternate }
|
return { active, normal, alternate }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTerminalMode = (value: ITerminalCore, mode: number) => {
|
||||||
|
if (!isRecord(value)) return false
|
||||||
|
const terminal = value.wasmTerm
|
||||||
|
if (!isRecord(terminal) || typeof terminal.getMode !== "function") return false
|
||||||
|
return terminal.getMode(mode) === true
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -544,7 +551,8 @@ export class SerializeAddon implements ITerminalAddon {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = options?.range
|
let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : ""
|
||||||
|
content += options?.range
|
||||||
? this._serializeBufferByRange(normalBuffer, options.range, true)
|
? this._serializeBufferByRange(normalBuffer, options.range, true)
|
||||||
: this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
|
: this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,16 @@ import { Font } from "@opencode-ai/ui/font"
|
|||||||
import { Splash } from "@opencode-ai/ui/logo"
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
import {
|
||||||
|
type BaseRouterProps,
|
||||||
|
Navigate,
|
||||||
|
Route,
|
||||||
|
Router,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
useParams,
|
||||||
|
useSearchParams,
|
||||||
|
} from "@solidjs/router"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
@@ -29,6 +38,7 @@ import {
|
|||||||
Show,
|
Show,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
|
||||||
type Mem = Performance & {
|
type Mem = Performance & {
|
||||||
memory?: {
|
memory?: {
|
||||||
@@ -107,8 +108,45 @@ function Cell(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
|
||||||
|
const content = () => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Force focus styles on all interactive elements"
|
||||||
|
aria-pressed={props.active}
|
||||||
|
classList={{
|
||||||
|
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
|
||||||
|
"min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline,
|
||||||
|
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
|
||||||
|
"bg-surface-raised-base text-text-strong": props.active,
|
||||||
|
}}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
|
||||||
|
<span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
|
||||||
|
{props.active ? "ON" : "OFF"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (props.inline) {
|
||||||
|
return (
|
||||||
|
<TooltipV2 value="Force focus styles on all interactive elements" placement="top">
|
||||||
|
{content()}
|
||||||
|
</TooltipV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip value="Force focus styles on all interactive elements" placement="top">
|
||||||
|
{content()}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function DebugBar(props: { inline?: boolean } = {}) {
|
export function DebugBar(props: { inline?: boolean } = {}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const routing = useIsRouting()
|
const routing = useIsRouting()
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
@@ -116,6 +154,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
delay: undefined as number | undefined,
|
delay: undefined as number | undefined,
|
||||||
fps: undefined as number | undefined,
|
fps: undefined as number | undefined,
|
||||||
gap: undefined as number | undefined,
|
gap: undefined as number | undefined,
|
||||||
|
focus: false,
|
||||||
heap: {
|
heap: {
|
||||||
limit: undefined as number | undefined,
|
limit: undefined as number | undefined,
|
||||||
used: undefined as number | undefined,
|
used: undefined as number | undefined,
|
||||||
@@ -142,6 +181,16 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
}
|
}
|
||||||
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
|
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
|
||||||
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
|
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
|
||||||
|
const toggleFocus = async () => {
|
||||||
|
if (!platform.setForceFocus) return
|
||||||
|
const enabled = !state.focus
|
||||||
|
await platform.setForceFocus(enabled)
|
||||||
|
setState("focus", enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
let prev = ""
|
let prev = ""
|
||||||
let start = 0
|
let start = 0
|
||||||
@@ -490,8 +539,11 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||||||
bad={bad(heap(), 0.8)}
|
bad={bad(heap(), 0.8)}
|
||||||
dim={state.heap.used === undefined}
|
dim={state.heap.used === undefined}
|
||||||
inline={props.inline}
|
inline={props.inline}
|
||||||
wide
|
wide={!platform.setForceFocus}
|
||||||
/>
|
/>
|
||||||
|
{platform.setForceFocus && (
|
||||||
|
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
|
import { mockProviderAuth } from "@/context/server-sync"
|
||||||
|
import { onCleanup, onMount } from "solid-js"
|
||||||
|
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
||||||
|
|
||||||
|
function ConnectProviderDialogStory() {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const open = () => dialog.show(() => <DialogConnectProvider />)
|
||||||
|
|
||||||
|
onMount(open)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="secondary" onClick={open}>
|
||||||
|
Open connect provider dialog
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderConnectionDialogStory(props) {
|
||||||
|
onCleanup(mockProviderAuth(props.provider, props.methods))
|
||||||
|
const dialog = useDialog()
|
||||||
|
const controller = useProviderConnectController()
|
||||||
|
controller.select(props.provider)
|
||||||
|
const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
|
||||||
|
|
||||||
|
onMount(open)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="secondary" onClick={open}>
|
||||||
|
Open {props.provider} connection dialog
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConnection(provider, methods) {
|
||||||
|
return () => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<ProviderConnectionDialogStory provider={provider} methods={methods} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "App/Dialogs/Connect Provider",
|
||||||
|
id: "app-dialog-connect-provider",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const V2 = {
|
||||||
|
render: () => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<ConnectProviderDialogStory />
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApiKey = {
|
||||||
|
render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OpenCodeZen = {
|
||||||
|
render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoginMethods = {
|
||||||
|
render: renderConnection("openai", [
|
||||||
|
{ type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
|
||||||
|
{ type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
|
||||||
|
{ type: "api", label: "API key" },
|
||||||
|
]),
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import {
|
import {
|
||||||
type Accessor,
|
type Accessor,
|
||||||
@@ -16,6 +19,8 @@ import {
|
|||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createResource,
|
createResource,
|
||||||
|
createUniqueId,
|
||||||
|
For,
|
||||||
Match,
|
Match,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
onMount,
|
onMount,
|
||||||
@@ -27,6 +32,7 @@ import { Link } from "@/components/link"
|
|||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||||
|
|
||||||
@@ -50,32 +56,22 @@ export const DialogConnectProvider: Component<{
|
|||||||
const fallback = useProviderConnectController()
|
const fallback = useProviderConnectController()
|
||||||
const controller = props.controller ?? fallback
|
const controller = props.controller ?? fallback
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
|
const newLayout = settings.general.newLayoutDesigns
|
||||||
const reset = controller.back
|
const reset = controller.back
|
||||||
const back = { current: reset }
|
const back = { current: reset }
|
||||||
|
let focusHost: HTMLDivElement | undefined
|
||||||
|
const holdFocus = () => focusHost?.focus({ preventScroll: true })
|
||||||
const select = (provider?: string) => {
|
const select = (provider?: string) => {
|
||||||
back.current = reset
|
back.current = reset
|
||||||
controller.select(provider)
|
controller.select(provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
function Content() {
|
||||||
<Dialog
|
return (
|
||||||
class="h-full"
|
|
||||||
transition
|
|
||||||
title={
|
|
||||||
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
|
||||||
<IconButton
|
|
||||||
tabIndex={-1}
|
|
||||||
icon="arrow-left"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => back.current()}
|
|
||||||
aria-label={language.t("common.goBack")}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={controller.selected() === CUSTOM_ID}>
|
<Match when={controller.selected() === CUSTOM_ID}>
|
||||||
<CustomProviderForm />
|
<CustomProviderForm autofocus={!newLayout()} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
||||||
{(provider) => (
|
{(provider) => (
|
||||||
@@ -88,14 +84,76 @@ export const DialogConnectProvider: Component<{
|
|||||||
)}
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<ProviderPicker directory={props.directory} onSelect={select} />
|
<ProviderPicker
|
||||||
|
directory={props.directory}
|
||||||
|
onSelect={select}
|
||||||
|
onPrepare={newLayout() ? holdFocus : undefined}
|
||||||
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Dialog>
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={newLayout()}
|
||||||
|
fallback={
|
||||||
|
<Dialog
|
||||||
|
class="h-full"
|
||||||
|
transition
|
||||||
|
title={
|
||||||
|
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
||||||
|
<IconButton
|
||||||
|
tabIndex={-1}
|
||||||
|
icon="arrow-left"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => back.current()}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Content />
|
||||||
|
</Dialog>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DialogV2
|
||||||
|
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
|
||||||
|
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
||||||
|
>
|
||||||
|
<DialogHeader closeLabel={language.t("common.close")}>
|
||||||
|
<Show
|
||||||
|
when={controller.selected()}
|
||||||
|
fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||||
|
onClick={() => back.current()}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" size="small" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
|
||||||
|
<div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
|
||||||
|
<Content />
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogV2>
|
||||||
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
|
function ProviderPicker(props: {
|
||||||
|
directory?: Accessor<string | undefined>
|
||||||
|
onSelect: (provider: string) => void
|
||||||
|
onPrepare?: () => void
|
||||||
|
}) {
|
||||||
|
const settings = useSettings()
|
||||||
|
if (settings.general.newLayoutDesigns())
|
||||||
|
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
||||||
const providers = useProviders(props.directory)
|
const providers = useProviders(props.directory)
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||||
@@ -163,6 +221,171 @@ function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSel
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProviderPickerV2(props: {
|
||||||
|
directory?: Accessor<string | undefined>
|
||||||
|
onSelect: (provider: string) => void
|
||||||
|
onPrepare?: () => void
|
||||||
|
}) {
|
||||||
|
const providers = useProviders(props.directory)
|
||||||
|
const language = useLanguage()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
filter: "",
|
||||||
|
active: undefined as string | undefined,
|
||||||
|
connecting: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
|
||||||
|
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
|
||||||
|
const all = createMemo(() => {
|
||||||
|
language.locale()
|
||||||
|
const query = store.filter.trim().toLowerCase()
|
||||||
|
const values = [custom(), ...providers.all().values()]
|
||||||
|
if (!query) return values
|
||||||
|
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
|
||||||
|
})
|
||||||
|
const popular = createMemo(() =>
|
||||||
|
all()
|
||||||
|
.filter((provider) => featured.includes(provider.id))
|
||||||
|
.sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)),
|
||||||
|
)
|
||||||
|
const other = createMemo(() =>
|
||||||
|
all()
|
||||||
|
.filter((provider) => !featured.includes(provider.id))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.id === CUSTOM_ID) return -1
|
||||||
|
if (b.id === CUSTOM_ID) return 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const rows = createMemo(() => [...popular(), ...other()])
|
||||||
|
let picker: HTMLDivElement | undefined
|
||||||
|
let search: HTMLInputElement | undefined
|
||||||
|
|
||||||
|
onMount(() => search?.focus({ preventScroll: true }))
|
||||||
|
|
||||||
|
const connect = (provider: string) => {
|
||||||
|
props.onPrepare?.()
|
||||||
|
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
|
||||||
|
props.onSelect(provider)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (store.connecting) return
|
||||||
|
setStore("connecting", provider)
|
||||||
|
void serverSDK()
|
||||||
|
.client.provider.auth()
|
||||||
|
.then((response) => {
|
||||||
|
serverSync().set("provider_auth", response.data ?? {})
|
||||||
|
props.onSelect(provider)
|
||||||
|
})
|
||||||
|
.catch(() => props.onSelect(provider))
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = (event: KeyboardEvent, direction: number) => {
|
||||||
|
const items = rows()
|
||||||
|
if (items.length === 0) return
|
||||||
|
const index = items.findIndex((provider) => provider.id === store.active)
|
||||||
|
const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
|
||||||
|
setStore("active", items[next].id)
|
||||||
|
picker
|
||||||
|
?.querySelector<HTMLElement>(`[data-provider-id="${CSS.escape(items[next].id)}"]`)
|
||||||
|
?.focus({ preventScroll: true })
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "ArrowDown") return move(event, 1)
|
||||||
|
if (event.key === "ArrowUp") return move(event, -1)
|
||||||
|
if (event.key !== "Enter" || !store.active) return
|
||||||
|
connect(store.active)
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
|
||||||
|
<div class="shrink-0 px-1 pt-px">
|
||||||
|
<TextInputV2
|
||||||
|
ref={search}
|
||||||
|
type="search"
|
||||||
|
class="!w-full [font-family:var(--v2-font-family-sans)]"
|
||||||
|
leadingIcon={<Icon name="magnifying-glass" size="small" />}
|
||||||
|
placeholder={language.t("dialog.provider.search.placeholder")}
|
||||||
|
value={store.filter}
|
||||||
|
onInput={(event) => {
|
||||||
|
setStore({ filter: event.currentTarget.value, active: undefined })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="relative min-h-0 flex-1">
|
||||||
|
<div class="flex size-full min-h-0 flex-col gap-4 overflow-y-auto pb-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
|
<For
|
||||||
|
each={[
|
||||||
|
{ title: language.t("dialog.provider.group.popular"), items: popular },
|
||||||
|
{ title: language.t("dialog.provider.group.other"), items: other },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{(group) => (
|
||||||
|
<Show when={group.items().length > 0}>
|
||||||
|
<section class="flex flex-col">
|
||||||
|
<div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
|
||||||
|
{group.title}
|
||||||
|
</div>
|
||||||
|
<For each={group.items()}>
|
||||||
|
{(provider) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-provider-id={provider.id}
|
||||||
|
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||||
|
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
|
||||||
|
onMouseEnter={() => setStore("active", provider.id)}
|
||||||
|
disabled={store.connecting !== undefined}
|
||||||
|
aria-busy={store.connecting === provider.id}
|
||||||
|
onClick={() => connect(provider.id)}
|
||||||
|
>
|
||||||
|
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
|
||||||
|
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
|
||||||
|
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
|
||||||
|
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
|
||||||
|
{language.t(
|
||||||
|
provider.id === "opencode"
|
||||||
|
? "dialog.provider.opencode.tagline"
|
||||||
|
: "dialog.provider.opencodeGo.tagline",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||||
|
{language.t("dialog.provider.tag.recommended")}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={provider.id === CUSTOM_ID}>
|
||||||
|
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||||
|
{language.t("settings.providers.tag.custom")}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={store.connecting === provider.id}>
|
||||||
|
<Spinner class="ml-auto size-4 shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</section>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={rows().length === 0}>
|
||||||
|
<div class="flex h-24 items-center justify-center text-[13px] font-[440] text-v2-text-text-muted">
|
||||||
|
{language.t("dialog.provider.empty")}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-x-0 bottom-0 h-10"
|
||||||
|
style={{ background: "linear-gradient(to bottom, transparent, var(--v2-background-bg-layer-01))" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ProviderConnection(props: {
|
function ProviderConnection(props: {
|
||||||
provider: string
|
provider: string
|
||||||
directory?: Accessor<string | undefined>
|
directory?: Accessor<string | undefined>
|
||||||
@@ -173,6 +396,8 @@ function ProviderConnection(props: {
|
|||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const settings = useSettings()
|
||||||
|
const newLayout = settings.general.newLayoutDesigns
|
||||||
const providers = useProviders(props.directory)
|
const providers = useProviders(props.directory)
|
||||||
|
|
||||||
const alive = { value: true }
|
const alive = { value: true }
|
||||||
@@ -207,11 +432,19 @@ function ProviderConnection(props: {
|
|||||||
)
|
)
|
||||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
||||||
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
||||||
|
const cachedMethods = serverSync().data.provider_auth[props.provider]
|
||||||
|
const directMethod =
|
||||||
|
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: undefined as undefined | number,
|
methodIndex: directMethod as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||||
promptInputs: undefined as undefined | Record<string, string>,
|
promptInputs: undefined as undefined | Record<string, string>,
|
||||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
state: (directMethod === undefined ? "pending" : undefined) as
|
||||||
|
| undefined
|
||||||
|
| "pending"
|
||||||
|
| "complete"
|
||||||
|
| "error"
|
||||||
|
| "prompt",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -279,6 +512,16 @@ function ProviderConnection(props: {
|
|||||||
return value.label ?? ""
|
return value.label ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const methodDetails = (value?: { type?: string; label?: string }) => {
|
||||||
|
const label = methodLabel(value)
|
||||||
|
const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
|
||||||
|
const hint = suffix?.[1]
|
||||||
|
return {
|
||||||
|
label: suffix ? label.slice(0, -suffix[0].length) : label,
|
||||||
|
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatError(value: unknown, fallback: string): string {
|
function formatError(value: unknown, fallback: string): string {
|
||||||
if (value && typeof value === "object" && "data" in value) {
|
if (value && typeof value === "object" && "data" in value) {
|
||||||
const data = (value as { data?: { message?: unknown } }).data
|
const data = (value as { data?: { message?: unknown } }).data
|
||||||
@@ -519,6 +762,37 @@ function ProviderConnection(props: {
|
|||||||
props.setBack(goBack)
|
props.setBack(goBack)
|
||||||
|
|
||||||
function MethodSelection() {
|
function MethodSelection() {
|
||||||
|
if (newLayout())
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||||
|
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<For each={methods()}>
|
||||||
|
{(item, index) => {
|
||||||
|
const details = () => methodDetails(item)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||||
|
onClick={() => void selectMethod(index())}
|
||||||
|
>
|
||||||
|
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
|
||||||
|
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
|
||||||
|
</span>
|
||||||
|
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
|
||||||
|
<Show when={details().hint}>
|
||||||
|
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
@@ -552,11 +826,18 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ApiAuthView() {
|
function ApiAuthView() {
|
||||||
|
let apiKey: HTMLInputElement | undefined
|
||||||
|
const errorID = createUniqueId()
|
||||||
const [formStore, setFormStore] = createStore({
|
const [formStore, setFormStore] = createStore({
|
||||||
value: "",
|
value: "",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!newLayout()) return
|
||||||
|
apiKey?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
@@ -581,6 +862,58 @@ function ProviderConnection(props: {
|
|||||||
await complete()
|
await complete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (newLayout())
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||||
|
<Show
|
||||||
|
when={provider().id === "opencode"}
|
||||||
|
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
|
||||||
|
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||||
|
<div>
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||||
|
<Link
|
||||||
|
href="https://opencode.ai/zen"
|
||||||
|
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
||||||
|
>
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||||
|
</Link>
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||||
|
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
||||||
|
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||||
|
<TextInputV2
|
||||||
|
ref={apiKey}
|
||||||
|
class="!w-full"
|
||||||
|
name="apiKey"
|
||||||
|
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||||
|
value={formStore.value}
|
||||||
|
invalid={formStore.error !== undefined}
|
||||||
|
aria-describedby={formStore.error ? errorID : undefined}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck={false}
|
||||||
|
onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Show when={formStore.error}>
|
||||||
|
{(error) => (
|
||||||
|
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
||||||
|
{error()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<ButtonV2 type="submit" variant="contrast">
|
||||||
|
{language.t("common.continue")}
|
||||||
|
</ButtonV2>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -605,7 +938,8 @@ function ProviderConnection(props: {
|
|||||||
</Switch>
|
</Switch>
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
<TextField
|
<TextField
|
||||||
autofocus
|
autofocus={!newLayout()}
|
||||||
|
ref={apiKey}
|
||||||
type="text"
|
type="text"
|
||||||
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||||
@@ -624,11 +958,18 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OAuthCodeView() {
|
function OAuthCodeView() {
|
||||||
|
let codeInput: HTMLInputElement | undefined
|
||||||
|
const errorID = createUniqueId()
|
||||||
const [formStore, setFormStore] = createStore({
|
const [formStore, setFormStore] = createStore({
|
||||||
value: "",
|
value: "",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!newLayout()) return
|
||||||
|
codeInput?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
@@ -657,6 +998,46 @@ function ProviderConnection(props: {
|
|||||||
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (newLayout())
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||||
|
<div>
|
||||||
|
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||||
|
<Link href={store.authorization!.url} class="text-v2-text-text-base">
|
||||||
|
{language.t("provider.connect.oauth.code.visit.link")}
|
||||||
|
</Link>
|
||||||
|
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||||
|
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
||||||
|
{language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||||
|
<TextInputV2
|
||||||
|
ref={codeInput}
|
||||||
|
class="!w-full"
|
||||||
|
name="code"
|
||||||
|
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||||
|
value={formStore.value}
|
||||||
|
invalid={formStore.error !== undefined}
|
||||||
|
aria-describedby={formStore.error ? errorID : undefined}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck={false}
|
||||||
|
onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Show when={formStore.error}>
|
||||||
|
{(error) => (
|
||||||
|
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
||||||
|
{error()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<ButtonV2 type="submit" variant="contrast">
|
||||||
|
{language.t("common.continue")}
|
||||||
|
</ButtonV2>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
@@ -666,7 +1047,8 @@ function ProviderConnection(props: {
|
|||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
<TextField
|
<TextField
|
||||||
autofocus
|
autofocus={!newLayout()}
|
||||||
|
ref={codeInput}
|
||||||
type="text"
|
type="text"
|
||||||
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||||
@@ -738,10 +1120,19 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}>
|
||||||
<div class="px-2.5 flex gap-4 items-center">
|
<div class={newLayout() ? "flex h-10 shrink-0 items-start gap-2 px-3" : "flex items-center gap-4 px-2.5"}>
|
||||||
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon
|
||||||
<div class="text-16-medium text-text-strong">
|
id={props.provider}
|
||||||
|
class={newLayout() ? "mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" : "size-5 shrink-0 icon-strong-base"}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class={
|
||||||
|
newLayout()
|
||||||
|
? "text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base"
|
||||||
|
: "text-16-medium text-text-strong"
|
||||||
|
}
|
||||||
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||||
{language.t("provider.connect.title.anthropicProMax")}
|
{language.t("provider.connect.title.anthropicProMax")}
|
||||||
@@ -750,8 +1141,12 @@ function ProviderConnection(props: {
|
|||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-10"}>
|
||||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
<div
|
||||||
|
onKeyDown={handleKey}
|
||||||
|
tabIndex={newLayout() ? undefined : 0}
|
||||||
|
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
|
||||||
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={loading()}>
|
<Match when={loading()}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomProviderForm() {
|
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
@@ -192,7 +192,7 @@ export function CustomProviderForm() {
|
|||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<TextField
|
<TextField
|
||||||
autofocus
|
autofocus={props.autofocus ?? true}
|
||||||
label={language.t("provider.custom.field.providerID.label")}
|
label={language.t("provider.custom.field.providerID.label")}
|
||||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||||
description={language.t("provider.custom.field.providerID.description")}
|
description={language.t("provider.custom.field.providerID.description")}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { createSignal, onMount } from "solid-js"
|
||||||
|
import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
|
||||||
|
|
||||||
|
const names = [
|
||||||
|
"MiMo V2.5 Free",
|
||||||
|
"Nemotron 3 Ultra Free",
|
||||||
|
"Deepseek V4 Flash Free",
|
||||||
|
"North Mini Code Free",
|
||||||
|
"Hy3 Free",
|
||||||
|
"Big Pickle",
|
||||||
|
]
|
||||||
|
|
||||||
|
function SelectModelWithoutProviders() {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const models = names.map((name, index) => ({
|
||||||
|
id: name.toLowerCase().replaceAll(" ", "-"),
|
||||||
|
name,
|
||||||
|
provider: { id: "opencode", name: "OpenCode" },
|
||||||
|
cost: { input: 0, output: 0 },
|
||||||
|
limit: { context: 128_000 },
|
||||||
|
capabilities: {
|
||||||
|
reasoning: index !== 5,
|
||||||
|
input: { text: true, image: false, audio: false, video: false, pdf: false },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
const [current, setCurrent] = createSignal(models[2])
|
||||||
|
const model = {
|
||||||
|
list: () => models,
|
||||||
|
current,
|
||||||
|
set(value) {
|
||||||
|
setCurrent(models.find((item) => item.id === value?.modelID))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
|
||||||
|
|
||||||
|
onMount(open)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="secondary" onClick={open}>
|
||||||
|
Open select model dialog
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "App/Dialogs/Select Model",
|
||||||
|
id: "app-dialog-select-model",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithoutProviders = {
|
||||||
|
render: () => <SelectModelWithoutProviders />,
|
||||||
|
}
|
||||||
@@ -1,23 +1,26 @@
|
|||||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
|
||||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useTheme } from "@opencode-ai/ui/theme"
|
||||||
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
|
|
||||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||||
|
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
|
||||||
|
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
|
||||||
|
|
||||||
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
|
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const model = props.model ?? local.model
|
const model = props.model ?? local.model
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const theme = useTheme()
|
||||||
const directory = () => decode64(local.slug())
|
const directory = () => decode64(local.slug())
|
||||||
const providers = useProviders(directory)
|
const providers = useProviders(directory)
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -28,6 +31,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
})
|
})
|
||||||
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
||||||
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
||||||
|
const freeModels = createMemo(() => model.list().filter(isFree))
|
||||||
|
|
||||||
const openProviders = (provider?: string) => {
|
const openProviders = (provider?: string) => {
|
||||||
void import("./dialog-connect-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
@@ -62,111 +66,109 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
|
<DialogV2
|
||||||
|
fit
|
||||||
|
containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
|
||||||
|
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
||||||
|
>
|
||||||
<DialogHeader closeLabel={language.t("common.close")}>
|
<DialogHeader closeLabel={language.t("common.close")}>
|
||||||
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
|
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
|
||||||
<DialogBody class="min-h-0 flex-1 gap-0">
|
<div ref={listEl} class="flex min-h-0 flex-col">
|
||||||
<ScrollView class="min-h-0 flex-1 w-full">
|
<div class="flex w-full flex-col items-start pb-3">
|
||||||
<div ref={listEl} class="flex min-h-full flex-col">
|
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
|
||||||
<div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
|
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
|
{language.t("dialog.model.unpaid.freeModels.title")}
|
||||||
<div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
|
||||||
{language.t("dialog.model.unpaid.freeModels.title")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<For each={model.list()}>
|
|
||||||
{(item) => (
|
|
||||||
<TooltipV2
|
|
||||||
class="w-full"
|
|
||||||
placement="right-start"
|
|
||||||
gutter={6}
|
|
||||||
openDelay={0}
|
|
||||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
|
||||||
onClick={() => selectModel(item)}
|
|
||||||
>
|
|
||||||
<span class="min-w-0 truncate">{item.name}</span>
|
|
||||||
<Show when={isFree(item)}>
|
|
||||||
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={item.latest}>
|
|
||||||
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={currentKey() === modelKey(item)}>
|
|
||||||
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
</TooltipV2>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
|
<For each={freeModels()}>
|
||||||
<div class="flex w-full flex-col p-2.5 pt-0">
|
{(item) => (
|
||||||
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
|
<TooltipV2
|
||||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
|
class="w-full"
|
||||||
<div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
placement="right-start"
|
||||||
{language.t("dialog.model.unpaid.addMore.title")}
|
gutter={6}
|
||||||
</div>
|
openDelay={0}
|
||||||
</div>
|
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||||
<div class="flex w-full flex-col">
|
value={
|
||||||
<For
|
<ModelTooltip
|
||||||
each={[...providers.popular()].sort((a, b) => {
|
model={{ ...item, name: displayModelName(item.name) }}
|
||||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
|
latest={item.latest}
|
||||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
free={isFree(item)}
|
||||||
}
|
v2
|
||||||
return a.name.localeCompare(b.name)
|
/>
|
||||||
})}
|
}
|
||||||
>
|
>
|
||||||
{(provider) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
|
||||||
onClick={() => openProviders(provider.id)}
|
|
||||||
>
|
|
||||||
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
|
|
||||||
<span class="min-w-0 truncate">{provider.name}</span>
|
|
||||||
<Show when={provider.id === "opencode"}>
|
|
||||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
|
||||||
{language.t("dialog.provider.opencode.tagline")}
|
|
||||||
</span>
|
|
||||||
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={provider.id === "opencode-go"}>
|
|
||||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
|
||||||
{language.t("dialog.provider.opencodeGo.tagline")}
|
|
||||||
</span>
|
|
||||||
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={provider.id === "anthropic"}>
|
|
||||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
|
||||||
{language.t("dialog.provider.anthropic.note")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||||
onClick={() => openProviders()}
|
onClick={() => selectModel(item)}
|
||||||
>
|
>
|
||||||
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
|
||||||
<Icon name="dot-grid" size="small" />
|
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
||||||
</span>
|
<Show when={item.latest}>
|
||||||
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
||||||
{language.t("dialog.provider.viewAll")}
|
</Show>
|
||||||
</span>
|
<Show when={currentKey() === modelKey(item)}>
|
||||||
|
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
|
||||||
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
|
</TooltipV2>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col">
|
||||||
|
<div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
|
||||||
|
<div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
|
||||||
|
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||||
|
{language.t("dialog.model.unpaid.addMore.title")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
|
||||||
|
<For
|
||||||
|
each={[...providers.popular()]
|
||||||
|
.filter((provider) => featuredProviders.includes(provider.id))
|
||||||
|
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
|
||||||
|
>
|
||||||
|
{(provider) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
|
||||||
|
classList={{
|
||||||
|
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
|
||||||
|
theme.mode() !== "dark",
|
||||||
|
"border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
|
||||||
|
}}
|
||||||
|
onClick={() => openProviders(provider.id)}
|
||||||
|
>
|
||||||
|
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
|
||||||
|
<span class="flex min-w-0 flex-col">
|
||||||
|
<span class="truncate">{provider.name}</span>
|
||||||
|
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
|
||||||
|
<span class="truncate font-[440] text-v2-text-text-muted">
|
||||||
|
{language.t(
|
||||||
|
provider.id === "opencode"
|
||||||
|
? "dialog.provider.opencode.tagline"
|
||||||
|
: "dialog.provider.opencodeGo.tagline",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||||
|
onClick={() => openProviders()}
|
||||||
|
>
|
||||||
|
{language.t("dialog.model.unpaid.viewMoreProviders")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollView>
|
</div>
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
</DialogV2>
|
</DialogV2>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function TabsInfoPopup() {
|
|||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||||
|
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||||
@@ -70,12 +71,40 @@ export function TabsInfoPopup() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<DrawerContent>
|
<DrawerContent
|
||||||
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
|
style={
|
||||||
|
windows()
|
||||||
|
? {
|
||||||
|
inset: "0 0 0 auto",
|
||||||
|
"max-height": "100vh",
|
||||||
|
"max-width": "100vw",
|
||||||
|
"border-radius": "0",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show when={windows()}>
|
||||||
|
<DrawerClose
|
||||||
|
as={IconButtonV2}
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="neutral"
|
||||||
|
aria-label="Close"
|
||||||
|
icon={<IconV2 name="xmark-small" />}
|
||||||
|
class="absolute top-[10px] left-[-36px]"
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<div
|
||||||
|
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
|
||||||
|
classList={{
|
||||||
|
"h-[40px] px-4": windows(),
|
||||||
|
"h-[52px] p-4": !windows(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
||||||
July 14
|
July 14
|
||||||
</p>
|
</p>
|
||||||
<Show when={platform.platform !== "desktop" || platform.os !== "windows"}>
|
<Show when={!windows()}>
|
||||||
<DrawerClose
|
<DrawerClose
|
||||||
as={IconButtonV2}
|
as={IconButtonV2}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,591 @@
|
|||||||
|
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
|
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { createEffect, createMemo, on, Show } from "solid-js"
|
||||||
|
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||||
|
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||||
|
import type { PromptInputProps } from "@/components/prompt-input/contracts"
|
||||||
|
import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
|
||||||
|
import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
|
||||||
|
import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
|
||||||
|
import { createPromptSubmit } from "@/components/prompt-input/submit"
|
||||||
|
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||||
|
import { useComments } from "@/context/comments"
|
||||||
|
import { useCommand } from "@/context/command"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { usePermission } from "@/context/permission"
|
||||||
|
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
|
||||||
|
import {
|
||||||
|
createPromptInputV2Controller,
|
||||||
|
createPromptInputV2State,
|
||||||
|
type PromptInputV2Interaction,
|
||||||
|
} from "@opencode-ai/session-ui/v2/prompt-input/interaction"
|
||||||
|
|
||||||
|
export type PromptInputV2ComposerProps = {
|
||||||
|
class?: string
|
||||||
|
controller: PromptInputV2ComposerController
|
||||||
|
borderUnderlay?: boolean
|
||||||
|
edit?: PromptInputProps["edit"]
|
||||||
|
onEditLoaded?: PromptInputProps["onEditLoaded"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
|
||||||
|
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
||||||
|
readonly model: PromptInputProps["controls"]["model"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const command = useCommand()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
useCommands(props)
|
||||||
|
useEditHandler(props)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<PromptInputV2
|
||||||
|
controller={props.controller}
|
||||||
|
borderUnderlay={props.borderUnderlay}
|
||||||
|
class={props.class}
|
||||||
|
attachKeybind={command.keybindParts("file.attach")}
|
||||||
|
attachShortcut={command.keybind("file.attach")}
|
||||||
|
modelControl={
|
||||||
|
<PromptInputV2ModelControl
|
||||||
|
loading={props.controller.model.loading}
|
||||||
|
paid={props.controller.model.paid}
|
||||||
|
title={language.t("command.model.choose")}
|
||||||
|
keybind={command.keybindParts("model.choose")}
|
||||||
|
model={props.controller.model.selection}
|
||||||
|
providerID={props.controller.model.selection.current()?.provider?.id}
|
||||||
|
modelName={props.controller.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
|
onClose={props.controller.restoreFocus}
|
||||||
|
onUnpaidClick={() =>
|
||||||
|
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controller.model.selection} />)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useEditHandler = (props: PromptInputV2ComposerProps) => {
|
||||||
|
const prompt = usePrompt()
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => props.edit?.id,
|
||||||
|
(id) => {
|
||||||
|
const edit = props.edit
|
||||||
|
if (!id || !edit) return
|
||||||
|
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
||||||
|
edit.context.forEach((item) =>
|
||||||
|
prompt.context.add({
|
||||||
|
type: item.type,
|
||||||
|
path: item.path,
|
||||||
|
selection: item.selection,
|
||||||
|
comment: item.comment,
|
||||||
|
commentID: item.commentID,
|
||||||
|
commentOrigin: item.commentOrigin,
|
||||||
|
preview: item.preview,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
props.controller.dispatch({ type: "mode.normal" })
|
||||||
|
props.controller.resetHistory()
|
||||||
|
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||||
|
props.controller.restoreFocus()
|
||||||
|
props.onEditLoaded?.()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCommands = (props: PromptInputV2ComposerProps) => {
|
||||||
|
const command = useCommand()
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
command.register("prompt-input", () => [
|
||||||
|
{
|
||||||
|
id: "file.attach",
|
||||||
|
title: language.t("prompt.action.attachFile"),
|
||||||
|
category: language.t("command.category.file"),
|
||||||
|
keybind: "mod+u",
|
||||||
|
disabled: props.controller.state.mode !== "normal",
|
||||||
|
onSelect: () => props.controller.attach(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.shell",
|
||||||
|
title: language.t("command.prompt.mode.shell"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: "mod+shift+x",
|
||||||
|
disabled: props.controller.state.mode === "shell",
|
||||||
|
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prompt.mode.normal",
|
||||||
|
title: language.t("command.prompt.mode.normal"),
|
||||||
|
category: language.t("command.category.session"),
|
||||||
|
keybind: "mod+shift+e",
|
||||||
|
disabled: props.controller.state.mode === "normal",
|
||||||
|
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const sync = useSync()
|
||||||
|
const files = useFile()
|
||||||
|
const layout = useLayout()
|
||||||
|
const comments = useComments()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const command = useCommand()
|
||||||
|
const permission = usePermission()
|
||||||
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const prompt = props.state ?? usePrompt()
|
||||||
|
let editor: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
const interaction = createPromptInputV2State()
|
||||||
|
const mode = () => interaction[0].mode
|
||||||
|
const history = props.history ?? createPersistedPromptInputHistory()
|
||||||
|
const tabs = () => props.controls.session.tabs
|
||||||
|
const activeFileTab = createSessionTabs({
|
||||||
|
tabs,
|
||||||
|
pathFromTab: files.pathFromTab,
|
||||||
|
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
|
||||||
|
}).activeFileTab
|
||||||
|
const recent = createMemo(() => {
|
||||||
|
const all = tabs().all()
|
||||||
|
const active = activeFileTab()
|
||||||
|
const order = active ? [active, ...all.filter((tab) => tab !== active)] : all
|
||||||
|
return order.reduce<string[]>((result, tab) => {
|
||||||
|
const path = files.pathFromTab(tab)
|
||||||
|
if (!path || result.includes(path)) return result
|
||||||
|
return [...result, path]
|
||||||
|
}, [])
|
||||||
|
})
|
||||||
|
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
||||||
|
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
||||||
|
const attachments = createMemo(() =>
|
||||||
|
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||||
|
)
|
||||||
|
const commentCount = createMemo(() => {
|
||||||
|
if (mode() === "shell") return 0
|
||||||
|
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||||
|
})
|
||||||
|
const blank = createMemo(() => {
|
||||||
|
const text = prompt
|
||||||
|
.current()
|
||||||
|
.map((part) => ("content" in part ? part.content : ""))
|
||||||
|
.join("")
|
||||||
|
return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
|
||||||
|
})
|
||||||
|
const stopping = createMemo(() => working() && blank())
|
||||||
|
const placeholder = createMemo(() =>
|
||||||
|
promptPlaceholder({
|
||||||
|
mode: mode(),
|
||||||
|
commentCount: commentCount(),
|
||||||
|
example: mode() === "shell" ? "git status" : "",
|
||||||
|
suggest: false,
|
||||||
|
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder())
|
||||||
|
|
||||||
|
const historyComments = () => {
|
||||||
|
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||||
|
return prompt.context.items().flatMap((item) => {
|
||||||
|
const comment = item.comment?.trim()
|
||||||
|
if (!comment) return []
|
||||||
|
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
|
||||||
|
const nextSelection =
|
||||||
|
selection ??
|
||||||
|
(item.selection
|
||||||
|
? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange)
|
||||||
|
: undefined)
|
||||||
|
if (!nextSelection) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: item.commentID ?? item.key,
|
||||||
|
path: item.path,
|
||||||
|
selection: { ...nextSelection },
|
||||||
|
comment,
|
||||||
|
time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
|
||||||
|
origin: item.commentOrigin,
|
||||||
|
preview: item.preview,
|
||||||
|
} satisfies PromptHistoryComment,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const restoreHistoryComments = (items: PromptHistoryComment[]) => {
|
||||||
|
comments.replace(
|
||||||
|
items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
file: item.path,
|
||||||
|
selection: { ...item.selection },
|
||||||
|
comment: item.comment,
|
||||||
|
time: item.time,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
prompt.context.replaceComments(
|
||||||
|
items.map((item) => ({
|
||||||
|
type: "file",
|
||||||
|
path: item.path,
|
||||||
|
selection: selectionFromLines(item.selection),
|
||||||
|
comment: item.comment,
|
||||||
|
commentID: item.id,
|
||||||
|
commentOrigin: item.origin,
|
||||||
|
preview: item.preview,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accepting = createMemo(() => {
|
||||||
|
const id = props.controls.session.id
|
||||||
|
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
|
||||||
|
return permission.isAutoAccepting(id, sdk().directory)
|
||||||
|
})
|
||||||
|
const submission = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info,
|
||||||
|
imageAttachments: attachments,
|
||||||
|
commentCount,
|
||||||
|
autoAccept: accepting,
|
||||||
|
mode,
|
||||||
|
working,
|
||||||
|
editor: () => editor,
|
||||||
|
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||||
|
promptLength,
|
||||||
|
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||||
|
resetHistoryNavigation: () => controller.resetHistory(),
|
||||||
|
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
|
||||||
|
setPopover: (popover) => {
|
||||||
|
if (!popover) controller.dispatch({ type: "popover.close" })
|
||||||
|
},
|
||||||
|
newSessionWorktree: () => props.newSessionWorktree,
|
||||||
|
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||||
|
shouldQueue: props.shouldQueue,
|
||||||
|
onQueue: props.onQueue,
|
||||||
|
onAbort: props.onAbort,
|
||||||
|
onSubmit: props.onSubmit,
|
||||||
|
model: props.controls.model.selection,
|
||||||
|
})
|
||||||
|
|
||||||
|
const referenceDescription = (reference: ReferenceInfo) =>
|
||||||
|
reference.source.type === "git" ? reference.source.repository : reference.source.path
|
||||||
|
const references = createMemo(() =>
|
||||||
|
sync()
|
||||||
|
.data.reference.filter((reference) => !reference.hidden)
|
||||||
|
.map((reference) => ({
|
||||||
|
id: `reference:${reference.name}`,
|
||||||
|
kind: "reference" as const,
|
||||||
|
label: `@${reference.name}`,
|
||||||
|
path: reference.path,
|
||||||
|
description: reference.description ?? referenceDescription(reference),
|
||||||
|
mention: {
|
||||||
|
type: "file" as const,
|
||||||
|
path: reference.path,
|
||||||
|
content: `@${reference.name}`,
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
mime: "application/x-directory",
|
||||||
|
filename: reference.name,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const resources = createMemo(() =>
|
||||||
|
Object.values(sync().data.mcp_resource).map((resource) => ({
|
||||||
|
id: `resource:${resource.client}:${resource.uri}`,
|
||||||
|
kind: "resource" as const,
|
||||||
|
label: `@${resource.name}`,
|
||||||
|
path: resource.uri,
|
||||||
|
description: resource.description,
|
||||||
|
mention: {
|
||||||
|
type: "file" as const,
|
||||||
|
path: resource.uri,
|
||||||
|
content: `@${resource.name}`,
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
mime: resource.mimeType ?? "text/plain",
|
||||||
|
filename: resource.name,
|
||||||
|
url: resource.uri,
|
||||||
|
source: {
|
||||||
|
type: "resource" as const,
|
||||||
|
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
|
||||||
|
clientName: resource.client,
|
||||||
|
uri: resource.uri,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resource,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const context = createMemo<PromptInputV2Suggestion[]>(() => [
|
||||||
|
...references(),
|
||||||
|
...props.controls.agents.available
|
||||||
|
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||||
|
.map((agent) => ({
|
||||||
|
id: `agent:${agent.name}`,
|
||||||
|
kind: "agent" as const,
|
||||||
|
label: `@${agent.name}`,
|
||||||
|
mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 },
|
||||||
|
})),
|
||||||
|
...resources(),
|
||||||
|
...recent().map((path) => ({
|
||||||
|
id: `file:${path}`,
|
||||||
|
kind: "file" as const,
|
||||||
|
label: path,
|
||||||
|
path,
|
||||||
|
recent: true,
|
||||||
|
mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 },
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
const slashCommands = createMemo(() => [
|
||||||
|
...sync().data.command.map((item) => ({
|
||||||
|
id: `custom.${item.name}`,
|
||||||
|
trigger: item.name,
|
||||||
|
title: item.name,
|
||||||
|
description: item.description,
|
||||||
|
type: "custom" as const,
|
||||||
|
})),
|
||||||
|
...command.options
|
||||||
|
.filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash)
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
trigger: item.slash!,
|
||||||
|
title: item.title,
|
||||||
|
description: item.description,
|
||||||
|
type: "builtin" as const,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
const commands = createMemo<PromptInputV2Suggestion[]>(() =>
|
||||||
|
slashCommands().map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
kind: "command",
|
||||||
|
label: `/${item.trigger}`,
|
||||||
|
trigger: item.trigger,
|
||||||
|
title: item.title,
|
||||||
|
description: item.description,
|
||||||
|
keybind: command.keybindParts(item.id),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
|
||||||
|
const controller = createPromptInputV2Controller({
|
||||||
|
store: () => prompt.capture().store,
|
||||||
|
state: interaction,
|
||||||
|
identity: () => prompt.capture(),
|
||||||
|
history: {
|
||||||
|
entries: (mode) =>
|
||||||
|
history.entries(mode).map((value) => {
|
||||||
|
const entry = normalizePromptHistoryEntry(value)
|
||||||
|
return { prompt: entry.prompt, metadata: entry.comments }
|
||||||
|
}),
|
||||||
|
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
|
||||||
|
capture: historyComments,
|
||||||
|
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
|
||||||
|
},
|
||||||
|
commands,
|
||||||
|
context,
|
||||||
|
searchContextFiles: async (query) =>
|
||||||
|
(await files.searchFilesAndDirectories(query)).map((path) => ({
|
||||||
|
id: `file:${path}`,
|
||||||
|
kind: "file",
|
||||||
|
label: path,
|
||||||
|
path,
|
||||||
|
mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
|
||||||
|
})),
|
||||||
|
onContextRemove(item) {
|
||||||
|
if (item?.commentID) comments.remove(item.path, item.commentID)
|
||||||
|
},
|
||||||
|
openAttachment: (attachment) =>
|
||||||
|
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
|
||||||
|
openContext(key) {
|
||||||
|
const item = controller.contextItem(key)
|
||||||
|
if (item) openComment(item, props, sync, layout, files, comments)
|
||||||
|
},
|
||||||
|
onEditor(element) {
|
||||||
|
editor = element as HTMLDivElement
|
||||||
|
props.ref?.(editor)
|
||||||
|
},
|
||||||
|
onSuggestionSelect(item) {
|
||||||
|
if (item.kind !== "command") return
|
||||||
|
const selected = slashCommands().find((entry) => entry.id === item.id)
|
||||||
|
if (!selected || selected.type === "custom") return
|
||||||
|
return () => command.trigger(selected.id, "slash")
|
||||||
|
},
|
||||||
|
attachments: {
|
||||||
|
picker: platform.openAttachmentPickerDialog,
|
||||||
|
directory: () => sdk().directory,
|
||||||
|
isDialogActive: () => !!dialog.active,
|
||||||
|
warn: () =>
|
||||||
|
showToast({
|
||||||
|
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||||
|
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||||
|
}),
|
||||||
|
onError: (error) =>
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
}),
|
||||||
|
readClipboardImage: platform.readClipboardImage,
|
||||||
|
getPathForFile: platform.getPathForFile,
|
||||||
|
},
|
||||||
|
view: {
|
||||||
|
placeholder: designPlaceholder,
|
||||||
|
agent:
|
||||||
|
props.controls.agents.visible && props.controls.agents.options.length > 0
|
||||||
|
? {
|
||||||
|
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
|
||||||
|
current: () => props.controls.agents.current,
|
||||||
|
onSelect: props.controls.agents.select,
|
||||||
|
keybind: () => command.keybindParts("agent.cycle"),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
variant: {
|
||||||
|
options: () => variants().map((value) => ({ id: value, label: value })),
|
||||||
|
current: () => props.controls.model.selection.variant.current() ?? "default",
|
||||||
|
onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
|
||||||
|
keybind: () => command.keybindParts("model.variant.cycle"),
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
stopping,
|
||||||
|
working,
|
||||||
|
onSubmit: () => void submission.handleSubmit(new Event("submit")),
|
||||||
|
onStop: () => void submission.abort(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
||||||
|
return controller as PromptInputV2ComposerController
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptInputV2ModelControl(props: {
|
||||||
|
loading: boolean
|
||||||
|
paid: boolean
|
||||||
|
title: string
|
||||||
|
keybind: string[]
|
||||||
|
model: PromptInputV2ComposerController["model"]["selection"]
|
||||||
|
providerID?: string
|
||||||
|
modelName: string
|
||||||
|
onClose: () => void
|
||||||
|
onUnpaidClick: () => void
|
||||||
|
}) {
|
||||||
|
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
|
||||||
|
const content = () => (
|
||||||
|
<>
|
||||||
|
<Show when={props.providerID}>
|
||||||
|
{(providerID) => (
|
||||||
|
<ProviderIcon
|
||||||
|
id={providerID()}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<span class="truncate leading-4">{props.modelName}</span>
|
||||||
|
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||||
|
<Icon name="chevron-down" />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Show when={!props.loading}>
|
||||||
|
<TooltipV2
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{props.title}
|
||||||
|
<KeybindV2 keys={props.keybind} variant="neutral" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={props.paid}
|
||||||
|
fallback={
|
||||||
|
<ButtonV2
|
||||||
|
data-action="prompt-model"
|
||||||
|
variant="ghost-muted"
|
||||||
|
size="normal"
|
||||||
|
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||||
|
classList={{ "animate-in fade-in": shouldAnimate() }}
|
||||||
|
style={{ height: "28px" }}
|
||||||
|
onClick={props.onUnpaidClick}
|
||||||
|
>
|
||||||
|
{content()}
|
||||||
|
</ButtonV2>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ModelSelectorPopoverV2
|
||||||
|
model={props.model}
|
||||||
|
triggerAs={ButtonV2}
|
||||||
|
triggerProps={{
|
||||||
|
variant: "ghost-muted",
|
||||||
|
size: "normal",
|
||||||
|
style: { height: "28px" },
|
||||||
|
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
||||||
|
classList: { "animate-in fade-in": shouldAnimate() },
|
||||||
|
"data-action": "prompt-model",
|
||||||
|
}}
|
||||||
|
onClose={props.onClose}
|
||||||
|
>
|
||||||
|
{content()}
|
||||||
|
</ModelSelectorPopoverV2>
|
||||||
|
</Show>
|
||||||
|
</TooltipV2>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openComment(
|
||||||
|
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
|
||||||
|
props: PromptInputV2ControllerProps,
|
||||||
|
sync: ReturnType<typeof useSync>,
|
||||||
|
layout: ReturnType<typeof useLayout>,
|
||||||
|
files: ReturnType<typeof useFile>,
|
||||||
|
comments: ReturnType<typeof useComments>,
|
||||||
|
) {
|
||||||
|
if (!item.commentID) return
|
||||||
|
const focus = { file: item.path, id: item.commentID }
|
||||||
|
comments.setActive(focus)
|
||||||
|
const queueFocus = (attempts = 6) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
comments.setFocus({ ...focus })
|
||||||
|
if (attempts <= 0) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const current = comments.focus()
|
||||||
|
if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
|
||||||
|
const review =
|
||||||
|
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
|
||||||
|
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||||
|
if (review) {
|
||||||
|
layout.fileTree.setTab("changes")
|
||||||
|
props.controls.session.tabs.setActive("review")
|
||||||
|
queueFocus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
layout.fileTree.setTab("all")
|
||||||
|
const tab = files.tab(item.path)
|
||||||
|
void props.controls.session.tabs.open(tab)
|
||||||
|
props.controls.session.tabs.setActive(tab)
|
||||||
|
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@ type PromptAttachmentsCoreInput = {
|
|||||||
getPathForFile?: (file: File) => string
|
getPathForFile?: (file: File) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PromptAttachmentsInput = {
|
export type PromptAttachmentsInput = {
|
||||||
prompt: ReturnType<typeof usePrompt>
|
prompt: ReturnType<typeof usePrompt>
|
||||||
editor: () => HTMLDivElement | undefined
|
editor: () => HTMLDivElement | undefined
|
||||||
isDialogActive: () => boolean
|
isDialogActive: () => boolean
|
||||||
|
|||||||
@@ -89,13 +89,15 @@ const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildRequestParts(input: BuildRequestPartsInput) {
|
export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||||
const requestParts: PromptRequestPart[] = [
|
const requestParts: PromptRequestPart[] = input.text.trim()
|
||||||
{
|
? [
|
||||||
id: Identifier.ascending("part"),
|
{
|
||||||
type: "text",
|
id: Identifier.ascending("part"),
|
||||||
text: input.text,
|
type: "text",
|
||||||
},
|
text: input.text,
|
||||||
]
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||||
const path = absolute(input.sessionDirectory, attachment.path)
|
const path = absolute(input.sessionDirectory, attachment.path)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { useLocal } from "@/context/local"
|
||||||
|
import type { Prompt, usePrompt } from "@/context/prompt"
|
||||||
|
import type { PromptInputHistory } from "./history-store"
|
||||||
|
import type { FollowupDraft } from "./submit"
|
||||||
|
|
||||||
|
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||||
|
|
||||||
|
export type PromptInputSubmission = {
|
||||||
|
abort: () => Promise<void> | void
|
||||||
|
handleSubmit: (event: Event) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptInputControls = {
|
||||||
|
agents: {
|
||||||
|
available: { name: string; hidden?: boolean; mode: string }[]
|
||||||
|
options: string[]
|
||||||
|
current: string
|
||||||
|
loading: boolean
|
||||||
|
visible: boolean
|
||||||
|
select: (name: string | undefined) => void
|
||||||
|
}
|
||||||
|
model: {
|
||||||
|
selection: ReturnType<typeof useLocal>["model"]
|
||||||
|
paid: boolean
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
session: {
|
||||||
|
id?: string
|
||||||
|
tabs: {
|
||||||
|
active: () => string | undefined
|
||||||
|
all: () => string[]
|
||||||
|
open: (tab: string) => void | Promise<void>
|
||||||
|
setActive: (tab: string) => void
|
||||||
|
}
|
||||||
|
reviewPanel: {
|
||||||
|
opened: () => boolean
|
||||||
|
open: () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptInputProps {
|
||||||
|
class?: string
|
||||||
|
state?: PromptInputState
|
||||||
|
history?: PromptInputHistory
|
||||||
|
submission?: PromptInputSubmission
|
||||||
|
controls: PromptInputControls
|
||||||
|
ref?: (el: HTMLDivElement) => void
|
||||||
|
newSessionWorktree?: string
|
||||||
|
onNewSessionWorktreeReset?: () => void
|
||||||
|
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
|
||||||
|
onEditLoaded?: () => void
|
||||||
|
shouldQueue?: () => boolean
|
||||||
|
onQueue?: (draft: FollowupDraft) => void
|
||||||
|
onAbort?: () => void
|
||||||
|
onSubmit?: () => void
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
|
import type { Prompt } from "@/context/prompt"
|
||||||
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history"
|
||||||
|
|
||||||
|
export type PromptInputHistory = {
|
||||||
|
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||||
|
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
|
||||||
|
|
||||||
|
function createPromptInputHistoryStore(
|
||||||
|
normal: Store<PromptHistoryState>,
|
||||||
|
setNormal: SetStoreFunction<PromptHistoryState>,
|
||||||
|
shell: Store<PromptHistoryState>,
|
||||||
|
setShell: SetStoreFunction<PromptHistoryState>,
|
||||||
|
): PromptInputHistory {
|
||||||
|
return {
|
||||||
|
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
|
||||||
|
add(prompt, mode, comments) {
|
||||||
|
const current = mode === "shell" ? shell : normal
|
||||||
|
const setCurrent = mode === "shell" ? setShell : setNormal
|
||||||
|
const next = prependHistoryEntry(current.entries, prompt, comments)
|
||||||
|
if (next === current.entries) return
|
||||||
|
setCurrent("entries", next)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPromptInputHistory(): PromptInputHistory {
|
||||||
|
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
|
||||||
|
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
|
||||||
|
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPersistedPromptInputHistory() {
|
||||||
|
const [normal, setNormal] = persisted(
|
||||||
|
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||||
|
createStore<PromptHistoryState>({ entries: [] }),
|
||||||
|
)
|
||||||
|
const [shell, setShell] = persisted(
|
||||||
|
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||||
|
createStore<PromptHistoryState>({ entries: [] }),
|
||||||
|
)
|
||||||
|
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||||
|
}
|
||||||
@@ -13,3 +13,8 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
|
|||||||
if (!input.suggest) return input.t("prompt.placeholder.simple")
|
if (!input.suggest) return input.t("prompt.placeholder.simple")
|
||||||
return input.t("prompt.placeholder.normal", { example: input.example })
|
return input.t("prompt.placeholder.normal", { example: input.example })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) {
|
||||||
|
if (mode === "shell") return placeholder
|
||||||
|
return "Ask anything, / for commands, @ for context..."
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
import type { Prompt } from "@/context/prompt"
|
import { createStore } from "solid-js/store"
|
||||||
|
import type { Prompt, PromptStore } from "@/context/prompt"
|
||||||
import type { ModelSelection } from "@/context/local"
|
import type { ModelSelection } from "@/context/local"
|
||||||
|
|
||||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||||
@@ -31,7 +32,13 @@ let permissionServer = "server-a"
|
|||||||
let createSessionGate: Promise<void> | undefined
|
let createSessionGate: Promise<void> | undefined
|
||||||
|
|
||||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
|
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||||
|
prompt: promptValue,
|
||||||
|
cursor: 0,
|
||||||
|
context: { items: [] },
|
||||||
|
})
|
||||||
const prompt = {
|
const prompt = {
|
||||||
|
store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore],
|
||||||
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
|
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||||
current: () => promptValue,
|
current: () => promptValue,
|
||||||
cursor: () => 0,
|
cursor: () => 0,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export type PromptInputTransientState = {
|
|||||||
draggingType: "image" | "@mention" | null
|
draggingType: "image" | "@mention" | null
|
||||||
mode: "normal" | "shell"
|
mode: "normal" | "shell"
|
||||||
applyingHistory: boolean
|
applyingHistory: boolean
|
||||||
variantOpen: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
|
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
|
||||||
@@ -25,7 +24,6 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
|
|||||||
draggingType: null,
|
draggingType: null,
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
variantOpen: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +38,6 @@ export function createPromptInputTransientState(identity: Accessor<unknown>, pla
|
|||||||
draggingType: null,
|
draggingType: null,
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
variantOpen: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
|
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function createPromptProjectController(input: {
|
|||||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||||
let searchRef: HTMLInputElement | undefined
|
let searchRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
const selected = () => {
|
const current = () => {
|
||||||
const key = pathKey(input.controls().directory)
|
const key = pathKey(input.controls().directory)
|
||||||
return input
|
return input
|
||||||
.controls()
|
.controls()
|
||||||
@@ -65,6 +65,7 @@ export function createPromptProjectController(input: {
|
|||||||
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
|
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const selected = () => current() ?? input.controls().available[0]
|
||||||
const projects = () => {
|
const projects = () => {
|
||||||
const search = store.search.trim().toLowerCase()
|
const search = store.search.trim().toLowerCase()
|
||||||
if (!search) return input.controls().available
|
if (!search) return input.controls().available
|
||||||
@@ -100,8 +101,8 @@ export function createPromptProjectController(input: {
|
|||||||
}
|
}
|
||||||
const select = (project: PromptProject) => {
|
const select = (project: PromptProject) => {
|
||||||
if (
|
if (
|
||||||
pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
|
pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") ||
|
||||||
project.server?.key !== selected()?.server?.key
|
project.server?.key !== current()?.server?.key
|
||||||
) {
|
) {
|
||||||
input.controls().select(project.worktree, project.server?.key)
|
input.controls().select(project.worktree, project.server?.key)
|
||||||
}
|
}
|
||||||
@@ -124,6 +125,7 @@ export function createPromptProjectController(input: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
selected,
|
selected,
|
||||||
|
empty: () => input.controls().available.length === 0,
|
||||||
projects,
|
projects,
|
||||||
servers,
|
servers,
|
||||||
projectKey,
|
projectKey,
|
||||||
|
|||||||
@@ -93,24 +93,36 @@ export function PromptWorkspaceSelector(props: {
|
|||||||
</MenuV2.Content>
|
</MenuV2.Content>
|
||||||
</MenuV2.Portal>
|
</MenuV2.Portal>
|
||||||
</MenuV2>
|
</MenuV2>
|
||||||
<Show when={props.branch}>
|
<PromptGitStatus branch={props.branch} />
|
||||||
{(branch) => (
|
|
||||||
<>
|
|
||||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
|
||||||
<TooltipV2
|
|
||||||
placement="top"
|
|
||||||
value={branch()}
|
|
||||||
class="min-w-0 max-w-[220px]"
|
|
||||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
|
||||||
>
|
|
||||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
|
||||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
|
||||||
<span class="min-w-0 truncate">{branch()}</span>
|
|
||||||
</div>
|
|
||||||
</TooltipV2>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const label = () => {
|
||||||
|
if (props.noGit) return language.t("session.new.git.none")
|
||||||
|
return props.branch
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={label()}>
|
||||||
|
{(value) => (
|
||||||
|
<>
|
||||||
|
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||||
|
<TooltipV2
|
||||||
|
placement="top"
|
||||||
|
value={value()}
|
||||||
|
class="min-w-0 max-w-[220px]"
|
||||||
|
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||||
|
>
|
||||||
|
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||||
|
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||||
|
<span class="min-w-0 truncate">{value()}</span>
|
||||||
|
</div>
|
||||||
|
</TooltipV2>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { createMemo, Show } from "solid-js"
|
|||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { useSortable } from "@dnd-kit/solid/sortable"
|
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -19,6 +20,7 @@ export function SortableTabV2(props: {
|
|||||||
const file = useFile()
|
const file = useFile()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
|
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
|
||||||
const sortable = useSortable({
|
const sortable = useSortable({
|
||||||
get id() {
|
get id() {
|
||||||
return props.tab
|
return props.tab
|
||||||
@@ -39,9 +41,15 @@ export function SortableTabV2(props: {
|
|||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value={props.tab}
|
value={props.tab}
|
||||||
closeButton={
|
closeButton={
|
||||||
<TooltipKeybind
|
<TooltipV2
|
||||||
title={language.t("common.closeTab")}
|
value={
|
||||||
keybind={command.keybind("tab.close")}
|
<>
|
||||||
|
{language.t("common.closeTab")}
|
||||||
|
<Show when={closeTabKeybind().length > 0}>
|
||||||
|
<KeybindV2 keys={closeTabKeybind()} variant="neutral" />
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
}
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
gutter={10}
|
gutter={10}
|
||||||
>
|
>
|
||||||
@@ -52,7 +60,7 @@ export function SortableTabV2(props: {
|
|||||||
onClick={() => props.onTabClose(props.tab)}
|
onClick={() => props.onTabClose(props.tab)}
|
||||||
aria-label={language.t("common.closeTab")}
|
aria-label={language.t("common.closeTab")}
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipV2>
|
||||||
}
|
}
|
||||||
hideCloseButton
|
hideCloseButton
|
||||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||||
|
|||||||
@@ -133,9 +133,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="settings-v2-row-description"] {
|
[data-slot="settings-v2-row-description"] {
|
||||||
|
margin-block: -3.5px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 440;
|
font-weight: 440;
|
||||||
line-height: 1;
|
line-height: 20px;
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--v2-text-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,10 +271,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-provider-description {
|
.settings-v2-provider-description {
|
||||||
margin: 0;
|
margin-block: -3.5px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 440;
|
font-weight: 440;
|
||||||
line-height: 1;
|
line-height: 20px;
|
||||||
color: var(--v2-text-text-muted);
|
color: var(--v2-text-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -292,7 +292,12 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
const scheduleSize = (cols: number, rows: number) => {
|
const scheduleSize = (cols: number, rows: number) => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
if (lastSize?.cols === cols && lastSize?.rows === rows) return
|
if (lastSize?.cols === cols && lastSize?.rows === rows) {
|
||||||
|
pendingSize = undefined
|
||||||
|
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
||||||
|
sizeTimer = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
pendingSize = { cols, rows }
|
pendingSize = { cols, rows }
|
||||||
|
|
||||||
@@ -317,8 +322,10 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const colors = terminalColors()
|
const colors = terminalColors()
|
||||||
|
const mode = theme.mode() === "dark" ? "dark" : "light"
|
||||||
if (!term) return
|
if (!term) return
|
||||||
setOptionIfSupported(term, "theme", colors)
|
setOptionIfSupported(term, "theme", colors)
|
||||||
|
setOptionIfSupported(term, "colorScheme", mode)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -396,6 +403,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
_ghostty = g
|
_ghostty = g
|
||||||
term = t
|
term = t
|
||||||
|
setOptionIfSupported(t, "colorScheme", theme.mode() === "dark" ? "dark" : "light")
|
||||||
output = terminalWriter((data, done) =>
|
output = terminalWriter((data, done) =>
|
||||||
t.write(data, () => {
|
t.write(data, () => {
|
||||||
done?.()
|
done?.()
|
||||||
@@ -595,6 +603,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
tries = 0
|
tries = 0
|
||||||
local.onConnect?.()
|
local.onConnect?.()
|
||||||
scheduleSize(t.cols, t.rows)
|
scheduleSize(t.cols, t.rows)
|
||||||
|
if (t.getMode(2031)) t.write("\x1b[?996n")
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function useTitlebarRightMount() {
|
|||||||
return mount
|
return mount
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Titlebar(props: { update?: TitlebarUpdate }) {
|
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
@@ -462,7 +462,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
"md:pl-4": !mac(),
|
"md:pl-4": !mac(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChannelIndicator />
|
<ChannelIndicator debugTools={props.debugTools} />
|
||||||
<Show when={windows() || linux()}>
|
<Show when={windows() || linux()}>
|
||||||
<WindowsAppMenu command={command} platform={platform} variant="v2" />
|
<WindowsAppMenu command={command} platform={platform} variant="v2" />
|
||||||
</Show>
|
</Show>
|
||||||
@@ -660,9 +660,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||||
<ChannelIndicator />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ChannelIndicator debugTools={props.debugTools} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -747,12 +747,27 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChannelIndicator() {
|
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||||
|
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
|
||||||
|
if (channel === "dev" && props.debugTools) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono cursor-pointer"
|
||||||
|
onClick={props.debugTools.toggle}
|
||||||
|
aria-label="Toggle debug tools"
|
||||||
|
aria-pressed={props.debugTools.visible}
|
||||||
|
>
|
||||||
|
DEV
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
|
{["beta", "dev"].includes(channel) && (
|
||||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||||
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
|
{channel.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -176,7 +176,14 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
|
|
||||||
onCleanup(() => states.forEach((value) => value.dispose()))
|
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||||
|
|
||||||
const selected = () => ensure(activeServer())
|
const selected = () => {
|
||||||
|
const list = global.servers.list()
|
||||||
|
const key = activeServer()
|
||||||
|
if (list.some((conn) => ServerConnection.key(conn) === key)) return ensure(key)
|
||||||
|
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
|
||||||
|
if (!conn) throw new Error("Notification server not found")
|
||||||
|
return ensure(ServerConnection.key(conn))
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => selected().ready(),
|
ready: () => selected().ready(),
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ type PlatformBase = {
|
|||||||
/** Export collected diagnostic logs (desktop only) */
|
/** Export collected diagnostic logs (desktop only) */
|
||||||
exportDebugLogs?(): Promise<string>
|
exportDebugLogs?(): Promise<string>
|
||||||
|
|
||||||
|
/** Force focus styles on interactive elements through desktop devtools (desktop only) */
|
||||||
|
setForceFocus?(enabled: boolean): Promise<void>
|
||||||
|
|
||||||
/** Record a fatal renderer error in platform logs (desktop only) */
|
/** Record a fatal renderer error in platform logs (desktop only) */
|
||||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export type PromptScope = { draftID: string } | { dir: string; id?: string }
|
|||||||
|
|
||||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||||
|
|
||||||
type PromptStore = {
|
export type PromptStore = {
|
||||||
prompt: Prompt
|
prompt: Prompt
|
||||||
cursor?: number
|
cursor?: number
|
||||||
model?: PromptModel
|
model?: PromptModel
|
||||||
@@ -189,6 +189,7 @@ function promptStore(initial?: InitialPrompt): PromptStore {
|
|||||||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
||||||
const actions = createPromptActions(setStore)
|
const actions = createPromptActions(setStore)
|
||||||
const value = {
|
const value = {
|
||||||
|
store: [() => store, setStore] as [Accessor<PromptStore>, SetStoreFunction<PromptStore>],
|
||||||
current: () => store.prompt,
|
current: () => store.prompt,
|
||||||
cursor: createMemo(() => store.cursor),
|
cursor: createMemo(() => store.cursor),
|
||||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useParams, useSearchParams } from "@solidjs/router"
|
import { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
import { ServerConnection } from "./server"
|
import { ServerConnection } from "./server"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import { useServerSDK } from "./server-sdk"
|
||||||
@@ -36,6 +36,7 @@ export type {
|
|||||||
ImageAttachmentPart,
|
ImageAttachmentPart,
|
||||||
Prompt,
|
Prompt,
|
||||||
PromptModel,
|
PromptModel,
|
||||||
|
PromptStore,
|
||||||
PromptScope,
|
PromptScope,
|
||||||
PromptSession,
|
PromptSession,
|
||||||
TextPart,
|
TextPart,
|
||||||
@@ -132,18 +133,29 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||||||
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
||||||
const ready = createPromptReady(session)
|
const ready = createPromptReady(session)
|
||||||
|
|
||||||
|
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
||||||
|
createResource(
|
||||||
|
async () => {
|
||||||
|
const value = cb()
|
||||||
|
await session().ready.promise
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
cb,
|
||||||
|
{ initialValue: cb() },
|
||||||
|
)[0]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
capture: (scope?: PromptScope) => pick(scope).capture(),
|
capture: (scope?: PromptScope) => pick(scope).capture(),
|
||||||
current: () => session().current(),
|
current: withSuspense(() => session().current()),
|
||||||
cursor: () => session().cursor(),
|
cursor: withSuspense(() => session().cursor()),
|
||||||
dirty: () => session().dirty(),
|
dirty: withSuspense(() => session().dirty()),
|
||||||
model: {
|
model: {
|
||||||
current: () => session().model.current(),
|
current: withSuspense(() => session().model.current()),
|
||||||
set: (model: PromptModel | undefined) => session().model.set(model),
|
set: (model: PromptModel | undefined) => session().model.set(model),
|
||||||
},
|
},
|
||||||
context: {
|
context: {
|
||||||
items: () => session().context.items(),
|
items: withSuspense(() => session().context.items()),
|
||||||
add: (item: ContextItem) => session().context.add(item),
|
add: (item: ContextItem) => session().context.add(item),
|
||||||
remove: (key: string) => session().context.remove(key),
|
remove: (key: string) => session().context.remove(key),
|
||||||
removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
|
removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
|
||||||
|
|||||||
@@ -199,6 +199,49 @@ describe("server session", () => {
|
|||||||
expect(store.history.more("child")).toBe(true)
|
expect(store.history.more("child")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
|
||||||
|
const missing = Promise.withResolvers<SingleMessageResponse>()
|
||||||
|
const assistant = assistantMessage("message-2", "message-missing")
|
||||||
|
const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [missing.promise])
|
||||||
|
const store = createServerSession(client)
|
||||||
|
const loading = store.sync("child")
|
||||||
|
await client.rootRequested(1)
|
||||||
|
|
||||||
|
missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } }))
|
||||||
|
await loading
|
||||||
|
|
||||||
|
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
|
||||||
|
expect(store.data.message.child).toEqual([assistant])
|
||||||
|
expect(store.history.more("child")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
|
||||||
|
const missing = Promise.withResolvers<SingleMessageResponse>()
|
||||||
|
const parent = userMessage("message-1")
|
||||||
|
const part = textPart(parent.id)
|
||||||
|
const assistant = assistantMessage("message-2", parent.id)
|
||||||
|
const client = rootMessageClient(
|
||||||
|
[
|
||||||
|
response([
|
||||||
|
{ info: parent, parts: [part] },
|
||||||
|
{ info: assistant, parts: [] },
|
||||||
|
]),
|
||||||
|
response([{ info: assistant, parts: [] }], "older"),
|
||||||
|
],
|
||||||
|
[missing.promise],
|
||||||
|
)
|
||||||
|
const store = createServerSession(client)
|
||||||
|
await store.sync("child")
|
||||||
|
const loading = store.sync("child", { force: true })
|
||||||
|
await client.rootRequested(1)
|
||||||
|
|
||||||
|
missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } }))
|
||||||
|
await loading
|
||||||
|
|
||||||
|
expect(store.data.message.child).toEqual([assistant])
|
||||||
|
expect(store.data.part[parent.id]).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
test("does not let an optimistic user suppress initial root backfill", async () => {
|
test("does not let an optimistic user suppress initial root backfill", async () => {
|
||||||
const user = userMessage("message-1")
|
const user = userMessage("message-1")
|
||||||
const part = textPart(user.id)
|
const part = textPart(user.id)
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ function reconcileFetched<T extends { id: string }>(
|
|||||||
options: {
|
options: {
|
||||||
touched?: ReadonlySet<string>
|
touched?: ReadonlySet<string>
|
||||||
retained?: ReadonlySet<string>
|
retained?: ReadonlySet<string>
|
||||||
|
removed?: ReadonlySet<string>
|
||||||
preserveUnfetched?: boolean | ((item: T) => boolean)
|
preserveUnfetched?: boolean | ((item: T) => boolean)
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
@@ -131,6 +132,7 @@ function reconcileFetched<T extends { id: string }>(
|
|||||||
if (item) result.set(id, item)
|
if (item) result.set(id, item)
|
||||||
if (!item) result.delete(id)
|
if (!item) result.delete(id)
|
||||||
}
|
}
|
||||||
|
for (const id of options.removed ?? emptyIDs) result.delete(id)
|
||||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,6 +572,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||||||
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
|
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
|
||||||
touched: touchedMessages,
|
touched: touchedMessages,
|
||||||
retained: load?.retainedMessages,
|
retained: load?.retainedMessages,
|
||||||
|
removed: load?.removedMessages,
|
||||||
preserveUnfetched,
|
preserveUnfetched,
|
||||||
})
|
})
|
||||||
batch(() => {
|
batch(() => {
|
||||||
@@ -638,7 +641,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||||||
if (generations.get(sessionID) !== active) break
|
if (generations.get(sessionID) !== active) break
|
||||||
const parent = await fetchMessage(sessionID, parentID, () =>
|
const parent = await fetchMessage(sessionID, parentID, () =>
|
||||||
resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)),
|
resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)),
|
||||||
)
|
).catch((error) => {
|
||||||
|
const cause = error instanceof Error && typeof error.cause === "object" ? error.cause : undefined
|
||||||
|
if (cause && "status" in cause && cause.status === 404) {
|
||||||
|
load.removedMessages.add(parentID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
if (!parent) continue
|
||||||
if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`)
|
if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`)
|
||||||
parents.push(parent)
|
parents.push(parent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -610,6 +610,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "محلي",
|
"session.new.workspace.triggerLocal": "محلي",
|
||||||
"session.new.workspace.local": "المستودع المحلي",
|
"session.new.workspace.local": "المستودع المحلي",
|
||||||
"session.new.workspace.existing": "مساحة عمل…",
|
"session.new.workspace.existing": "مساحة عمل…",
|
||||||
|
"session.new.git.none": "لا يوجد Git",
|
||||||
"session.new.lastModified": "آخر تعديل",
|
"session.new.lastModified": "آخر تعديل",
|
||||||
"session.header.search.placeholder": "بحث {{project}}",
|
"session.header.search.placeholder": "بحث {{project}}",
|
||||||
"session.header.searchFiles": "بحث عن الملفات",
|
"session.header.searchFiles": "بحث عن الملفات",
|
||||||
|
|||||||
@@ -616,6 +616,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "Local",
|
"session.new.workspace.triggerLocal": "Local",
|
||||||
"session.new.workspace.local": "Repositório local",
|
"session.new.workspace.local": "Repositório local",
|
||||||
"session.new.workspace.existing": "Espaço de trabalho…",
|
"session.new.workspace.existing": "Espaço de trabalho…",
|
||||||
|
"session.new.git.none": "Sem Git",
|
||||||
"session.new.lastModified": "Última modificação",
|
"session.new.lastModified": "Última modificação",
|
||||||
"session.header.search.placeholder": "Buscar {{project}}",
|
"session.header.search.placeholder": "Buscar {{project}}",
|
||||||
"session.header.searchFiles": "Buscar arquivos",
|
"session.header.searchFiles": "Buscar arquivos",
|
||||||
|
|||||||
@@ -672,6 +672,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "Lokalno",
|
"session.new.workspace.triggerLocal": "Lokalno",
|
||||||
"session.new.workspace.local": "Lokalni repozitorij",
|
"session.new.workspace.local": "Lokalni repozitorij",
|
||||||
"session.new.workspace.existing": "Radni prostor…",
|
"session.new.workspace.existing": "Radni prostor…",
|
||||||
|
"session.new.git.none": "Nema Gita",
|
||||||
"session.new.lastModified": "Posljednja izmjena",
|
"session.new.lastModified": "Posljednja izmjena",
|
||||||
|
|
||||||
"session.header.search.placeholder": "Pretraži {{project}}",
|
"session.header.search.placeholder": "Pretraži {{project}}",
|
||||||
|
|||||||
@@ -667,6 +667,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "Lokal",
|
"session.new.workspace.triggerLocal": "Lokal",
|
||||||
"session.new.workspace.local": "Lokalt repository",
|
"session.new.workspace.local": "Lokalt repository",
|
||||||
"session.new.workspace.existing": "Arbejdsområde…",
|
"session.new.workspace.existing": "Arbejdsområde…",
|
||||||
|
"session.new.git.none": "Ingen Git",
|
||||||
"session.new.lastModified": "Sidst ændret",
|
"session.new.lastModified": "Sidst ændret",
|
||||||
|
|
||||||
"session.header.search.placeholder": "Søg {{project}}",
|
"session.header.search.placeholder": "Søg {{project}}",
|
||||||
|
|||||||
@@ -625,6 +625,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "Lokal",
|
"session.new.workspace.triggerLocal": "Lokal",
|
||||||
"session.new.workspace.local": "Lokales Repository",
|
"session.new.workspace.local": "Lokales Repository",
|
||||||
"session.new.workspace.existing": "Arbeitsbereich…",
|
"session.new.workspace.existing": "Arbeitsbereich…",
|
||||||
|
"session.new.git.none": "Kein Git",
|
||||||
"session.new.lastModified": "Zuletzt geändert",
|
"session.new.lastModified": "Zuletzt geändert",
|
||||||
"session.header.search.placeholder": "{{project}} durchsuchen",
|
"session.header.search.placeholder": "{{project}} durchsuchen",
|
||||||
"session.header.searchFiles": "Dateien suchen",
|
"session.header.searchFiles": "Dateien suchen",
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const dict = {
|
|||||||
"dialog.provider.empty": "No providers found",
|
"dialog.provider.empty": "No providers found",
|
||||||
"dialog.provider.group.popular": "Popular",
|
"dialog.provider.group.popular": "Popular",
|
||||||
"dialog.provider.group.other": "Other",
|
"dialog.provider.group.other": "Other",
|
||||||
|
"dialog.provider.custom.label": "Custom OpenAI-compatible provider",
|
||||||
"dialog.provider.tag.recommended": "Recommended",
|
"dialog.provider.tag.recommended": "Recommended",
|
||||||
"dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more",
|
"dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more",
|
||||||
"dialog.provider.opencode.tagline": "Reliable optimized models",
|
"dialog.provider.opencode.tagline": "Reliable optimized models",
|
||||||
@@ -122,6 +123,7 @@ export const dict = {
|
|||||||
|
|
||||||
"dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
|
"dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
|
||||||
"dialog.model.unpaid.addMore.title": "Add more models from popular providers",
|
"dialog.model.unpaid.addMore.title": "Add more models from popular providers",
|
||||||
|
"dialog.model.unpaid.viewMoreProviders": "See 70+ more providers",
|
||||||
|
|
||||||
"dialog.provider.viewAll": "Show more providers",
|
"dialog.provider.viewAll": "Show more providers",
|
||||||
|
|
||||||
@@ -694,6 +696,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "Local",
|
"session.new.workspace.triggerLocal": "Local",
|
||||||
"session.new.workspace.local": "Local repository",
|
"session.new.workspace.local": "Local repository",
|
||||||
"session.new.workspace.existing": "Workspace…",
|
"session.new.workspace.existing": "Workspace…",
|
||||||
|
"session.new.git.none": "No Git",
|
||||||
"session.new.lastModified": "Last modified",
|
"session.new.lastModified": "Last modified",
|
||||||
|
|
||||||
"session.header.search.placeholder": "Search {{project}}",
|
"session.header.search.placeholder": "Search {{project}}",
|
||||||
|
|||||||
@@ -673,6 +673,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "Local",
|
"session.new.workspace.triggerLocal": "Local",
|
||||||
"session.new.workspace.local": "Repositorio local",
|
"session.new.workspace.local": "Repositorio local",
|
||||||
"session.new.workspace.existing": "Espacio de trabajo…",
|
"session.new.workspace.existing": "Espacio de trabajo…",
|
||||||
|
"session.new.git.none": "Sin Git",
|
||||||
"session.new.lastModified": "Última modificación",
|
"session.new.lastModified": "Última modificación",
|
||||||
|
|
||||||
"session.header.search.placeholder": "Buscar {{project}}",
|
"session.header.search.placeholder": "Buscar {{project}}",
|
||||||
|
|||||||
@@ -621,6 +621,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "Local",
|
"session.new.workspace.triggerLocal": "Local",
|
||||||
"session.new.workspace.local": "Dépôt local",
|
"session.new.workspace.local": "Dépôt local",
|
||||||
"session.new.workspace.existing": "Espace de travail…",
|
"session.new.workspace.existing": "Espace de travail…",
|
||||||
|
"session.new.git.none": "Pas de Git",
|
||||||
"session.new.lastModified": "Dernière modification",
|
"session.new.lastModified": "Dernière modification",
|
||||||
"session.header.search.placeholder": "Rechercher {{project}}",
|
"session.header.search.placeholder": "Rechercher {{project}}",
|
||||||
"session.header.searchFiles": "Rechercher des fichiers",
|
"session.header.searchFiles": "Rechercher des fichiers",
|
||||||
|
|||||||
@@ -612,6 +612,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "ローカル",
|
"session.new.workspace.triggerLocal": "ローカル",
|
||||||
"session.new.workspace.local": "ローカルリポジトリ",
|
"session.new.workspace.local": "ローカルリポジトリ",
|
||||||
"session.new.workspace.existing": "ワークスペース…",
|
"session.new.workspace.existing": "ワークスペース…",
|
||||||
|
"session.new.git.none": "Git なし",
|
||||||
"session.new.lastModified": "最終更新",
|
"session.new.lastModified": "最終更新",
|
||||||
"session.header.search.placeholder": "{{project}}を検索",
|
"session.header.search.placeholder": "{{project}}を検索",
|
||||||
"session.header.searchFiles": "ファイルを検索",
|
"session.header.searchFiles": "ファイルを検索",
|
||||||
|
|||||||
@@ -970,6 +970,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "로컬",
|
"session.new.workspace.triggerLocal": "로컬",
|
||||||
"session.new.workspace.local": "로컬 저장소",
|
"session.new.workspace.local": "로컬 저장소",
|
||||||
"session.new.workspace.existing": "작업 공간…",
|
"session.new.workspace.existing": "작업 공간…",
|
||||||
|
"session.new.git.none": "Git 없음",
|
||||||
|
|
||||||
"sidebar.empty.title": "열린 프로젝트 없음",
|
"sidebar.empty.title": "열린 프로젝트 없음",
|
||||||
"sidebar.empty.description": "프로젝트를 열어 시작하세요",
|
"sidebar.empty.description": "프로젝트를 열어 시작하세요",
|
||||||
|
|||||||
@@ -1064,6 +1064,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "Lokalt",
|
"session.new.workspace.triggerLocal": "Lokalt",
|
||||||
"session.new.workspace.local": "Lokalt depot",
|
"session.new.workspace.local": "Lokalt depot",
|
||||||
"session.new.workspace.existing": "Arbeidsområde…",
|
"session.new.workspace.existing": "Arbeidsområde…",
|
||||||
|
"session.new.git.none": "Ingen Git",
|
||||||
|
|
||||||
"sidebar.empty.title": "Ingen åpne prosjekter",
|
"sidebar.empty.title": "Ingen åpne prosjekter",
|
||||||
"sidebar.empty.description": "Åpne et prosjekt for å komme i gang",
|
"sidebar.empty.description": "Åpne et prosjekt for å komme i gang",
|
||||||
|
|||||||
@@ -616,6 +616,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "Lokalnie",
|
"session.new.workspace.triggerLocal": "Lokalnie",
|
||||||
"session.new.workspace.local": "Lokalne repozytorium",
|
"session.new.workspace.local": "Lokalne repozytorium",
|
||||||
"session.new.workspace.existing": "Przestrzeń robocza…",
|
"session.new.workspace.existing": "Przestrzeń robocza…",
|
||||||
|
"session.new.git.none": "Brak Git",
|
||||||
"session.new.lastModified": "Ostatnio zmodyfikowano",
|
"session.new.lastModified": "Ostatnio zmodyfikowano",
|
||||||
"session.header.search.placeholder": "Szukaj {{project}}",
|
"session.header.search.placeholder": "Szukaj {{project}}",
|
||||||
"session.header.searchFiles": "Szukaj plików",
|
"session.header.searchFiles": "Szukaj plików",
|
||||||
|
|||||||
@@ -670,6 +670,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "Локально",
|
"session.new.workspace.triggerLocal": "Локально",
|
||||||
"session.new.workspace.local": "Локальный репозиторий",
|
"session.new.workspace.local": "Локальный репозиторий",
|
||||||
"session.new.workspace.existing": "Рабочее пространство…",
|
"session.new.workspace.existing": "Рабочее пространство…",
|
||||||
|
"session.new.git.none": "Нет Git",
|
||||||
"session.new.lastModified": "Последнее изменение",
|
"session.new.lastModified": "Последнее изменение",
|
||||||
|
|
||||||
"session.header.search.placeholder": "Поиск {{project}}",
|
"session.header.search.placeholder": "Поиск {{project}}",
|
||||||
|
|||||||
@@ -666,6 +666,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "ในเครื่อง",
|
"session.new.workspace.triggerLocal": "ในเครื่อง",
|
||||||
"session.new.workspace.local": "รีโพซิทอรีในเครื่อง",
|
"session.new.workspace.local": "รีโพซิทอรีในเครื่อง",
|
||||||
"session.new.workspace.existing": "พื้นที่ทำงาน…",
|
"session.new.workspace.existing": "พื้นที่ทำงาน…",
|
||||||
|
"session.new.git.none": "ไม่มี Git",
|
||||||
"session.new.lastModified": "แก้ไขล่าสุด",
|
"session.new.lastModified": "แก้ไขล่าสุด",
|
||||||
|
|
||||||
"session.header.search.placeholder": "ค้นหา {{project}}",
|
"session.header.search.placeholder": "ค้นหา {{project}}",
|
||||||
|
|||||||
@@ -676,6 +676,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "Yerel",
|
"session.new.workspace.triggerLocal": "Yerel",
|
||||||
"session.new.workspace.local": "Yerel depo",
|
"session.new.workspace.local": "Yerel depo",
|
||||||
"session.new.workspace.existing": "Çalışma alanı…",
|
"session.new.workspace.existing": "Çalışma alanı…",
|
||||||
|
"session.new.git.none": "Git yok",
|
||||||
"session.new.lastModified": "Son değişiklik",
|
"session.new.lastModified": "Son değişiklik",
|
||||||
|
|
||||||
"session.header.search.placeholder": "{{project}} ara",
|
"session.header.search.placeholder": "{{project}} ara",
|
||||||
|
|||||||
@@ -699,6 +699,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "Локально",
|
"session.new.workspace.triggerLocal": "Локально",
|
||||||
"session.new.workspace.local": "Локальний репозиторій",
|
"session.new.workspace.local": "Локальний репозиторій",
|
||||||
"session.new.workspace.existing": "Робоча область…",
|
"session.new.workspace.existing": "Робоча область…",
|
||||||
|
"session.new.git.none": "Немає Git",
|
||||||
"session.new.lastModified": "Востаннє змінено",
|
"session.new.lastModified": "Востаннє змінено",
|
||||||
|
|
||||||
"session.header.search.placeholder": "Пошук {{project}}",
|
"session.header.search.placeholder": "Пошук {{project}}",
|
||||||
|
|||||||
@@ -663,6 +663,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "本地",
|
"session.new.workspace.triggerLocal": "本地",
|
||||||
"session.new.workspace.local": "本地仓库",
|
"session.new.workspace.local": "本地仓库",
|
||||||
"session.new.workspace.existing": "工作区…",
|
"session.new.workspace.existing": "工作区…",
|
||||||
|
"session.new.git.none": "无 Git",
|
||||||
"session.new.lastModified": "最后修改",
|
"session.new.lastModified": "最后修改",
|
||||||
"session.header.search.placeholder": "搜索 {{project}}",
|
"session.header.search.placeholder": "搜索 {{project}}",
|
||||||
"session.header.searchFiles": "搜索文件",
|
"session.header.searchFiles": "搜索文件",
|
||||||
|
|||||||
@@ -660,6 +660,7 @@ export const dict = {
|
|||||||
"session.new.workspace.triggerLocal": "本機",
|
"session.new.workspace.triggerLocal": "本機",
|
||||||
"session.new.workspace.local": "本機儲存庫",
|
"session.new.workspace.local": "本機儲存庫",
|
||||||
"session.new.workspace.existing": "工作區…",
|
"session.new.workspace.existing": "工作區…",
|
||||||
|
"session.new.git.none": "無 Git",
|
||||||
"session.new.lastModified": "最後修改",
|
"session.new.lastModified": "最後修改",
|
||||||
|
|
||||||
"session.header.search.placeholder": "搜尋 {{project}}",
|
"session.header.search.placeholder": "搜尋 {{project}}",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { DebugBar } from "@/components/debug-bar"
|
import { DebugBar } from "@/components/debug-bar"
|
||||||
import { TabsInfoPopup } from "@/components/help-button"
|
import { TabsInfoPopup } from "@/components/help-button"
|
||||||
@@ -11,6 +12,7 @@ export default function NewLayout(props: ParentProps) {
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
setNavigate(navigate)
|
setNavigate(navigate)
|
||||||
|
const [state, setState] = createStore({ debugTools: true })
|
||||||
|
|
||||||
createEffect(() => setV2Toast(true))
|
createEffect(() => setV2Toast(true))
|
||||||
|
|
||||||
@@ -32,11 +34,18 @@ export default function NewLayout(props: ParentProps) {
|
|||||||
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Titlebar update={update} />
|
<Titlebar
|
||||||
|
update={update}
|
||||||
|
debugTools={
|
||||||
|
import.meta.env.DEV
|
||||||
|
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||||
<Suspense>{props.children}</Suspense>
|
<Suspense>{props.children}</Suspense>
|
||||||
</main>
|
</main>
|
||||||
{import.meta.env.DEV && <DebugBar inline />}
|
{import.meta.env.DEV && state.debugTools && <DebugBar inline />}
|
||||||
<TabsInfoPopup />
|
<TabsInfoPopup />
|
||||||
<ToastRegion v2 />
|
<ToastRegion v2 />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
sizing: false,
|
sizing: false,
|
||||||
peek: undefined as string | undefined,
|
peek: undefined as string | undefined,
|
||||||
peeked: false,
|
peeked: false,
|
||||||
|
debugTools: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateVersion = () => {
|
const updateVersion = () => {
|
||||||
@@ -2248,7 +2249,14 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
return (
|
return (
|
||||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||||
{autoselecting() ?? ""}
|
{autoselecting() ?? ""}
|
||||||
<Titlebar update={titlebarUpdate} />
|
<Titlebar
|
||||||
|
update={titlebarUpdate}
|
||||||
|
debugTools={
|
||||||
|
import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1"
|
||||||
|
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Show when={updateVersion() !== undefined}>
|
<Show when={updateVersion() !== undefined}>
|
||||||
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
||||||
</Show>
|
</Show>
|
||||||
@@ -2393,7 +2401,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && state.debugTools && <DebugBar />}
|
||||||
</div>
|
</div>
|
||||||
<TabsInfoPopup />
|
<TabsInfoPopup />
|
||||||
<ToastRegion v2={false} />
|
<ToastRegion v2={false} />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { NewSessionDesignView } from "@/components/session"
|
import { NewSessionDesignView } from "@/components/session"
|
||||||
import { PromptInput } from "@/components/prompt-input"
|
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
||||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||||
import {
|
import {
|
||||||
PromptProjectAddButton,
|
PromptProjectAddButton,
|
||||||
@@ -25,11 +25,11 @@ import { createPromptInputController, createPromptProjectControls } from "@/page
|
|||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||||
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSettingsCommand, useSettingsDialog } from "@/components/settings-dialog"
|
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import createPresence from "solid-presence"
|
import createPresence from "solid-presence"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
@@ -55,7 +55,11 @@ export default function NewSessionPage() {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const openProviderSettings = useSettingsDialog("providers")
|
const openProviders = () => {
|
||||||
|
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
|
||||||
|
void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
|
||||||
|
})
|
||||||
|
}
|
||||||
useSettingsCommand()
|
useSettingsCommand()
|
||||||
const route = useSessionKey()
|
const route = useSessionKey()
|
||||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||||
@@ -64,8 +68,6 @@ export default function NewSessionPage() {
|
|||||||
|
|
||||||
useComposerCommands({ model })
|
useComposerCommands({ model })
|
||||||
|
|
||||||
let inputRef: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
const inputController = createPromptInputController({
|
const inputController = createPromptInputController({
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
sessionID: () => route.params.id,
|
sessionID: () => route.params.id,
|
||||||
@@ -73,29 +75,6 @@ export default function NewSessionPage() {
|
|||||||
model,
|
model,
|
||||||
})
|
})
|
||||||
const projectControls = createPromptProjectControls()
|
const projectControls = createPromptProjectControls()
|
||||||
const projectController = createPromptProjectController({
|
|
||||||
controls: projectControls,
|
|
||||||
onDone: () => inputRef?.focus(),
|
|
||||||
})
|
|
||||||
|
|
||||||
command.register("new-session", () => [
|
|
||||||
{
|
|
||||||
id: "command.palette",
|
|
||||||
title: language.t("command.palette"),
|
|
||||||
hidden: true,
|
|
||||||
onSelect: async () => {
|
|
||||||
const { DialogSelectFile } = await import("@/components/dialog-select-file")
|
|
||||||
void dialog.show(() => <DialogSelectFile />)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "input.focus",
|
|
||||||
title: language.t("command.input.focus"),
|
|
||||||
category: language.t("command.category.view"),
|
|
||||||
keybind: "ctrl+l",
|
|
||||||
onSelect: () => inputRef?.focus(),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const [store, setStore] = createStore<{ worktree?: string }>({})
|
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||||
const rightMount = useTitlebarRightMount()
|
const rightMount = useTitlebarRightMount()
|
||||||
@@ -115,6 +94,39 @@ export default function NewSessionPage() {
|
|||||||
if (worktree === "main" || worktree === "create") return localBranch()
|
if (worktree === "main" || worktree === "create") return localBranch()
|
||||||
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
|
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
|
||||||
})
|
})
|
||||||
|
const promptInputV2Controller = usePromptInputV2Controller({
|
||||||
|
get controls() {
|
||||||
|
return inputController()
|
||||||
|
},
|
||||||
|
get newSessionWorktree() {
|
||||||
|
return newSessionWorktree()
|
||||||
|
},
|
||||||
|
onNewSessionWorktreeReset: () => setStore("worktree", undefined),
|
||||||
|
onSubmit: () => comments.clear(),
|
||||||
|
})
|
||||||
|
const projectController = createPromptProjectController({
|
||||||
|
controls: projectControls,
|
||||||
|
onDone: promptInputV2Controller.restoreFocus,
|
||||||
|
})
|
||||||
|
|
||||||
|
command.register("new-session", () => [
|
||||||
|
{
|
||||||
|
id: "command.palette",
|
||||||
|
title: language.t("command.palette"),
|
||||||
|
hidden: true,
|
||||||
|
onSelect: async () => {
|
||||||
|
const { DialogSelectFile } = await import("@/components/dialog-select-file")
|
||||||
|
void dialog.show(() => <DialogSelectFile />)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "input.focus",
|
||||||
|
title: language.t("command.input.focus"),
|
||||||
|
category: language.t("command.category.view"),
|
||||||
|
keybind: "ctrl+l",
|
||||||
|
onSelect: () => promptInputV2Controller.restoreFocus(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!prompt.ready()) return
|
if (!prompt.ready()) return
|
||||||
@@ -128,16 +140,18 @@ export default function NewSessionPage() {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!prompt.ready()) return
|
if (!prompt.ready()) return
|
||||||
requestAnimationFrame(() => inputRef?.focus())
|
promptInputV2Controller.restoreFocus()
|
||||||
})
|
})
|
||||||
|
|
||||||
const ready = Promise.resolve()
|
const ready = Promise.resolve()
|
||||||
const [promptReady] = createResource(
|
const [suspendUntilPromptReady] = createResource(
|
||||||
() => prompt.ready.promise ?? ready,
|
() => prompt.ready.promise ?? ready,
|
||||||
(promise) => promise.then(() => true),
|
(promise) => promise.then(() => true),
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-full overflow-hidden flex flex-col">
|
<div class="relative size-full overflow-hidden flex flex-col">
|
||||||
|
{suspendUntilPromptReady()}
|
||||||
<Show when={rightMount()}>
|
<Show when={rightMount()}>
|
||||||
{(mount) => (
|
{(mount) => (
|
||||||
<Portal mount={mount()}>
|
<Portal mount={mount()}>
|
||||||
@@ -154,69 +168,44 @@ export default function NewSessionPage() {
|
|||||||
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
||||||
<NewSessionDesignView>
|
<NewSessionDesignView>
|
||||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||||
<Show
|
<div class="flex flex-col gap-8">
|
||||||
when={prompt.ready() || promptReady()}
|
<PromptInputV2Composer controller={promptInputV2Controller} />
|
||||||
fallback={
|
<Show when={projectController.empty()}>
|
||||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak pointer-events-none">
|
<PromptProjectAddButton controller={projectController} />
|
||||||
{language.t("prompt.loading")}
|
</Show>
|
||||||
</div>
|
<Show when={projectController.selected()}>
|
||||||
}
|
<div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
|
||||||
>
|
<PromptProjectSelector controller={projectController} placement="bottom" />
|
||||||
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar(), "gap-3": !showWorkspaceBar() }}>
|
<Show
|
||||||
<PromptInput
|
when={showWorkspaceBar()}
|
||||||
controls={inputController()}
|
fallback={<PromptGitStatus branch={selectedBranch()} noGit={sync().project?.vcs !== "git"} />}
|
||||||
variant="new-session"
|
|
||||||
ref={(el) => {
|
|
||||||
inputRef = el
|
|
||||||
}}
|
|
||||||
newSessionWorktree={newSessionWorktree()}
|
|
||||||
onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
|
|
||||||
onSubmit={() => comments.clear()}
|
|
||||||
toolbar={
|
|
||||||
<Show when={!projectController.selected()}>
|
|
||||||
<PromptProjectAddButton controller={projectController} />
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Show when={projectController.selected()}>
|
|
||||||
<div
|
|
||||||
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
|
|
||||||
classList={{
|
|
||||||
"flex-col justify-center sm:flex-row": showWorkspaceBar(),
|
|
||||||
"justify-start": !showWorkspaceBar(),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<PromptProjectSelector
|
<PromptWorkspaceSelector
|
||||||
controller={projectController}
|
value={newSessionWorktree()}
|
||||||
placement={showWorkspaceBar() ? "bottom" : "bottom-start"}
|
projectRoot={projectRoot()}
|
||||||
|
workspaces={sync().project?.sandboxes ?? []}
|
||||||
|
branch={selectedBranch()}
|
||||||
|
onChange={(value) =>
|
||||||
|
setStore(
|
||||||
|
"worktree",
|
||||||
|
value === "main" && sync().project?.worktree !== sdk().directory
|
||||||
|
? sync().project?.worktree
|
||||||
|
: value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDone={promptInputV2Controller.restoreFocus}
|
||||||
/>
|
/>
|
||||||
<Show when={showWorkspaceBar()}>
|
</Show>
|
||||||
<PromptWorkspaceSelector
|
</div>
|
||||||
value={newSessionWorktree()}
|
</Show>
|
||||||
projectRoot={projectRoot()}
|
</div>
|
||||||
workspaces={sync().project?.sandboxes ?? []}
|
{/*</Show>*/}
|
||||||
branch={selectedBranch()}
|
|
||||||
onChange={(value) =>
|
|
||||||
setStore(
|
|
||||||
"worktree",
|
|
||||||
value === "main" && sync().project?.worktree !== sdk().directory
|
|
||||||
? sync().project?.worktree
|
|
||||||
: value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onDone={() => inputRef?.focus()}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</NewSessionDesignView>
|
</NewSessionDesignView>
|
||||||
<ProviderTip
|
<ProviderTip
|
||||||
ready={() => serverSync().child(sdk().directory)[0].provider_ready}
|
ready={() => serverSync().child(sdk().directory)[0].provider_ready}
|
||||||
connected={() => providers.paid().length > 0}
|
connected={() => providers.paid().length > 0}
|
||||||
openProviders={openProviderSettings}
|
openProviders={openProviders}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { TerminalProvider, useTerminal } from "@/context/terminal"
|
import { TerminalProvider, useTerminal } from "@/context/terminal"
|
||||||
import { PromptInput } from "@/components/prompt-input"
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
|
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
||||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||||
import { setCursorPosition } from "@/components/prompt-input/editor-dom"
|
import { setCursorPosition } from "@/components/prompt-input/editor-dom"
|
||||||
import { promptLength } from "@/components/prompt-input/history"
|
import { promptLength } from "@/components/prompt-input/history"
|
||||||
@@ -665,7 +666,8 @@ export default function Page() {
|
|||||||
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
||||||
const wantsReview = createMemo(() =>
|
const wantsReview = createMemo(() =>
|
||||||
isDesktop()
|
isDesktop()
|
||||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
? desktopFileTreeOpen() ||
|
||||||
|
(desktopReviewOpen() && (activeTab() === "review" || (newSessionDesign() && !!activeFileTab())))
|
||||||
: store.mobileTab === "changes",
|
: store.mobileTab === "changes",
|
||||||
)
|
)
|
||||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||||
@@ -2006,78 +2008,6 @@ export default function Page() {
|
|||||||
|
|
||||||
useUsageExceededDialogs()
|
useUsageExceededDialogs()
|
||||||
|
|
||||||
const composerRegion = () => {
|
|
||||||
const controller = createSessionComposerRegionController({
|
|
||||||
state: composer,
|
|
||||||
sessionKey,
|
|
||||||
sessionID: () => params.id,
|
|
||||||
prompt,
|
|
||||||
centered,
|
|
||||||
followup: () =>
|
|
||||||
params.id && !isChildSession()
|
|
||||||
? {
|
|
||||||
items: followupDock(),
|
|
||||||
sending: sendingFollowup(),
|
|
||||||
onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
|
|
||||||
onEdit: editFollowup,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
revert: () =>
|
|
||||||
rolled().length > 0
|
|
||||||
? {
|
|
||||||
items: rolled(),
|
|
||||||
restoring: restoring(),
|
|
||||||
disabled: reverting(),
|
|
||||||
onRestore: restore,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onResponseSubmit: resumeScroll,
|
|
||||||
openParent: () => {
|
|
||||||
const id = info()?.parentID
|
|
||||||
if (!id) return
|
|
||||||
navigate(
|
|
||||||
params.serverKey
|
|
||||||
? sessionHref(requireServerKey(params.serverKey), id)
|
|
||||||
: legacySessionHref(sdk().directory, id),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
setPromptRef: (el) => {
|
|
||||||
inputRef = el
|
|
||||||
},
|
|
||||||
setDockRef: (el) => {
|
|
||||||
promptDock = el
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return (
|
|
||||||
<SessionComposerRegion
|
|
||||||
controller={controller}
|
|
||||||
promptInput={
|
|
||||||
<PromptInput
|
|
||||||
controls={inputController()}
|
|
||||||
ref={(el) => {
|
|
||||||
inputRef = el
|
|
||||||
}}
|
|
||||||
newSessionWorktree={newSessionWorktree()}
|
|
||||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
|
||||||
onSubmit={() => {
|
|
||||||
comments.clear()
|
|
||||||
resumeScroll()
|
|
||||||
}}
|
|
||||||
edit={editingFollowup()}
|
|
||||||
onEditLoaded={clearFollowupEdit}
|
|
||||||
shouldQueue={queueEnabled}
|
|
||||||
onQueue={queueFollowup}
|
|
||||||
onAbort={() => {
|
|
||||||
const id = params.id
|
|
||||||
if (!id) return
|
|
||||||
setFollowup("paused", id, true)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const mobileTabs = (compact = false, bottom = false) => (
|
const mobileTabs = (compact = false, bottom = false) => (
|
||||||
<Tabs value={store.mobileTab} class="h-auto">
|
<Tabs value={store.mobileTab} class="h-auto">
|
||||||
<Tabs.List
|
<Tabs.List
|
||||||
@@ -2192,7 +2122,118 @@ export default function Page() {
|
|||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
|
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>
|
||||||
|
{(_) => {
|
||||||
|
const controller = createSessionComposerRegionController({
|
||||||
|
state: composer,
|
||||||
|
sessionKey,
|
||||||
|
sessionID: () => params.id,
|
||||||
|
prompt,
|
||||||
|
centered,
|
||||||
|
followup: () =>
|
||||||
|
params.id && !isChildSession()
|
||||||
|
? {
|
||||||
|
items: followupDock(),
|
||||||
|
sending: sendingFollowup(),
|
||||||
|
onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
|
||||||
|
onEdit: editFollowup,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
revert: () =>
|
||||||
|
rolled().length > 0
|
||||||
|
? {
|
||||||
|
items: rolled(),
|
||||||
|
restoring: restoring(),
|
||||||
|
disabled: reverting(),
|
||||||
|
onRestore: restore,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onResponseSubmit: resumeScroll,
|
||||||
|
openParent: () => {
|
||||||
|
const id = info()?.parentID
|
||||||
|
if (!id) return
|
||||||
|
navigate(
|
||||||
|
params.serverKey
|
||||||
|
? sessionHref(requireServerKey(params.serverKey), id)
|
||||||
|
: legacySessionHref(sdk().directory, id),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
setPromptRef: (el) => {
|
||||||
|
inputRef = el
|
||||||
|
},
|
||||||
|
setDockRef: (el) => {
|
||||||
|
promptDock = el
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<SessionComposerRegion
|
||||||
|
controller={controller}
|
||||||
|
promptInput={
|
||||||
|
<Show
|
||||||
|
when={newSessionDesign()}
|
||||||
|
fallback={
|
||||||
|
<PromptInput
|
||||||
|
controls={inputController()}
|
||||||
|
ref={(el) => {
|
||||||
|
inputRef = el
|
||||||
|
}}
|
||||||
|
newSessionWorktree={newSessionWorktree()}
|
||||||
|
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||||
|
onSubmit={() => {
|
||||||
|
comments.clear()
|
||||||
|
resumeScroll()
|
||||||
|
}}
|
||||||
|
edit={editingFollowup()}
|
||||||
|
onEditLoaded={clearFollowupEdit}
|
||||||
|
shouldQueue={queueEnabled}
|
||||||
|
onQueue={queueFollowup}
|
||||||
|
onAbort={() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
setFollowup("paused", id, true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(_) => {
|
||||||
|
const controller = usePromptInputV2Controller({
|
||||||
|
get controls() {
|
||||||
|
return inputController()
|
||||||
|
},
|
||||||
|
ref: (el) => {
|
||||||
|
inputRef = el
|
||||||
|
},
|
||||||
|
get newSessionWorktree() {
|
||||||
|
return newSessionWorktree()
|
||||||
|
},
|
||||||
|
onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"),
|
||||||
|
onSubmit: () => {
|
||||||
|
comments.clear()
|
||||||
|
resumeScroll()
|
||||||
|
},
|
||||||
|
shouldQueue: queueEnabled,
|
||||||
|
onQueue: queueFollowup,
|
||||||
|
onAbort: () => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
setFollowup("paused", id, true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<PromptInputV2Composer
|
||||||
|
controller={controller}
|
||||||
|
borderUnderlay
|
||||||
|
edit={editingFollowup()}
|
||||||
|
onEditLoaded={clearFollowupEdit}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -2282,6 +2323,9 @@ export default function Page() {
|
|||||||
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
|
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
|
||||||
reviewCount={reviewCount}
|
reviewCount={reviewCount}
|
||||||
reviewPanel={reviewPanelV2}
|
reviewPanel={reviewPanelV2}
|
||||||
|
diffVersion={vcsQuery.dataUpdatedAt}
|
||||||
|
loadDiff={loadReviewDiff}
|
||||||
|
expandUnchanged={reviewV2State.expandMode() === "expand"}
|
||||||
reviewSidebarToggle={(disabled) => (
|
reviewSidebarToggle={(disabled) => (
|
||||||
<SessionReviewV2SidebarToggle
|
<SessionReviewV2SidebarToggle
|
||||||
opened={reviewV2State.sidebarOpened()}
|
opened={reviewV2State.sidebarOpened()}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
|||||||
import { createQuery } from "@tanstack/solid-query"
|
import { createQuery } from "@tanstack/solid-query"
|
||||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||||
import { type Accessor, createMemo } from "solid-js"
|
import { type Accessor, createMemo } from "solid-js"
|
||||||
import type { PromptInputControls } from "@/components/prompt-input"
|
import type { PromptInputControls } from "@/components/prompt-input/contracts"
|
||||||
import type { PromptProjectControls } from "@/components/prompt-project-selector"
|
import type { PromptProjectControls } from "@/components/prompt-project-selector"
|
||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
@@ -12,7 +12,6 @@ import type { QueryOptionsApi } from "@/context/server-sync"
|
|||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { serverName, ServerConnection, useServer } from "@/context/server"
|
import { serverName, ServerConnection, useServer } from "@/context/server"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
@@ -27,7 +26,6 @@ export function createPromptInputController(input: {
|
|||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const settings = useSettings()
|
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const view = layout.view(input.sessionKey)
|
const view = layout.view(input.sessionKey)
|
||||||
@@ -54,7 +52,6 @@ export function createPromptInputController(input: {
|
|||||||
tabs: layout.tabs(input.sessionKey),
|
tabs: layout.tabs(input.sessionKey),
|
||||||
reviewPanel: view.reviewPanel,
|
reviewPanel: view.reviewPanel,
|
||||||
},
|
},
|
||||||
newLayoutDesigns: settings.general.newLayoutDesigns(),
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { For } from "solid-js"
|
import { For } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { DockShell } from "@opencode-ai/ui/dock-surface"
|
||||||
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||||
|
|
||||||
@@ -78,12 +79,17 @@ function Stage(props: { count: number }) {
|
|||||||
{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
|
{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
|
||||||
<div style={{ display: "flex", "flex-direction": "column" }}>
|
<div style={{ display: "flex", "flex-direction": "column" }}>
|
||||||
<SessionRevertDock items={store.items} onRestore={restore} />
|
<SessionRevertDock items={store.items} onRestore={restore} />
|
||||||
<div
|
<DockShell
|
||||||
|
data-dock-border-underlay={v2() ? "v2" : "legacy"}
|
||||||
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
|
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
|
||||||
class="min-h-24 w-full rounded-[12px] border border-v2-border-border-base bg-v2-background-bg-base px-4 py-3 text-[13px] text-v2-text-text-faint"
|
classList={{
|
||||||
|
"min-h-24 w-full rounded-[12px] px-4 py-3 text-[13px]": true,
|
||||||
|
"bg-v2-background-bg-base text-v2-text-text-faint": v2(),
|
||||||
|
"text-text-weak": !v2(),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Ask anything...
|
Ask anything...
|
||||||
</div>
|
</DockShell>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-[12px] text-v2-text-text-faint">
|
<div class="text-[12px] text-v2-text-text-faint">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
|
import { createEffect, createMemo, createResource, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
@@ -8,6 +8,7 @@ import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/sessi
|
|||||||
import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations"
|
import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations"
|
||||||
import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2"
|
import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2"
|
||||||
import { sampledChecksum } from "@opencode-ai/core/util/encode"
|
import { sampledChecksum } from "@opencode-ai/core/util/encode"
|
||||||
|
import { normalize, text } from "@opencode-ai/session-ui/session-diff"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
|
import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
|
||||||
@@ -18,11 +19,23 @@ import { showToast } from "@/utils/toast"
|
|||||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { getSessionHandoff } from "@/pages/session/handoff"
|
import { getSessionHandoff } from "@/pages/session/handoff"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
|
import { reviewDiffNeedsLoad, type RenderDiff } from "@/pages/session/v2/review-diff-kinds"
|
||||||
|
|
||||||
|
type SessionFileViewProps = {
|
||||||
|
tab: string
|
||||||
|
diff?: RenderDiff
|
||||||
|
diffVersion?: number
|
||||||
|
loadDiff?: (path: string, version?: number) => Promise<RenderDiff | undefined>
|
||||||
|
expandUnchanged?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
|
||||||
|
|
||||||
function FileCommentMenu(props: {
|
function FileCommentMenu(props: {
|
||||||
moreLabel: string
|
moreLabel: string
|
||||||
@@ -207,11 +220,27 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionFileView(props: { tab: string }) {
|
export function SessionFileView(props: SessionFileViewProps) {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
const detailSource = createMemo(() => {
|
||||||
|
if (!props.diff || !props.loadDiff || !reviewDiffNeedsLoad(props.diff)) return
|
||||||
|
return { diff: props.diff, load: props.loadDiff, version: props.diffVersion }
|
||||||
|
})
|
||||||
|
const [loadedDiff] = createResource(detailSource, async ({ diff, load, version }) => {
|
||||||
|
const value = await load(diff.file, version)
|
||||||
|
if (value?.file !== diff.file) return
|
||||||
|
return { source: diff, version, value }
|
||||||
|
})
|
||||||
|
const diff = createMemo(() => {
|
||||||
|
const source = props.diff
|
||||||
|
if (!source) return
|
||||||
|
const loaded = loadedDiff()
|
||||||
|
return normalize(loaded?.source === source && loaded.version === props.diffVersion ? loaded.value : source)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={settings.general.newLayoutDesigns()} fallback={<SessionFileViewV1 tab={props.tab} />}>
|
<Show when={settings.general.newLayoutDesigns()} fallback={<SessionFileViewV1 tab={props.tab} />}>
|
||||||
<SessionFileViewV2 tab={props.tab} />
|
<SessionFileViewV2 tab={props.tab} diff={diff()} expandUnchanged={props.expandUnchanged} />
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -501,11 +530,12 @@ function SessionFileViewV1(props: { tab: string }) {
|
|||||||
return content()
|
return content()
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionFileViewV2(props: { tab: string }) {
|
function SessionFileViewV2(props: { tab: string; diff?: ReturnType<typeof normalize>; expandUnchanged?: boolean }) {
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
|
const layout = useLayout()
|
||||||
const fileComponent = useFileComponent()
|
const fileComponent = useFileComponent()
|
||||||
const { sessionKey, tabs, view } = useSessionLayout()
|
const { sessionKey, tabs, view } = useSessionLayout()
|
||||||
const activeFileTab = createSessionTabs({
|
const activeFileTab = createSessionTabs({
|
||||||
@@ -548,10 +578,15 @@ function SessionFileViewV2(props: { tab: string }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildPreview = (filePath: string, selection: FileSelection) => {
|
const buildPreview = (filePath: string, lines: SelectedLineRange) => {
|
||||||
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
|
const source =
|
||||||
|
filePath === path()
|
||||||
|
? props.diff
|
||||||
|
? text(props.diff, selectionSide(lines))
|
||||||
|
: contents()
|
||||||
|
: file.get(filePath)?.content?.content
|
||||||
if (!source) return undefined
|
if (!source) return undefined
|
||||||
return selectionPreview(source, selection)
|
return selectionPreview(source, selectionFromLines(lines))
|
||||||
}
|
}
|
||||||
|
|
||||||
const addCommentToContext = (input: {
|
const addCommentToContext = (input: {
|
||||||
@@ -562,7 +597,7 @@ function SessionFileViewV2(props: { tab: string }) {
|
|||||||
origin?: "review" | "file"
|
origin?: "review" | "file"
|
||||||
}) => {
|
}) => {
|
||||||
const selection = selectionFromLines(input.selection)
|
const selection = selectionFromLines(input.selection)
|
||||||
const preview = input.preview ?? buildPreview(input.file, selection)
|
const preview = input.preview ?? buildPreview(input.file, input.selection)
|
||||||
|
|
||||||
const saved = comments.add({
|
const saved = comments.add({
|
||||||
file: input.file,
|
file: input.file,
|
||||||
@@ -587,7 +622,7 @@ function SessionFileViewV2(props: { tab: string }) {
|
|||||||
comment: string
|
comment: string
|
||||||
}) => {
|
}) => {
|
||||||
comments.update(input.file, input.id, input.comment)
|
comments.update(input.file, input.id, input.comment)
|
||||||
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
|
const preview = input.file === path() ? buildPreview(input.file, input.selection) : undefined
|
||||||
prompt.context.updateComment(input.file, input.id, {
|
prompt.context.updateComment(input.file, input.id, {
|
||||||
comment: input.comment,
|
comment: input.comment,
|
||||||
...(preview ? { preview } : {}),
|
...(preview ? { preview } : {}),
|
||||||
@@ -628,7 +663,7 @@ function SessionFileViewV2(props: { tab: string }) {
|
|||||||
mention: {
|
mention: {
|
||||||
items: file.searchFilesAndDirectories,
|
items: file.searchFilesAndDirectories,
|
||||||
},
|
},
|
||||||
getSide: (range) => range.endSide ?? range.side ?? "additions",
|
getSide: selectionSide,
|
||||||
state: {
|
state: {
|
||||||
opened: () => note.openedComment,
|
opened: () => note.openedComment,
|
||||||
setOpened: (id) => setNote("openedComment", id),
|
setOpened: (id) => setNote("openedComment", id),
|
||||||
@@ -726,12 +761,21 @@ function SessionFileViewV2(props: { tab: string }) {
|
|||||||
<div class="relative overflow-hidden pb-40">
|
<div class="relative overflow-hidden pb-40">
|
||||||
<Dynamic
|
<Dynamic
|
||||||
component={fileComponent}
|
component={fileComponent}
|
||||||
mode="text"
|
{...(props.diff
|
||||||
file={{
|
? {
|
||||||
name: path() ?? "",
|
mode: "diff" as const,
|
||||||
contents: source,
|
fileDiff: props.diff.fileDiff,
|
||||||
cacheKey: cacheKey(),
|
diffStyle: layout.review.diffStyle(),
|
||||||
}}
|
expandUnchanged: props.expandUnchanged,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
mode: "text" as const,
|
||||||
|
file: {
|
||||||
|
name: path() ?? "",
|
||||||
|
contents: source,
|
||||||
|
cacheKey: cacheKey(),
|
||||||
|
},
|
||||||
|
})}
|
||||||
enableLineSelection
|
enableLineSelection
|
||||||
enableGutterUtility
|
enableGutterUtility
|
||||||
selectedLines={activeSelection()}
|
selectedLines={activeSelection()}
|
||||||
@@ -761,6 +805,7 @@ function SessionFileViewV2(props: { tab: string }) {
|
|||||||
media={{
|
media={{
|
||||||
mode: "auto",
|
mode: "auto",
|
||||||
path: path(),
|
path: path(),
|
||||||
|
deleted: props.diff?.status === "deleted",
|
||||||
current: state()?.content,
|
current: state()?.content,
|
||||||
onLoad: scrollSync.queueRestore,
|
onLoad: scrollSync.queueRestore,
|
||||||
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||||
@@ -779,6 +824,7 @@ function SessionFileViewV2(props: { tab: string }) {
|
|||||||
<div class="mt-3 relative h-full min-h-0">
|
<div class="mt-3 relative h-full min-h-0">
|
||||||
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
|
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Match when={props.diff}>{renderFile(contents())}</Match>
|
||||||
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
||||||
<Match when={state()?.loading}>
|
<Match when={state()?.loading}>
|
||||||
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
|
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user