mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 16:56:33 -04:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db72532825 | |||
| b3a85f4bcc | |||
| 044d320d38 | |||
| 89130db6b0 | |||
| 9465a6c97d | |||
| 53fd02970d | |||
| 6ff0adef22 | |||
| 3307f99f1f | |||
| 8de7a3c739 | |||
| 28d0637406 | |||
| ef7f712867 | |||
| 3016830e25 | |||
| e9e7472456 | |||
| 9f9fbf815c | |||
| c88facb2cc | |||
| d4b85f8d83 | |||
| 1882c33827 | |||
| 01624c8c87 | |||
| 0891ecd8e0 | |||
| 32f278b48f | |||
| bb6bec9070 | |||
| 0eac0964af | |||
| f67e80c275 |
@@ -339,8 +339,7 @@ jobs:
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
CSC_KEYCHAIN: build.keychain
|
||||
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -96,7 +96,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -144,7 +144,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -194,7 +194,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -221,7 +221,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -243,7 +243,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -267,7 +267,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -287,7 +287,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -381,9 +381,10 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron-context-menu": "4.1.2",
|
||||
"electron-log": "^5",
|
||||
@@ -434,7 +435,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -448,7 +449,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -460,7 +461,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -492,7 +493,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -508,7 +509,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -539,7 +540,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -558,7 +559,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -689,7 +690,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -765,7 +766,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -780,7 +781,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -795,7 +796,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||
@@ -839,7 +840,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -852,7 +853,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -886,7 +887,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -905,7 +906,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -947,7 +948,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -974,7 +975,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1026,7 +1027,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-SiJd6uXrL/MqqFGN/uUcHh0Wzdlafnpx++VZa5gUCoE=",
|
||||
"aarch64-linux": "sha256-pKcT34NYIHVasraRTx0ASTzyuFuIzBTxXg6+KSKvTps=",
|
||||
"aarch64-darwin": "sha256-hzrym0KpiyYAv80eT/DjcSIJWYBUq4QDgDf6Wtot7jU=",
|
||||
"x86_64-darwin": "sha256-Or5dSTdajUwij0XpovXSXjJ0LmRUcpcW5TJ4q4B0k0A="
|
||||
"x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=",
|
||||
"aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=",
|
||||
"aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=",
|
||||
"x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
|
||||
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
|
||||
|
||||
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
|
||||
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
|
||||
const OUTSIDE_CAP = "foxtrot-docs"
|
||||
|
||||
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
|
||||
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
|
||||
const rows = (page: Page) => page.locator("[data-directory-path]")
|
||||
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
|
||||
|
||||
async function openProjectDialog(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
fileList: () => [],
|
||||
findFiles: () => [],
|
||||
})
|
||||
await page.addInitScript((dirs) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
|
||||
lastProject: {},
|
||||
}),
|
||||
)
|
||||
}, worktrees)
|
||||
await page.goto("/")
|
||||
const add = page.getByRole("button", { name: "Add project" }).first()
|
||||
await expectAppVisible(add)
|
||||
await add.click()
|
||||
await expect(rows(page)).toHaveCount(5)
|
||||
return page.getByRole("textbox").last()
|
||||
}
|
||||
|
||||
test("searches every recent project, not just the five most recent", async ({ page }) => {
|
||||
const search = await openProjectDialog(page)
|
||||
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
||||
|
||||
await search.fill("foxtrot")
|
||||
|
||||
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("still caps the idle recent list at five projects", async ({ page }) => {
|
||||
await openProjectDialog(page)
|
||||
|
||||
await expect(row(page, NAMES[4])).toHaveCount(1)
|
||||
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -18,6 +18,8 @@ interface DialogSelectDirectoryProps {
|
||||
server: ServerConnection.Any
|
||||
}
|
||||
|
||||
const RECENT_PROJECT_LIMIT = 5
|
||||
|
||||
type Row = {
|
||||
absolute: string
|
||||
search: string
|
||||
@@ -102,7 +104,6 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
return projects
|
||||
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
|
||||
.sort((a, b) => b.at - a.at || a.index - b.index)
|
||||
.slice(0, 5)
|
||||
.map(({ project }) => {
|
||||
const row = toRow(project.worktree, home(), "recent")
|
||||
const name = project.name || getFilename(project.worktree)
|
||||
@@ -116,7 +117,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const items = async (value: string) => {
|
||||
const results = await directories(value)
|
||||
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
|
||||
return uniqueRows([...recentProjects(), ...directoryRows])
|
||||
// Cap the idle list only. Once a query narrows the results, every project stays searchable.
|
||||
const recent = recentProjects()
|
||||
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
|
||||
return uniqueRows([...visible, ...directoryRows])
|
||||
}
|
||||
|
||||
function resolve(absolute: string) {
|
||||
|
||||
@@ -344,7 +344,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
if (item?.commentID) comments.remove(item.path, item.commentID)
|
||||
},
|
||||
openAttachment: (attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
|
||||
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
if (item) openComment(item, props, sync, layout, files, comments)
|
||||
@@ -377,6 +377,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
}),
|
||||
readClipboardImage: platform.readClipboardImage,
|
||||
getPathForFile: platform.getPathForFile,
|
||||
store: platform.draftStore?.putBlob,
|
||||
},
|
||||
view: {
|
||||
placeholder: designPlaceholder,
|
||||
|
||||
@@ -1489,7 +1489,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<PromptImageAttachments
|
||||
attachments={imageAttachments()}
|
||||
onOpen={(attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />)
|
||||
}
|
||||
onRemove={removeAttachment}
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
|
||||
@@ -3,28 +3,13 @@ import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { uuid } from "@/utils/uuid"
|
||||
import { getCursorPosition } from "./editor-dom"
|
||||
import { createBlobReference, type DraftStore } from "@/utils/draft-store"
|
||||
import { attachmentMime } from "./files"
|
||||
import { normalizePaste, pasteMode } from "./paste"
|
||||
|
||||
function dataUrl(file: File, mime: string) {
|
||||
return new Promise<string>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("error", () => resolve(""))
|
||||
reader.addEventListener("load", () => {
|
||||
const value = typeof reader.result === "string" ? reader.result : ""
|
||||
const idx = value.indexOf(",")
|
||||
if (idx === -1) {
|
||||
resolve(value)
|
||||
return
|
||||
}
|
||||
resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
|
||||
})
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
|
||||
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
|
||||
|
||||
@@ -36,6 +21,7 @@ type PromptAttachmentsCoreInput = {
|
||||
warn?: () => void
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
getPathForFile?: (file: File) => string
|
||||
draftStore?: DraftStore
|
||||
}
|
||||
|
||||
export type PromptAttachmentsInput = {
|
||||
@@ -65,16 +51,13 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
const url = await dataUrl(file, mime)
|
||||
if (!url) return false
|
||||
|
||||
const attachment: ImageAttachmentPart = {
|
||||
type: "image",
|
||||
id: uuid(),
|
||||
filename: file.name,
|
||||
sourcePath: input.getPathForFile?.(file) || undefined,
|
||||
mime,
|
||||
dataUrl: url,
|
||||
blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file),
|
||||
}
|
||||
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
||||
return true
|
||||
@@ -166,8 +149,10 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
|
||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const attachments = createPromptAttachmentsCore({
|
||||
...input,
|
||||
draftStore: platform.draftStore,
|
||||
capture: input.prompt.capture,
|
||||
warn: () => {
|
||||
showToast({
|
||||
|
||||
@@ -22,7 +22,7 @@ type ContextFile = {
|
||||
type BuildRequestPartsInput = {
|
||||
prompt: Prompt
|
||||
context: ContextFile[]
|
||||
images: ImageAttachmentPart[]
|
||||
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
|
||||
text: string
|
||||
messageID: string
|
||||
sessionID: string
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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"
|
||||
import {
|
||||
clonePromptHistoryComments,
|
||||
clonePromptParts,
|
||||
prependHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryStoredEntry,
|
||||
} from "./history"
|
||||
|
||||
export type PromptInputHistory = {
|
||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||
@@ -35,13 +41,23 @@ export function createPromptInputHistory(): PromptInputHistory {
|
||||
}
|
||||
|
||||
export function createPersistedPromptInputHistory() {
|
||||
const [normal, setNormal] = persisted(
|
||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||
const [normal, setNormal, normalInit] = persisted(
|
||||
Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])),
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
const [shell, setShell] = persisted(
|
||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||
const [shell, setShell, shellInit] = persisted(
|
||||
Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])),
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
return {
|
||||
...history,
|
||||
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
|
||||
const ready = mode === "shell" ? shellInit : normalInit
|
||||
if (!(ready instanceof Promise)) return history.add(prompt, mode, comments)
|
||||
const saved = clonePromptParts(prompt)
|
||||
const metadata = clonePromptHistoryComments(comments)
|
||||
void ready.then(() => history.add(saved, mode, metadata))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("prompt-input history", () => {
|
||||
end: 12,
|
||||
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
|
||||
},
|
||||
{ type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
|
||||
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
|
||||
]
|
||||
const copy = clonePromptParts(original)
|
||||
expect(copy).not.toBe(original)
|
||||
|
||||
@@ -100,7 +100,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
src={attachment.blob.url}
|
||||
alt={attachment.filename}
|
||||
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
|
||||
onClick={() => props.onOpen(attachment)}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ScopedKey } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { blobDataUrl } from "@/utils/draft-store"
|
||||
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
@@ -95,10 +96,12 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
providerID: input.draft.model.providerID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
name: attachment.filename,
|
||||
})),
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
name: attachment.filename,
|
||||
})),
|
||||
),
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
@@ -108,10 +111,16 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
}
|
||||
|
||||
const messageID = input.messageID ?? Identifier.ascending("message")
|
||||
const encodedImages = await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
...attachment,
|
||||
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
})),
|
||||
)
|
||||
const { requestParts, optimisticParts } = buildRequestParts({
|
||||
prompt: input.draft.prompt,
|
||||
context: input.draft.context,
|
||||
images,
|
||||
images: encodedImages,
|
||||
text,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
@@ -516,10 +525,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
name: attachment.filename,
|
||||
})),
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
name: attachment.filename,
|
||||
})),
|
||||
),
|
||||
})
|
||||
.catch((err) => {
|
||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
bootstrapDirectory,
|
||||
loadAgentsQuery,
|
||||
loadCommands,
|
||||
loadGlobalConfigQuery,
|
||||
loadPathQuery,
|
||||
loadProjectsQuery,
|
||||
loadProvidersQuery,
|
||||
@@ -76,6 +77,7 @@ function directoryState() {
|
||||
|
||||
describe("bootstrapDirectory", () => {
|
||||
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
|
||||
const legacyConfigReads: string[] = []
|
||||
const mcpReads: string[] = []
|
||||
const [store, setStore] = directoryState()
|
||||
|
||||
@@ -91,7 +93,12 @@ describe("bootstrapDirectory", () => {
|
||||
},
|
||||
sdk: {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
config: {
|
||||
get: async () => {
|
||||
legacyConfigReads.push("directory")
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
session: { status: async () => ({ data: {} }) },
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: {
|
||||
@@ -134,8 +141,88 @@ describe("bootstrapDirectory", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
|
||||
expect(store.status).toBe("complete")
|
||||
expect(legacyConfigReads).toEqual(["directory"])
|
||||
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
|
||||
})
|
||||
|
||||
test("skips legacy config while refreshing a v2 directory", async () => {
|
||||
const [store, setStore] = directoryState()
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
scope: ServerScope.local,
|
||||
mcp: false,
|
||||
global: {
|
||||
config: {} satisfies Config,
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
project: [{ id: "project", worktree: "/project" } as Project],
|
||||
provider,
|
||||
},
|
||||
sdk: {
|
||||
config: {
|
||||
get: async () => {
|
||||
throw new Error("legacy directory config should not be called")
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient,
|
||||
api,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
loadSessions() {},
|
||||
translate: (key) => key,
|
||||
queryClient: new QueryClient(),
|
||||
protocol: Promise.resolve("v2"),
|
||||
})
|
||||
|
||||
expect(store.status).toBe("partial")
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
|
||||
expect(store.status).toBe("complete")
|
||||
})
|
||||
})
|
||||
|
||||
describe("config queries", () => {
|
||||
test("skips legacy global config for v2 servers", async () => {
|
||||
const sdk = {
|
||||
global: {
|
||||
config: {
|
||||
get: async () => {
|
||||
throw new Error("legacy global config should not be called")
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
|
||||
const result = await new QueryClient().fetchQuery(
|
||||
loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v2")),
|
||||
)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("loads legacy global config for v1 servers", async () => {
|
||||
const calls: string[] = []
|
||||
const config = { shell: "zsh" } satisfies Config
|
||||
const sdk = {
|
||||
global: {
|
||||
config: {
|
||||
get: async () => {
|
||||
calls.push("global")
|
||||
return { data: config }
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
|
||||
const result = await new QueryClient().fetchQuery(
|
||||
loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v1")),
|
||||
)
|
||||
|
||||
expect(result).toEqual(config)
|
||||
expect(calls).toEqual(["global"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("query keys", () => {
|
||||
|
||||
@@ -105,10 +105,13 @@ function showErrors(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient, protocol?: Promise<ServerProtocol>) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "config"],
|
||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
||||
queryFn: async () => {
|
||||
if ((await protocol) !== "v1") return {}
|
||||
return retry(() => sdk.global.config.get().then((x) => x.data!))
|
||||
},
|
||||
})
|
||||
|
||||
type ProjectApi = {
|
||||
@@ -149,7 +152,7 @@ export async function bootstrapGlobal(input: {
|
||||
queryClient: QueryClient
|
||||
}) {
|
||||
const slow = [
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
||||
@@ -376,7 +379,10 @@ export async function bootstrapDirectory(input: {
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
|
||||
.then((data) => input.setStore("agent", data)),
|
||||
() =>
|
||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||
retry(async () => {
|
||||
if ((await input.protocol) !== "v1") return
|
||||
return input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))
|
||||
}),
|
||||
() =>
|
||||
retry(() =>
|
||||
(async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { DesktopMenuAction } from "../desktop-menu"
|
||||
import { ServerConnection } from "./server"
|
||||
import type { WslServersPlatform } from "../wsl/types"
|
||||
import type { UpdaterPlatform } from "../updater"
|
||||
import type { DraftStore } from "@/utils/draft-store"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
@@ -64,6 +65,9 @@ type PlatformBase = {
|
||||
/** Storage mechanism, defaults to localStorage */
|
||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||
|
||||
/** Prompt drafts, history, and their blobs. */
|
||||
draftStore?: DraftStore
|
||||
|
||||
/** Stable platform window identity for window-scoped persistence */
|
||||
windowID?: string
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import type { BlobReference } from "@/utils/draft-store"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
@@ -37,7 +38,7 @@ export interface ImageAttachmentPart {
|
||||
filename: string
|
||||
sourcePath?: string
|
||||
mime: string
|
||||
dataUrl: string
|
||||
blob: BlobReference
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||
@@ -168,9 +169,9 @@ function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
|
||||
}
|
||||
|
||||
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
|
||||
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
||||
if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt"))
|
||||
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
||||
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
||||
return Persist.prompt(Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]))
|
||||
}
|
||||
|
||||
function promptStore(initial?: InitialPrompt): PromptStore {
|
||||
@@ -245,7 +246,7 @@ export function createPromptSession(serverScope: ServerScope, scope: PromptScope
|
||||
}
|
||||
|
||||
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
|
||||
return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
|
||||
return createPersistedPrompt(Persist.prompt(Persist.draft(draftID, "prompt")), initial)
|
||||
}
|
||||
|
||||
export type PromptSession = ReturnType<typeof createPromptSession>
|
||||
|
||||
@@ -184,7 +184,7 @@ function makeQueryOptionsApi(
|
||||
protocol: Promise<"v1" | "v2">,
|
||||
) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK(), protocol),
|
||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||
providers: (directory: PathKey | null) =>
|
||||
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
|
||||
|
||||
@@ -101,7 +101,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
const removeDraftPersisted = (draftID: string) => {
|
||||
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
||||
for (const key of draftPersistedKeys()) {
|
||||
const target = Persist.draft(draftID, key)
|
||||
removePersisted(key === "prompt" ? Persist.prompt(target) : target, platform)
|
||||
}
|
||||
}
|
||||
|
||||
const removeInfo = (key: string) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as Sentry from "@sentry/solid"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface } from "@/app"
|
||||
import { type Platform, PlatformProvider } from "@/context/platform"
|
||||
import { createBrowserDraftStore } from "@/utils/draft-store"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as zh } from "@/i18n/zh"
|
||||
import { authFromToken } from "@/utils/server"
|
||||
@@ -116,6 +117,7 @@ const clearAuthToken = () => {
|
||||
|
||||
const platform: Platform = {
|
||||
platform: "web",
|
||||
draftStore: createBrowserDraftStore(),
|
||||
version: pkg.version,
|
||||
openExternal,
|
||||
restart,
|
||||
|
||||
@@ -27,3 +27,4 @@ export {
|
||||
type WslServersState,
|
||||
} from "./wsl/types"
|
||||
export { ServerConnection } from "./context/server"
|
||||
export { createDraftStore, type DraftStore } from "./utils/draft-store"
|
||||
|
||||
@@ -167,4 +167,41 @@ describe("current session timeline rows", () => {
|
||||
"thinking:msg_2",
|
||||
])
|
||||
})
|
||||
|
||||
test("removes a failed assistant error when the turn continues streaming", () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "recover", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_failed",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
error: { type: "ProviderError", message: "temporary failure" },
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_recovery",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "streaming again" }],
|
||||
time: { created: 4 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
const normalized = normalizeSessionMessages("ses_1", source)
|
||||
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
|
||||
|
||||
const result = Timeline.constructSessionMessageRows(
|
||||
source,
|
||||
(messageID) => messages.get(messageID),
|
||||
(messageID) => normalized.parts.get(messageID) ?? [],
|
||||
true,
|
||||
"busy",
|
||||
true,
|
||||
normalized.messages.filter((message) => message.role === "user"),
|
||||
)
|
||||
|
||||
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,7 +116,8 @@ export namespace Timeline {
|
||||
const compaction = userParts.some((p) => p.type === "compaction")
|
||||
const interruptedMessageIndex = assistantMessages.findIndex((m) => m.error?.name === "MessageAbortedError")
|
||||
const interrupted = interruptedMessageIndex !== -1
|
||||
const error = assistantMessages.find((m) => m.error && m.error.name !== "MessageAbortedError")?.error
|
||||
const latestError = assistantMessages.at(-1)?.error
|
||||
const error = latestError?.name === "MessageAbortedError" ? undefined : latestError
|
||||
|
||||
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
|
||||
getMessageParts(message.id)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
|
||||
export type BlobReference = { id: string; url: string }
|
||||
|
||||
type Driver = {
|
||||
get(key: string): Promise<string | null>
|
||||
set(key: string, value: string): Promise<void>
|
||||
remove(key: string): Promise<void>
|
||||
putBlob(blob: Blob): Promise<string>
|
||||
getBlob(id: string): Promise<Blob | null>
|
||||
}
|
||||
|
||||
export type DraftStore = AsyncStorage & { putBlob(blob: Blob): Promise<BlobReference> }
|
||||
const urls = new Map<string, string>()
|
||||
|
||||
function blobUrl(id: string, blob: Blob) {
|
||||
const existing = urls.get(id)
|
||||
if (existing) return existing
|
||||
const url = URL.createObjectURL(blob)
|
||||
urls.set(id, url)
|
||||
return url
|
||||
}
|
||||
|
||||
async function blobID(blob: Blob) {
|
||||
const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer())))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
return id
|
||||
}
|
||||
|
||||
export async function createBlobReference(blob: Blob): Promise<BlobReference> {
|
||||
const id = await blobID(blob)
|
||||
return { id, url: blobUrl(id, blob) }
|
||||
}
|
||||
|
||||
export function createDraftStore(driver: Driver): DraftStore {
|
||||
const versions = new Map<string, number>()
|
||||
const putBlob = async (blob: Blob) => {
|
||||
const id = await driver.putBlob(blob)
|
||||
return { id, url: blobUrl(id, blob) }
|
||||
}
|
||||
const encode = async (value: unknown): Promise<unknown> => {
|
||||
if (Array.isArray(value)) return Promise.all(value.map(encode))
|
||||
if (!value || typeof value !== "object") return value
|
||||
const item = value as Record<string, unknown>
|
||||
if (item.type === "image" && typeof item.dataUrl === "string") {
|
||||
const blob = await fetch(item.dataUrl).then((response) => response.blob())
|
||||
const { dataUrl: _, ...rest } = item
|
||||
return { ...rest, blob: { id: await driver.putBlob(blob) } }
|
||||
}
|
||||
if ("blob" in item && item.blob && typeof item.blob === "object") {
|
||||
const blob = item.blob as Record<string, unknown>
|
||||
if (typeof blob.id === "string" && blob.id.startsWith("data:")) {
|
||||
const data = await fetch(blob.id).then((response) => response.blob())
|
||||
return { ...item, blob: { id: await driver.putBlob(data) } }
|
||||
}
|
||||
return { ...item, blob: { id: blob.id } }
|
||||
}
|
||||
return Object.fromEntries(
|
||||
await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await encode(entry)])),
|
||||
)
|
||||
}
|
||||
const decode = async (value: unknown): Promise<unknown> => {
|
||||
if (Array.isArray(value)) return Promise.all(value.map(decode))
|
||||
if (!value || typeof value !== "object") return value
|
||||
const item = value as Record<string, unknown>
|
||||
if (item.blob && typeof item.blob === "object") {
|
||||
const ref = item.blob as Record<string, unknown>
|
||||
if (typeof ref.id === "string") {
|
||||
const blob = await driver.getBlob(ref.id)
|
||||
if (blob) return { ...item, blob: { id: ref.id, url: blobUrl(ref.id, blob) } }
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await decode(entry)])),
|
||||
)
|
||||
}
|
||||
return {
|
||||
getItem: async (key) => {
|
||||
const value = await driver.get(key)
|
||||
return value === null ? null : JSON.stringify(await decode(JSON.parse(value)))
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
const version = (versions.get(key) ?? 0) + 1
|
||||
versions.set(key, version)
|
||||
const encoded = JSON.stringify(await encode(JSON.parse(value)))
|
||||
if (versions.get(key) === version) await driver.set(key, encoded)
|
||||
},
|
||||
removeItem: async (key) => {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1)
|
||||
await driver.remove(key)
|
||||
},
|
||||
putBlob,
|
||||
}
|
||||
}
|
||||
|
||||
export function createBrowserDraftStore(): DraftStore {
|
||||
const request = indexedDB.open("opencode-drafts", 1)
|
||||
request.addEventListener("upgradeneeded", () => {
|
||||
request.result.createObjectStore("documents")
|
||||
request.result.createObjectStore("blobs")
|
||||
})
|
||||
const db = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
request.addEventListener("success", () => {
|
||||
const database = request.result
|
||||
const transaction = database.transaction(["documents", "blobs"], "readwrite")
|
||||
const documents = transaction.objectStore("documents").getAll()
|
||||
documents.addEventListener("success", () => {
|
||||
const used = new Set<string>()
|
||||
JSON.parse(`[${documents.result.join(",")}]`, (_key, item) => {
|
||||
if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
|
||||
return item
|
||||
})
|
||||
const blobs = transaction.objectStore("blobs").openKeyCursor()
|
||||
blobs.addEventListener("success", () => {
|
||||
const cursor = blobs.result
|
||||
if (!cursor) return
|
||||
if (!used.has(String(cursor.key))) cursor.delete()
|
||||
cursor.continue()
|
||||
})
|
||||
})
|
||||
transaction.addEventListener("complete", () => resolve(database))
|
||||
transaction.addEventListener("abort", () => resolve(database))
|
||||
})
|
||||
request.addEventListener("error", () => reject(request.error))
|
||||
})
|
||||
const get = async (store: string, key: string) => {
|
||||
const result = (await db).transaction(store).objectStore(store).get(key)
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
result.addEventListener("success", () => resolve(result.result))
|
||||
result.addEventListener("error", () => reject(result.error))
|
||||
})
|
||||
}
|
||||
const write = async (store: string, key: string, value?: unknown) => {
|
||||
const transaction = (await db).transaction(store, "readwrite")
|
||||
if (value === undefined) transaction.objectStore(store).delete(key)
|
||||
else transaction.objectStore(store).put(value, key)
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
transaction.addEventListener("complete", () => resolve())
|
||||
transaction.addEventListener("error", () => reject(transaction.error))
|
||||
})
|
||||
}
|
||||
return createDraftStore({
|
||||
get: async (key) => ((await get("documents", key)) as string | undefined) ?? null,
|
||||
set: (key, value) => write("documents", key, value),
|
||||
remove: (key) => write("documents", key),
|
||||
putBlob: async (blob) => {
|
||||
const id = await blobID(blob)
|
||||
await write("blobs", id, blob)
|
||||
return id
|
||||
},
|
||||
getBlob: async (id) => ((await get("blobs", id)) as Blob | undefined) ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function blobDataUrl(blob: BlobReference, mime: string) {
|
||||
const data = await fetch(blob.url).then((response) => response.blob())
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("error", () => reject(reader.error))
|
||||
reader.addEventListener("load", () => {
|
||||
const value = typeof reader.result === "string" ? reader.result : ""
|
||||
resolve(`data:${mime};base64,${value.slice(value.indexOf(",") + 1)}`)
|
||||
})
|
||||
reader.readAsDataURL(data)
|
||||
})
|
||||
}
|
||||
|
||||
export function createLegacyBlobReference(dataUrl: string): BlobReference {
|
||||
return { id: dataUrl, url: dataUrl }
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type PersistedWithReady<T> = [
|
||||
]
|
||||
|
||||
type PersistTarget = {
|
||||
draft?: boolean
|
||||
storage?: string
|
||||
scope?: "window"
|
||||
legacyStorageNames?: string[]
|
||||
@@ -295,6 +296,14 @@ async function removeAsync(storage: AsyncStorage, key: string) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function toAsyncStorage(storage: SyncStorage | AsyncStorage): AsyncStorage {
|
||||
return {
|
||||
getItem: async (key) => storage.getItem(key),
|
||||
setItem: async (key, value) => storage.setItem(key, value),
|
||||
removeItem: async (key) => storage.removeItem(key),
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateLegacyAsync(input: {
|
||||
current: AsyncStorage
|
||||
legacyStore?: AsyncStorage
|
||||
@@ -513,6 +522,9 @@ export const Persist = {
|
||||
if (session) return Persist.serverSession(scope, dir, session, key, legacy)
|
||||
return Persist.serverWorkspace(scope, dir, key, legacy)
|
||||
},
|
||||
prompt(target: PersistTarget): PersistTarget {
|
||||
return { ...target, draft: true }
|
||||
},
|
||||
}
|
||||
|
||||
function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget {
|
||||
@@ -526,9 +538,12 @@ function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget
|
||||
}
|
||||
|
||||
export function removePersisted(
|
||||
target: { storage?: string; legacyStorageNames?: string[]; key: string },
|
||||
target: { draft?: boolean; storage?: string; legacyStorageNames?: string[]; key: string },
|
||||
platform?: Platform,
|
||||
) {
|
||||
if (target.draft && platform?.draftStore) {
|
||||
void platform.draftStore.removeItem(`${target.storage ?? "default"}:${target.key}`)
|
||||
}
|
||||
const isDesktop = platform?.platform === "desktop" && !!platform.storage
|
||||
|
||||
if (isDesktop) {
|
||||
@@ -561,8 +576,17 @@ export function persisted<T>(
|
||||
const legacy = config.legacy ?? []
|
||||
|
||||
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
||||
const draft = config.draft ? platform.draftStore : undefined
|
||||
|
||||
const currentStorage = (() => {
|
||||
if (draft) {
|
||||
const prefix = `${config.storage ?? "default"}:`
|
||||
return {
|
||||
getItem: (key: string) => draft.getItem(prefix + key),
|
||||
setItem: (key: string, value: string) => draft.setItem(prefix + key, value),
|
||||
removeItem: (key: string) => draft.removeItem(prefix + key),
|
||||
} satisfies AsyncStorage
|
||||
}
|
||||
if (isDesktop) return platform.storage?.(config.storage)
|
||||
if (!config.storage) return localStorageDirect()
|
||||
return localStorageWithPrefix(config.storage)
|
||||
@@ -577,7 +601,7 @@ export function persisted<T>(
|
||||
const legacyStorageNames = config.legacyStorageNames ?? []
|
||||
|
||||
const storage = (() => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktop && !draft) {
|
||||
const current = currentStorage as SyncStorage
|
||||
const legacyStore = legacyStorage as SyncStorage
|
||||
const legacyStores = legacyStorageNames.map(localStorageWithPrefix)
|
||||
@@ -609,15 +633,26 @@ export function persisted<T>(
|
||||
|
||||
const current = currentStorage as AsyncStorage
|
||||
const legacyStore = legacyStorage as AsyncStorage | undefined
|
||||
const legacyStores = legacyStorageNames
|
||||
.map((name) => platform.storage?.(name) as AsyncStorage | undefined)
|
||||
const oldCurrent = draft
|
||||
? isDesktop
|
||||
? platform.storage?.(config.storage)
|
||||
: config.storage
|
||||
? localStorageWithPrefix(config.storage)
|
||||
: localStorageDirect()
|
||||
: undefined
|
||||
const legacyStores = [
|
||||
oldCurrent,
|
||||
...legacyStorageNames.map((name) => (isDesktop ? platform.storage?.(name) : localStorageWithPrefix(name))),
|
||||
]
|
||||
.filter((x) => !!x)
|
||||
.map(toAsyncStorage)
|
||||
let draftLatest: string | undefined
|
||||
|
||||
const api: AsyncStorage = {
|
||||
getItem: async (key) => {
|
||||
const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate })
|
||||
if (value !== undefined) return value
|
||||
return migrateLegacyAsync({
|
||||
const migrated = await migrateLegacyAsync({
|
||||
current,
|
||||
legacyStore,
|
||||
stores: legacyStores,
|
||||
@@ -626,8 +661,15 @@ export function persisted<T>(
|
||||
defaults,
|
||||
migrate: config.migrate,
|
||||
})
|
||||
if (draftLatest === undefined) {
|
||||
if (draft && migrated !== null) return (await current.getItem(key)) ?? migrated
|
||||
return migrated
|
||||
}
|
||||
await current.setItem(key, draftLatest)
|
||||
return draftLatest
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
if (draft) draftLatest = value
|
||||
await current.setItem(key, value)
|
||||
},
|
||||
removeItem: async (key) => {
|
||||
|
||||
@@ -37,8 +37,18 @@ describe("extractPromptFromParts", () => {
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0]).toMatchObject({ type: "text", content: "check these" })
|
||||
expect(result.slice(1)).toMatchObject([
|
||||
{ type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
{ type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
|
||||
{
|
||||
type: "image",
|
||||
filename: "a.png",
|
||||
mime: "image/png",
|
||||
blob: expect.objectContaining({ id: expect.any(String) }),
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
filename: "b.pdf",
|
||||
mime: "application/pdf",
|
||||
blob: expect.objectContaining({ id: expect.any(String) }),
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { createLegacyBlobReference } from "@/utils/draft-store"
|
||||
|
||||
type Inline =
|
||||
| {
|
||||
@@ -107,7 +108,7 @@ export function extractPromptFromParts(parts: Part[], opts?: { directory?: strin
|
||||
id: filePart.id,
|
||||
filename: filePart.filename ?? attachmentName,
|
||||
mime: filePart.mime,
|
||||
dataUrl: filePart.url,
|
||||
blob: createLegacyBlobReference(filePart.url),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { createEffect, createRoot } from "solid-js"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { createDraftStore } from "@/utils/draft-store"
|
||||
|
||||
let Prompt: typeof import("@/context/prompt")
|
||||
let read: ((value: string | null) => void) | undefined
|
||||
@@ -30,7 +31,7 @@ beforeAll(async () => {
|
||||
}),
|
||||
}))
|
||||
mock.module("@/context/platform", () => ({
|
||||
usePlatform: () => ({ platform: "desktop", storage: () => storage }),
|
||||
usePlatform: () => ({ platform: "desktop", storage: () => storage, draftStore: storage }),
|
||||
}))
|
||||
|
||||
Prompt = await import("@/context/prompt")
|
||||
@@ -69,3 +70,50 @@ describe("prompt persistence", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("moves legacy image data URLs into blobs and hydrates object URLs", async () => {
|
||||
const documents = new Map<string, string>()
|
||||
const blobs = new Map<string, Blob>()
|
||||
const store = createDraftStore({
|
||||
get: async (key) => documents.get(key) ?? null,
|
||||
set: async (key, value) => void documents.set(key, value),
|
||||
remove: async (key) => void documents.delete(key),
|
||||
putBlob: async (blob) => {
|
||||
const id = String(blob.size)
|
||||
blobs.set(id, blob)
|
||||
return id
|
||||
},
|
||||
getBlob: async (id) => blobs.get(id) ?? null,
|
||||
})
|
||||
|
||||
await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }))
|
||||
expect(documents.get("prompt")).not.toContain("dataUrl")
|
||||
const value = JSON.parse((await store.getItem("prompt"))!)
|
||||
expect(value.prompt[0].blob.id).toBe("1")
|
||||
expect(value.prompt[0].blob.url).toStartWith("blob:")
|
||||
})
|
||||
|
||||
test("does not let delayed blob migration overwrite a newer draft", async () => {
|
||||
const documents = new Map<string, string>()
|
||||
const migration = Promise.withResolvers<void>()
|
||||
const store = createDraftStore({
|
||||
get: async () => null,
|
||||
set: async (key, value) => void documents.set(key, value),
|
||||
remove: async () => undefined,
|
||||
putBlob: async () => {
|
||||
await migration.promise
|
||||
return "blob"
|
||||
},
|
||||
getBlob: async () => null,
|
||||
})
|
||||
const older = store.setItem(
|
||||
"prompt",
|
||||
JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }),
|
||||
)
|
||||
await Bun.sleep(0)
|
||||
await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "text", content: "latest" }] }))
|
||||
migration.resolve()
|
||||
await older
|
||||
|
||||
expect(documents.get("prompt")).toContain("latest")
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -347,6 +347,7 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention":
|
||||
"تُنشأ سجلات مراقبة إساءة الاستخدام لكل استخدام لميزات API، ويُحتفظ بها لمدة تصل إلى 30 يومًا.",
|
||||
"go.faq.a5.learnMore": "اعرف المزيد",
|
||||
"go.faq.a5.deepseekRetention": "تُجدَّد اتفاقية ZDR شهريًا. الاتفاقية الحالية سارية حتى 31 أغسطس 2026.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"تتم استضافة نماذج Go في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع",
|
||||
"go.faq.a5.exceptionsLink": "الاستثناءات التالية",
|
||||
|
||||
@@ -354,6 +354,8 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention":
|
||||
"Logs de monitoramento de abuso são gerados para todo uso de recursos da API e retidos por até 30 dias.",
|
||||
"go.faq.a5.learnMore": "Saiba mais",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
"O acordo de ZDR é renovado mensalmente. O acordo atual é válido até 31 de agosto de 2026.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Os modelos Go são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos, com as",
|
||||
"go.faq.a5.exceptionsLink": "seguintes exceções",
|
||||
|
||||
@@ -351,6 +351,8 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention":
|
||||
"Logfiler til overvågning af misbrug genereres ved al brug af API-funktioner og opbevares i op til 30 dage.",
|
||||
"go.faq.a5.learnMore": "Læs mere",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
"ZDR-aftalen fornyes månedligt. Den nuværende aftale er gyldig til og med 31. august 2026.",
|
||||
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Go-modeller hostes i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning, med de",
|
||||
|
||||
@@ -353,6 +353,8 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention":
|
||||
"Für die Nutzung aller API-Funktionen werden Protokolle zur Missbrauchsüberwachung erstellt und bis zu 30 Tage lang aufbewahrt.",
|
||||
"go.faq.a5.learnMore": "Mehr erfahren",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
"Die ZDR-Vereinbarung wird monatlich erneuert. Die aktuelle Vereinbarung gilt bis einschließlich 31. August 2026.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Go-Modelle werden in den USA gehostet. Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen, mit den",
|
||||
"go.faq.a5.exceptionsLink": "folgenden Ausnahmen",
|
||||
|
||||
@@ -348,6 +348,8 @@ export const dict = {
|
||||
"ZDR disables important API features that depend on stored data, including the stateful Responses API, Files and Collections, and the Batch API.",
|
||||
"go.faq.a5.gptRetention":
|
||||
"Abuse monitoring logs are generated for all API feature usage and retained for up to 30 days.",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
"ZDR agreement is renewed monthly. The current agreement is valid through August 31, 2026.",
|
||||
"go.faq.a5.learnMore": "Learn more",
|
||||
|
||||
"go.faq.a5.beforeExceptions":
|
||||
|
||||
@@ -354,6 +354,8 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention":
|
||||
"Se generan registros de supervisión de abusos para todo el uso de funciones de la API y se conservan durante un máximo de 30 días.",
|
||||
"go.faq.a5.learnMore": "Más información",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
"El acuerdo de ZDR se renueva mensualmente. El acuerdo actual es válido hasta el 31 de agosto de 2026.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Los modelos de Go están alojados en EE. UU. Los proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos, con las",
|
||||
"go.faq.a5.exceptionsLink": "siguientes excepciones",
|
||||
|
||||
@@ -355,6 +355,8 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention":
|
||||
"Des journaux de surveillance des abus sont générés pour toute utilisation des fonctionnalités API et conservés pendant un maximum de 30 jours.",
|
||||
"go.faq.a5.learnMore": "En savoir plus",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
"L’accord ZDR est renouvelé chaque mois. L’accord actuel est valable jusqu’au 31 août 2026.",
|
||||
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Les modèles Go sont hébergés aux États-Unis. Les fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles, avec les",
|
||||
|
||||
@@ -350,6 +350,8 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention":
|
||||
"I log di monitoraggio degli abusi vengono generati per l'utilizzo di tutte le funzionalità API e conservati per un massimo di 30 giorni.",
|
||||
"go.faq.a5.learnMore": "Scopri di più",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
"L'accordo ZDR viene rinnovato mensilmente. L'accordo attuale è valido fino al 31 agosto 2026.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"I modelli Go sono ospitati negli Stati Uniti. I provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli, con le",
|
||||
"go.faq.a5.exceptionsLink": "seguenti eccezioni",
|
||||
|
||||
@@ -349,6 +349,7 @@ export const dict = {
|
||||
"ZDRでは、保存データに依存する重要なAPI機能(ステートフルなResponses API、Files and Collections、Batch APIなど)が無効になります。",
|
||||
"go.faq.a5.gptRetention": "不正使用監視ログはすべてのAPI機能の使用時に生成され、最大30日間保持されます。",
|
||||
"go.faq.a5.learnMore": "詳しく見る",
|
||||
"go.faq.a5.deepseekRetention": "ZDR契約は毎月更新されます。現在の契約は2026年8月31日まで有効です。",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Goのモデルは米国でホストされています。プロバイダーはゼロ保持ポリシーに従い、モデルのトレーニングにデータを使用しません(",
|
||||
"go.faq.a5.exceptionsLink": "以下の例外",
|
||||
|
||||
@@ -345,6 +345,7 @@ export const dict = {
|
||||
"ZDR은 저장된 데이터에 의존하는 중요한 API 기능(상태 저장형 Responses API, Files and Collections, Batch API 포함)을 비활성화합니다.",
|
||||
"go.faq.a5.gptRetention": "모든 API 기능 사용에 대해 악용 모니터링 로그가 생성되며 최대 30일 동안 보존됩니다.",
|
||||
"go.faq.a5.learnMore": "자세히 알아보기",
|
||||
"go.faq.a5.deepseekRetention": "ZDR 계약은 매월 갱신됩니다. 현재 계약은 2026년 8월 31일까지 유효합니다.",
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Go 모델은 미국에서 호스팅됩니다. 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다. 단,",
|
||||
"go.faq.a5.exceptionsLink": "다음 예외",
|
||||
|
||||
@@ -351,6 +351,8 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention":
|
||||
"Logger for overvåking av misbruk genereres for all bruk av API-funksjoner og oppbevares i opptil 30 dager.",
|
||||
"go.faq.a5.learnMore": "Les mer",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
"ZDR-avtalen fornyes månedlig. Den gjeldende avtalen er gyldig til og med 31. august 2026.",
|
||||
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Go-modeller hostes i USA. Leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening, med",
|
||||
|
||||
@@ -352,6 +352,7 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention":
|
||||
"Dzienniki monitorowania nadużyć są generowane dla każdego użycia funkcji API i przechowywane przez maksymalnie 30 dni.",
|
||||
"go.faq.a5.learnMore": "Dowiedz się więcej",
|
||||
"go.faq.a5.deepseekRetention": "Umowa ZDR jest odnawiana co miesiąc. Obecna umowa obowiązuje do 31 sierpnia 2026 r.",
|
||||
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Modele Go są hostowane w USA. Dostawcy stosują politykę zerowej retencji i nie używają Twoich danych do trenowania modeli, z",
|
||||
|
||||
@@ -356,6 +356,8 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention":
|
||||
"Журналы мониторинга злоупотреблений создаются при любом использовании функций API и хранятся до 30 дней.",
|
||||
"go.faq.a5.learnMore": "Подробнее",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
"Соглашение ZDR продлевается ежемесячно. Текущее соглашение действует до 31 августа 2026 года.",
|
||||
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Модели Go размещены в США. Провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей, за",
|
||||
|
||||
@@ -348,6 +348,7 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention":
|
||||
"ระบบจะสร้างบันทึกการตรวจสอบการใช้งานในทางที่ผิดสำหรับการใช้งานฟีเจอร์ API ทั้งหมด และเก็บรักษาไว้นานสูงสุด 30 วัน",
|
||||
"go.faq.a5.learnMore": "ดูข้อมูลเพิ่มเติม",
|
||||
"go.faq.a5.deepseekRetention": "ข้อตกลง ZDR จะต่ออายุทุกเดือน ข้อตกลงปัจจุบันมีผลใช้ถึงวันที่ 31 สิงหาคม 2026",
|
||||
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"โมเดล Go โฮสต์ในสหรัฐอเมริกา ผู้ให้บริการปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูล (zero-retention policy) และไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล โดยมี",
|
||||
|
||||
@@ -354,6 +354,8 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention":
|
||||
"Tüm API özelliklerinin kullanımı için kötüye kullanım izleme günlükleri oluşturulur ve 30 güne kadar saklanır.",
|
||||
"go.faq.a5.learnMore": "Daha fazla bilgi",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
"ZDR anlaşması aylık olarak yenilenir. Mevcut anlaşma 31 Ağustos 2026 tarihine kadar geçerlidir.",
|
||||
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Go modelleri ABD'de barındırılmaktadır. Sağlayıcılar sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz; şu",
|
||||
|
||||
@@ -352,6 +352,7 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention":
|
||||
"Журнали моніторингу зловживань створюються для всіх випадків використання функцій API та зберігаються до 30 днів.",
|
||||
"go.faq.a5.learnMore": "Докладніше",
|
||||
"go.faq.a5.deepseekRetention": "Угода ZDR поновлюється щомісяця. Поточна угода дійсна до 31 серпня 2026 року.",
|
||||
|
||||
"go.faq.a5.beforeExceptions":
|
||||
"Моделі Go розміщені в США. Провайдери дотримуються політики нульового зберігання та не використовують ваші дані для навчання моделей, за",
|
||||
|
||||
@@ -333,6 +333,7 @@ export const dict = {
|
||||
"ZDR 会禁用依赖所存储数据的重要 API 功能,包括有状态的 Responses API、Files and Collections 和 Batch API。",
|
||||
"go.faq.a5.gptRetention": "所有 API 功能的使用都会生成滥用监控日志,并最多保留 30 天。",
|
||||
"go.faq.a5.learnMore": "了解更多",
|
||||
"go.faq.a5.deepseekRetention": "ZDR 协议每月续签。当前协议有效期至 2026 年 8 月 31 日。",
|
||||
"go.faq.a5.beforeExceptions": "Go 模型托管在美国。提供商遵循零留存政策,不使用您的数据进行模型训练,",
|
||||
"go.faq.a5.exceptionsLink": "以下例外情况除外",
|
||||
"go.faq.q6": "我可以充值余额吗?",
|
||||
|
||||
@@ -333,6 +333,7 @@ export const dict = {
|
||||
"ZDR 會停用依賴儲存資料的重要 API 功能,包括具狀態的 Responses API、Files and Collections 與 Batch API。",
|
||||
"go.faq.a5.gptRetention": "所有 API 功能的使用都會產生濫用監控日誌,並保留最多 30 天。",
|
||||
"go.faq.a5.learnMore": "了解更多",
|
||||
"go.faq.a5.deepseekRetention": "ZDR 協議每月續簽。目前的協議有效至 2026 年 8 月 31 日。",
|
||||
"go.faq.a5.beforeExceptions": "Go 模型託管在美國。供應商遵循零留存政策,不會將你的資料用於模型訓練,但有",
|
||||
"go.faq.a5.exceptionsLink": "以下例外",
|
||||
"go.faq.q6": "我可以儲值額度嗎?",
|
||||
|
||||
@@ -32,13 +32,14 @@ const models = [
|
||||
{ name: "Kimi K2.6", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "MiMo-V2.5-Pro", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "MiMo-V2.5", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "Qwen3.8 Max", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "Qwen3.7 Max", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "Qwen3.7 Plus", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "Qwen3.6 Plus", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "MiniMax M3", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "MiniMax M2.7", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "DeepSeek V4 Pro", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "DeepSeek V4 Flash", training: "go.faq.a5.used", retention: "go.faq.a5.noAgreement" },
|
||||
{ name: "DeepSeek V4 Flash", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "Hy3", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
] as const
|
||||
|
||||
@@ -67,6 +68,7 @@ function LimitsGraph(props: { href: string }) {
|
||||
const graph = [
|
||||
{ id: "grok-4.5", name: "Grok 4.5", req: 120, d: "50ms" },
|
||||
{ id: "kimi-k3", name: "Kimi K3", req: 110, d: "75ms" },
|
||||
{ id: "qwen3.8-max", name: "Qwen3.8 Max", req: 160, d: "90ms" },
|
||||
{ id: "glm-5.2", name: "GLM-5.2", req: 880, d: "100ms" },
|
||||
{ id: "minimax-m3", name: "MiniMax M3", req: 3200, d: "210ms" },
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "270ms" },
|
||||
@@ -505,6 +507,9 @@ export default function Home() {
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
<strong>DeepSeek V4 Flash:</strong> {i18n.t("go.faq.a5.deepseekRetention")}
|
||||
</p>
|
||||
</div>
|
||||
</Faq>
|
||||
</li>
|
||||
|
||||
@@ -313,6 +313,7 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
|
||||
<li>Kimi K2.6</li>
|
||||
<li>MiniMax M3</li>
|
||||
<li>MiniMax M2.7</li>
|
||||
<li>Qwen3.8 Max</li>
|
||||
<li>Qwen3.7 Max</li>
|
||||
<li>Qwen3.7 Plus</li>
|
||||
<li>Qwen3.6 Plus</li>
|
||||
|
||||
@@ -65,7 +65,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
|
||||
const cacheReadTokens = usage.cachedContentTokenCount ?? 0
|
||||
return {
|
||||
inputTokens: inputTokens - cacheReadTokens,
|
||||
outputTokens,
|
||||
outputTokens: outputTokens + reasoningTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens: undefined,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE UNIQUE INDEX `global_lite_subscription_id` ON `billing` (`lite_subscription_id`);--> statement-breakpoint
|
||||
CREATE INDEX `referral_id` ON `referral_reward` (`referral_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -59,30 +59,7 @@ export namespace Referral {
|
||||
const accountID = Actor.account()
|
||||
const code = await ensureCode(workspaceID)
|
||||
const rows = await Database.use(async (tx) => {
|
||||
const [rewards, invites, inviteeReferral, inviteeRewards] = await Promise.all([
|
||||
tx
|
||||
.select({
|
||||
referralID: ReferralRewardTable.referralID,
|
||||
workspaceID: ReferralRewardTable.workspaceID,
|
||||
referralWorkspaceID: ReferralTable.workspaceID,
|
||||
inviteeEmail: AuthTable.subject,
|
||||
amount: ReferralRewardTable.amount,
|
||||
timeCreated: ReferralRewardTable.timeCreated,
|
||||
timeApplied: ReferralRewardTable.timeApplied,
|
||||
})
|
||||
.from(ReferralRewardTable)
|
||||
.innerJoin(ReferralTable, eq(ReferralTable.id, ReferralRewardTable.referralID))
|
||||
.innerJoin(
|
||||
AuthTable,
|
||||
and(eq(AuthTable.accountID, ReferralTable.inviteeAccountID), eq(AuthTable.provider, "email")),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(ReferralRewardTable.workspaceID, workspaceID),
|
||||
isNull(ReferralRewardTable.timeDeleted),
|
||||
isNull(ReferralTable.timeDeleted),
|
||||
),
|
||||
),
|
||||
const [invites, inviteeReferral] = await Promise.all([
|
||||
tx
|
||||
.select({ id: ReferralTable.id, inviteeEmail: AuthTable.subject, timeCreated: ReferralTable.timeCreated })
|
||||
.from(ReferralTable)
|
||||
@@ -106,19 +83,20 @@ export namespace Referral {
|
||||
.where(and(eq(ReferralTable.inviteeAccountID, accountID), isNull(ReferralTable.timeDeleted)))
|
||||
.orderBy(asc(UserTable.timeCreated))
|
||||
.then((rows) => rows.find((row) => row.inviterEmail) ?? rows[0]),
|
||||
tx
|
||||
.select({ referralID: ReferralRewardTable.referralID })
|
||||
.from(ReferralRewardTable)
|
||||
.innerJoin(ReferralTable, eq(ReferralTable.id, ReferralRewardTable.referralID))
|
||||
.where(
|
||||
and(
|
||||
eq(ReferralTable.inviteeAccountID, accountID),
|
||||
isNull(ReferralRewardTable.timeDeleted),
|
||||
isNull(ReferralTable.timeDeleted),
|
||||
),
|
||||
),
|
||||
])
|
||||
|
||||
// Hide reward history until the referral_id index can be deployed and this lookup restored.
|
||||
const rewards: {
|
||||
referralID: string
|
||||
workspaceID: string
|
||||
referralWorkspaceID: string
|
||||
inviteeEmail: string
|
||||
amount: number
|
||||
timeCreated: Date
|
||||
timeApplied: Date | null
|
||||
}[] = []
|
||||
// Hide pending invitee rewards until the referral_id index can be deployed and this lookup restored.
|
||||
const inviteeRewards = inviteeReferral ? [{ referralID: inviteeReferral.id }] : []
|
||||
return { inviteeReferral, inviteeRewards, invites, rewards }
|
||||
})
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export const BillingTable = mysqlTable(
|
||||
...workspaceIndexes(table),
|
||||
uniqueIndex("global_customer_id").on(table.customerID),
|
||||
uniqueIndex("global_subscription_id").on(table.subscriptionID),
|
||||
uniqueIndex("global_lite_subscription_id").on(table.liteSubscriptionID),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { bigint, mysqlTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||
import { bigint, index, mysqlTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
||||
import { workspaceIndexes } from "./workspace.sql"
|
||||
|
||||
@@ -31,5 +31,8 @@ export const ReferralRewardTable = mysqlTable(
|
||||
amount: bigint("amount", { mode: "number" }).notNull(),
|
||||
timeApplied: utc("time_applied"),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.workspaceID, table.referralID] })],
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.workspaceID, table.referralID] }),
|
||||
index("referral_id").on(table.referralID),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -115,11 +115,7 @@ export const Model = Schema.Struct({
|
||||
),
|
||||
status: Schema.optional(CatalogModelStatus),
|
||||
provider: Schema.optional(
|
||||
Schema.Struct({
|
||||
npm: Schema.optional(Schema.String),
|
||||
api: Schema.optional(Schema.String),
|
||||
shape: Schema.optional(Schema.Literals(["responses", "completions"])),
|
||||
}),
|
||||
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
|
||||
),
|
||||
})
|
||||
export type Model = Schema.Schema.Type<typeof Model>
|
||||
|
||||
@@ -84,27 +84,19 @@ function applyModel(
|
||||
) {
|
||||
draft.name = input.name ?? model.name
|
||||
draft.family = model.family
|
||||
draft.api =
|
||||
model.provider?.shape === "responses"
|
||||
? {
|
||||
id: model.id,
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai",
|
||||
url: model.provider.api ?? draft.api.url,
|
||||
}
|
||||
: model.provider?.npm
|
||||
? {
|
||||
id: model.id,
|
||||
type: "aisdk",
|
||||
package: model.provider.npm,
|
||||
url: model.provider.api,
|
||||
}
|
||||
: {
|
||||
id: model.id,
|
||||
type: "native",
|
||||
url: model.provider?.api,
|
||||
settings: {},
|
||||
}
|
||||
draft.api = model.provider?.npm
|
||||
? {
|
||||
id: model.id,
|
||||
type: "aisdk",
|
||||
package: model.provider.npm,
|
||||
url: model.provider.api,
|
||||
}
|
||||
: {
|
||||
id: model.id,
|
||||
type: "native",
|
||||
url: model.provider?.api,
|
||||
settings: {},
|
||||
}
|
||||
draft.capabilities = {
|
||||
tools: model.tool_call,
|
||||
input: [...(model.modalities?.input ?? [])],
|
||||
|
||||
@@ -62,11 +62,7 @@ export const Model = Schema.Struct({
|
||||
experimental: Schema.optional(Schema.Boolean),
|
||||
status: Schema.optional(ModelStatus),
|
||||
provider: Schema.optional(
|
||||
Schema.Struct({
|
||||
npm: Schema.optional(Schema.String),
|
||||
api: Schema.optional(Schema.String),
|
||||
shape: Schema.optional(Schema.Literals(["responses", "completions"])),
|
||||
}),
|
||||
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
|
||||
),
|
||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
|
||||
@@ -76,17 +76,6 @@ describe("ModelsDevPlugin", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
"deepseek-v4-flash": {
|
||||
id: "deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash",
|
||||
release_date: "2026-07-31",
|
||||
attachment: false,
|
||||
reasoning: true,
|
||||
temperature: true,
|
||||
tool_call: true,
|
||||
provider: { shape: "responses" },
|
||||
limit: { context: 1_000_000, output: 384_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, ModelsDev.Provider>),
|
||||
@@ -103,7 +92,6 @@ describe("ModelsDevPlugin", () => {
|
||||
const providerID = ProviderV2.ID.make("acme")
|
||||
const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4"))
|
||||
const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast"))
|
||||
const deepseek = yield* catalog.model.get(providerID, ModelV2.ID.make("deepseek-v4-flash"))
|
||||
|
||||
expect(base?.variants).toEqual([])
|
||||
expect(base?.request.body).toEqual({})
|
||||
@@ -133,12 +121,6 @@ describe("ModelsDevPlugin", () => {
|
||||
cache: { read: 0.5, write: 0 },
|
||||
},
|
||||
])
|
||||
expect(deepseek?.api).toMatchObject({
|
||||
id: "deepseek-v4-flash",
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai",
|
||||
url: "https://api.acme.test/v1",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
@@ -30,7 +30,8 @@
|
||||
"electron-log": "^5",
|
||||
"electron-store": "11.0.2",
|
||||
"electron-updater": "6.8.9",
|
||||
"electron-window-state": "^5.0.3"
|
||||
"electron-window-state": "^5.0.3",
|
||||
"drizzle-orm": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "4.0.0",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createDesktopDraftStore } from "./draft-store"
|
||||
|
||||
test("flushes the latest buffered draft and stores blobs", () => {
|
||||
const store = createDesktopDraftStore(":memory:")
|
||||
store.set("prompt", "first")
|
||||
store.set("prompt", "latest")
|
||||
expect(store.get("prompt")).toBe("latest")
|
||||
store.flush()
|
||||
expect(store.get("prompt")).toBe("latest")
|
||||
|
||||
const bytes = new TextEncoder().encode("image")
|
||||
const id = store.putBlob(bytes)
|
||||
expect(store.getBlob(id)).toEqual(bytes)
|
||||
store.close()
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { DatabaseSync } from "node:sqlite"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { drizzle } from "drizzle-orm/node-sqlite"
|
||||
import { blob, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
|
||||
const documents = sqliteTable("document", {
|
||||
key: text().primaryKey(),
|
||||
value: text().notNull(),
|
||||
})
|
||||
const blobs = sqliteTable("blob", {
|
||||
id: text().primaryKey(),
|
||||
data: blob({ mode: "buffer" }).notNull(),
|
||||
})
|
||||
|
||||
export function createDesktopDraftStore(filename: string) {
|
||||
const native = new DatabaseSync(filename)
|
||||
native.exec(
|
||||
"PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS document (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS blob (id TEXT PRIMARY KEY, data BLOB NOT NULL);",
|
||||
)
|
||||
const db = drizzle({ client: native })
|
||||
const used = new Set<string>()
|
||||
db.select({ value: documents.value })
|
||||
.from(documents)
|
||||
.all()
|
||||
.forEach(({ value }) =>
|
||||
JSON.parse(value, (_key, item) => {
|
||||
if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
|
||||
return item
|
||||
}),
|
||||
)
|
||||
db.select({ id: blobs.id })
|
||||
.from(blobs)
|
||||
.all()
|
||||
.filter(({ id }) => !used.has(id))
|
||||
.forEach(({ id }) => db.delete(blobs).where(eq(blobs.id, id)).run())
|
||||
const pending = new Map<string, string | null>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const flush = () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
const writes = [...pending]
|
||||
pending.clear()
|
||||
db.transaction((tx) => {
|
||||
writes.forEach(([key, value]) => {
|
||||
if (value === null) tx.delete(documents).where(eq(documents.key, key)).run()
|
||||
else
|
||||
tx.insert(documents)
|
||||
.values({ key, value })
|
||||
.onConflictDoUpdate({ target: documents.key, set: { value } })
|
||||
.run()
|
||||
})
|
||||
})
|
||||
}
|
||||
const schedule = () => {
|
||||
if (!timer) timer = setTimeout(flush, 500)
|
||||
}
|
||||
return {
|
||||
get: (key: string) =>
|
||||
pending.has(key)
|
||||
? (pending.get(key) ?? null)
|
||||
: (db.select({ value: documents.value }).from(documents).where(eq(documents.key, key)).get()?.value ?? null),
|
||||
set(key: string, value: string | null) {
|
||||
pending.set(key, value)
|
||||
schedule()
|
||||
},
|
||||
putBlob(data: Uint8Array) {
|
||||
const id = createHash("sha256").update(data).digest("hex")
|
||||
db.insert(blobs)
|
||||
.values({ id, data: Buffer.from(data) })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
return id
|
||||
},
|
||||
getBlob: (id: string) => db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null,
|
||||
flush,
|
||||
close() {
|
||||
flush()
|
||||
native.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,7 @@ const main = Effect.gen(function* () {
|
||||
setAppQuitting()
|
||||
void stopSidecars().finally(() => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
app.quit()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ const main = Effect.gen(function* () {
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
setAppQuitting()
|
||||
void stopSidecars().finally(() => app.exit(0))
|
||||
void stopSidecars().finally(() => app.quit())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import { stat } from "node:fs/promises"
|
||||
import { basename } from "node:path"
|
||||
import { basename, join } from "node:path"
|
||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"
|
||||
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./windows"
|
||||
import type { UpdaterController } from "./updater-controller"
|
||||
import { createUpdaterSubscriptions } from "./updater-subscriptions"
|
||||
import { createDesktopDraftStore } from "./draft-store"
|
||||
|
||||
const pickerFilters = (ext?: string[]) => {
|
||||
if (!ext || ext.length === 0) return undefined
|
||||
@@ -51,8 +52,12 @@ type Deps = {
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: Deps) {
|
||||
const drafts = createDesktopDraftStore(join(app.getPath("userData"), "drafts.sqlite"))
|
||||
const updaterSubscriptions = createUpdaterSubscriptions()
|
||||
app.once("will-quit", updaterSubscriptions.clear)
|
||||
app.on("before-quit", () => drafts.flush())
|
||||
app.once("will-quit", () => drafts.close())
|
||||
app.on("browser-window-created", (_event, win) => win.on("session-end", () => drafts.flush()))
|
||||
|
||||
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
||||
ipcMain.handle("await-initialization", () => deps.awaitInitialization())
|
||||
@@ -123,6 +128,14 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
const store = getStore(name)
|
||||
return Object.keys(store.store).length
|
||||
})
|
||||
ipcMain.handle("draft-get", (_event, key: string) => drafts.get(key))
|
||||
ipcMain.handle("draft-set", (_event, key: string, value: string) => drafts.set(key, value))
|
||||
ipcMain.handle("draft-delete", (_event, key: string) => drafts.set(key, null))
|
||||
ipcMain.handle("draft-blob-put", (_event, data: ArrayBuffer) => drafts.putBlob(new Uint8Array(data)))
|
||||
ipcMain.handle("draft-blob-get", (_event, id: string) => {
|
||||
const data = drafts.getBlob(id)
|
||||
return data ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) : null
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"open-directory-picker",
|
||||
|
||||
@@ -73,6 +73,11 @@ const api: ElectronAPI = {
|
||||
storeClear: (name) => ipcRenderer.invoke("store-clear", name),
|
||||
storeKeys: (name) => ipcRenderer.invoke("store-keys", name),
|
||||
storeLength: (name) => ipcRenderer.invoke("store-length", name),
|
||||
draftGet: (key) => ipcRenderer.invoke("draft-get", key),
|
||||
draftSet: (key, value) => ipcRenderer.invoke("draft-set", key, value),
|
||||
draftDelete: (key) => ipcRenderer.invoke("draft-delete", key),
|
||||
draftBlobPut: (data) => ipcRenderer.invoke("draft-blob-put", data),
|
||||
draftBlobGet: (id) => ipcRenderer.invoke("draft-blob-get", id),
|
||||
|
||||
getWindowID: () => ipcRenderer.invoke("get-window-id"),
|
||||
onMenuCommand: (cb) => {
|
||||
|
||||
@@ -63,6 +63,11 @@ export type ElectronAPI = {
|
||||
storeClear: (name: string) => Promise<void>
|
||||
storeKeys: (name: string) => Promise<string[]>
|
||||
storeLength: (name: string) => Promise<number>
|
||||
draftGet: (key: string) => Promise<string | null>
|
||||
draftSet: (key: string, value: string) => Promise<void>
|
||||
draftDelete: (key: string) => Promise<void>
|
||||
draftBlobPut: (data: ArrayBuffer) => Promise<string>
|
||||
draftBlobGet: (id: string) => Promise<ArrayBuffer | null>
|
||||
|
||||
getWindowID: () => Promise<string>
|
||||
onMenuCommand: (cb: (id: string) => void) => () => void
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type Locale,
|
||||
type Platform,
|
||||
PlatformProvider,
|
||||
createDraftStore,
|
||||
ServerConnection,
|
||||
useCommand,
|
||||
useWslServers,
|
||||
@@ -226,6 +227,13 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
|
||||
},
|
||||
|
||||
storage,
|
||||
draftStore: createDraftStore({
|
||||
get: window.api.draftGet,
|
||||
set: window.api.draftSet,
|
||||
remove: window.api.draftDelete,
|
||||
putBlob: (blob) => blob.arrayBuffer().then(window.api.draftBlobPut),
|
||||
getBlob: (id) => window.api.draftBlobGet(id).then((data) => data && new Blob([data])),
|
||||
}),
|
||||
|
||||
updater: {
|
||||
state: updaterState,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1218,10 +1218,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
|
||||
api: {
|
||||
id: model.id,
|
||||
url: model.provider?.api ?? provider.api ?? "",
|
||||
npm:
|
||||
model.provider?.shape === "responses"
|
||||
? "@ai-sdk/openai"
|
||||
: (model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible"),
|
||||
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
|
||||
},
|
||||
status: model.status ?? "active",
|
||||
headers: {},
|
||||
@@ -1440,13 +1437,11 @@ const layer = Layer.effect(
|
||||
const existingModel = parsed.models[model.id ?? modelID]
|
||||
const apiID = model.id ?? existingModel?.api.id ?? modelID
|
||||
const apiNpm =
|
||||
model.provider?.shape === "responses"
|
||||
? "@ai-sdk/openai"
|
||||
: (model.provider?.npm ??
|
||||
provider.npm ??
|
||||
existingModel?.api.npm ??
|
||||
modelsDev[providerID]?.npm ??
|
||||
"@ai-sdk/openai-compatible")
|
||||
model.provider?.npm ??
|
||||
provider.npm ??
|
||||
existingModel?.api.npm ??
|
||||
modelsDev[providerID]?.npm ??
|
||||
"@ai-sdk/openai-compatible"
|
||||
const name = iife(() => {
|
||||
if (model.name) return model.name
|
||||
if (model.id && model.id !== modelID) return modelID
|
||||
|
||||
@@ -1483,36 +1483,6 @@ test("models.dev normalization fills required response fields", () => {
|
||||
expect(model.release_date).toBe("")
|
||||
})
|
||||
|
||||
test("models.dev response-shaped models use the OpenAI Responses provider", () => {
|
||||
const provider = {
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
env: [],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
api: "https://api.deepseek.com",
|
||||
models: {
|
||||
"deepseek-v4-flash": {
|
||||
id: "deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash",
|
||||
provider: { shape: "responses" },
|
||||
limit: { context: 1_000_000, output: 384_000 },
|
||||
},
|
||||
"deepseek-v4-pro": {
|
||||
id: "deepseek-v4-pro",
|
||||
name: "DeepSeek V4 Pro",
|
||||
limit: { context: 1_000_000, output: 384_000 },
|
||||
},
|
||||
},
|
||||
} as unknown as ModelsDev.Provider
|
||||
|
||||
const models = Provider.fromModelsDevProvider(provider).models
|
||||
expect(models["deepseek-v4-flash"].api).toMatchObject({
|
||||
npm: "@ai-sdk/openai",
|
||||
url: "https://api.deepseek.com",
|
||||
})
|
||||
expect(models["deepseek-v4-pro"].api.npm).toBe("@ai-sdk/openai-compatible")
|
||||
})
|
||||
|
||||
test("models.dev reasoning options replace generated variants and unsupported toggles fall back", () => {
|
||||
const provider = {
|
||||
id: "reasoning",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -78,6 +78,7 @@ export type PromptInputV2AttachmentConfig = {
|
||||
onError: (error: unknown) => void
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
getPathForFile?: (file: File) => string
|
||||
store?: (file: File) => Promise<{ id: string; url: string }>
|
||||
}
|
||||
|
||||
export function createPromptInputV2Attachments(
|
||||
@@ -102,8 +103,7 @@ export function createPromptInputV2Attachments(
|
||||
if (toast) input.warn()
|
||||
return false
|
||||
}
|
||||
const url = await dataUrl(file, mime)
|
||||
if (!url) return false
|
||||
const blob = input.store ? await input.store(file) : await blobReference(file)
|
||||
const sourcePath = input.getPathForFile?.(file) || undefined
|
||||
// Native clipboard images arrive with a fresh timestamped filename on every paste, so identical
|
||||
// clipboard content is matched on bytes alone.
|
||||
@@ -112,7 +112,7 @@ export function createPromptInputV2Attachments(
|
||||
.some(
|
||||
(part) =>
|
||||
part.type === "image" &&
|
||||
part.dataUrl === url &&
|
||||
part.blob.id === blob.id &&
|
||||
(sourcePath
|
||||
? part.sourcePath === sourcePath
|
||||
: !part.sourcePath && (clipboard || part.filename === file.name)),
|
||||
@@ -127,7 +127,7 @@ export function createPromptInputV2Attachments(
|
||||
filename: file.name,
|
||||
sourcePath,
|
||||
mime,
|
||||
dataUrl: url,
|
||||
blob,
|
||||
}
|
||||
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
||||
return true
|
||||
@@ -219,20 +219,14 @@ export function createPromptInputV2Attachments(
|
||||
}
|
||||
}
|
||||
|
||||
function dataUrl(file: File, mime: string) {
|
||||
return new Promise<string>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("error", () => resolve(""))
|
||||
reader.addEventListener("load", () => {
|
||||
const value = typeof reader.result === "string" ? reader.result : ""
|
||||
const index = value.indexOf(",")
|
||||
resolve(index === -1 ? value : `data:${mime};base64,${value.slice(index + 1)}`)
|
||||
})
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
||||
|
||||
async function blobReference(file: File) {
|
||||
const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer())))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
return { id, url: URL.createObjectURL(file) }
|
||||
}
|
||||
const imageExtensions = new Map([
|
||||
["gif", "image/gif"],
|
||||
["jpeg", "image/jpeg"],
|
||||
|
||||
@@ -212,20 +212,20 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
onContext={props.controller.openContext}
|
||||
onShell={props.controller.openShell}
|
||||
/>
|
||||
<Show when={view.agent}>
|
||||
<Show when={view.agent} keyed>
|
||||
{(control) => (
|
||||
<PromptInputV2ConfiguredSelect title="Choose agent" keybind={["Mod", "."]} control={control()} />
|
||||
<PromptInputV2ConfiguredSelect title="Choose agent" keybind={["Mod", "."]} control={control} />
|
||||
)}
|
||||
</Show>
|
||||
<Show
|
||||
when={props.modelControl}
|
||||
fallback={
|
||||
<Show when={view.model}>
|
||||
<Show when={view.model} keyed>
|
||||
{(control) => (
|
||||
<PromptInputV2ConfiguredSelect
|
||||
title="Choose model"
|
||||
keybind={["Mod", "M"]}
|
||||
control={control()}
|
||||
control={control}
|
||||
model
|
||||
/>
|
||||
)}
|
||||
@@ -234,13 +234,13 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
>
|
||||
{props.modelControl}
|
||||
</Show>
|
||||
<Show when={(props.variantControlVisible ?? true) && view.variant}>
|
||||
<Show when={(props.variantControlVisible ?? true) && view.variant} keyed>
|
||||
{(control) => (
|
||||
<Show when={control().options().length > 1}>
|
||||
<Show when={control.options().length > 1}>
|
||||
<PromptInputV2ConfiguredSelect
|
||||
title="Choose model variant"
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control()}
|
||||
control={control}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
@@ -425,7 +425,7 @@ export function PromptInputV2Attachments(props: {
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
src={attachment.blob.url}
|
||||
alt={attachment.filename}
|
||||
class="w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
onClick={() => props.onAttachmentClick?.(attachment)}
|
||||
|
||||
@@ -121,7 +121,7 @@ function ControlledPromptInput() {
|
||||
id: "attachment-1",
|
||||
filename: "requirements.md",
|
||||
mime: "text/markdown",
|
||||
dataUrl: "data:text/markdown;base64,IyBSZXF1aXJlbWVudHM=",
|
||||
blob: { id: "requirements", url: "data:text/markdown;base64,IyBSZXF1aXJlbWVudHM=" },
|
||||
},
|
||||
],
|
||||
cursor: 0,
|
||||
@@ -199,7 +199,7 @@ function ControlledPromptInput() {
|
||||
id: `attachment-${store.state.prompt.filter((part) => part.type === "image").length + 1}`,
|
||||
filename,
|
||||
mime,
|
||||
dataUrl: `data:${mime};base64,`,
|
||||
blob: { id: filename, url: `data:${mime};base64,` },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ function createPromptStore() {
|
||||
id: "attachment-1",
|
||||
filename: "notes.txt",
|
||||
mime: "text/plain",
|
||||
dataUrl: "data:text/plain;base64,",
|
||||
blob: { id: "a", url: "blob:a" },
|
||||
},
|
||||
],
|
||||
cursor: 3,
|
||||
@@ -50,7 +50,7 @@ describe("prompt input v2 store", () => {
|
||||
id: "attachment-1",
|
||||
filename: "notes.txt",
|
||||
mime: "text/plain",
|
||||
dataUrl: "data:text/plain;base64,",
|
||||
blob: { id: "a", url: "blob:a" },
|
||||
},
|
||||
])
|
||||
expect(prompt.state.cursor).toBe(7)
|
||||
|
||||
@@ -31,7 +31,7 @@ export type PromptInputV2Attachment = {
|
||||
filename: string
|
||||
sourcePath?: string
|
||||
mime: string
|
||||
dataUrl: string
|
||||
blob: { id: string; url: string }
|
||||
}
|
||||
|
||||
export type PromptInputV2Prompt = (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.18.10",
|
||||
"version": "1.18.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.10913 12.07C3.65512 12.07 5.76627 11.5988 6.85825 10.98C7.95023 10.3612 7.95023 10.3612 10.207 8.75965C13.0642 6.73196 15.0845 7.41088 18.3968 7.41088" fill="currentColor"/>
|
||||
<path d="M3.10913 12.07C3.65512 12.07 5.76627 11.5988 6.85825 10.98C7.95023 10.3612 7.95023 10.3612 10.207 8.75965C13.0642 6.73196 15.0845 7.41088 18.3968 7.41088" stroke="currentColor" stroke-width="3.27593"/>
|
||||
<path d="M21.6 7.43108L16.0037 10.6622V4.20001L21.6 7.43108Z" fill="currentColor" stroke="currentColor" stroke-width="0.0363992"/>
|
||||
<path d="M3 12.072C3.54599 12.072 5.65714 12.5432 6.74912 13.162C7.8411 13.7808 7.8411 13.7808 10.0978 15.3823C12.9551 17.41 14.9753 16.7311 18.2877 16.7311" fill="currentColor"/>
|
||||
<path d="M3 12.072C3.54599 12.072 5.65714 12.5432 6.74912 13.162C7.8411 13.7808 7.8411 13.7808 10.0978 15.3823C12.9551 17.41 14.9753 16.7311 18.2877 16.7311" stroke="currentColor" stroke-width="3.27593"/>
|
||||
<path d="M21.4909 16.7109L15.8945 13.4798V19.942L21.4909 16.7109Z" fill="currentColor" stroke="currentColor" stroke-width="0.0363992"/>
|
||||
<path d="M728.039 234C819.325 234 893.323 308.62 893.323 400.668C893.323 492.716 819.325 567.336 728.039 567.336L891.984 732.656C912.81 753.655 898.061 789.56 868.613 789.56H397.472C245.333 789.56 122 665.193 122 511.78C122 358.367 245.333 234 397.472 234H728.039ZM397.472 345.112C306.189 345.112 232.189 419.732 232.189 511.78C232.189 603.828 306.189 678.448 397.472 678.448C488.756 678.448 562.756 603.828 562.756 511.78C562.756 419.732 488.756 345.112 397.472 345.112Z" fill="currentColor" transform="scale(0.0234375)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 627 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user