mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4219eb9a7 |
@@ -322,7 +322,6 @@ jobs:
|
||||
working-directory: packages/desktop
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -96,7 +96,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -144,7 +144,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"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.14",
|
||||
"version": "1.18.13",
|
||||
"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.14",
|
||||
"version": "1.18.13",
|
||||
"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.14",
|
||||
"version": "1.18.13",
|
||||
"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.14",
|
||||
"version": "1.18.13",
|
||||
"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.14",
|
||||
"version": "1.18.13",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -321,6 +321,7 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.1",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
@@ -380,7 +381,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"drizzle-orm": "catalog:",
|
||||
@@ -434,7 +435,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -446,9 +447,21 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -469,7 +482,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
@@ -481,7 +493,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -497,7 +509,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -528,7 +540,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -547,7 +559,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -678,7 +690,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -754,7 +766,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -769,7 +781,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -784,7 +796,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||
@@ -824,7 +836,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -837,7 +849,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -871,7 +883,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -890,7 +902,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -932,7 +944,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -959,7 +971,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1010,7 +1022,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1066,7 +1078,6 @@
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -1956,6 +1967,8 @@
|
||||
|
||||
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
|
||||
|
||||
"@opencode-ai/effect-sqlite-node": ["@opencode-ai/effect-sqlite-node@workspace:packages/effect-sqlite-node"],
|
||||
|
||||
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
|
||||
|
||||
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=",
|
||||
"aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=",
|
||||
"aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=",
|
||||
"x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU="
|
||||
"x86_64-linux": "sha256-GRjnvvyj37H36RqiCB7dz5ALAEwvw16izwuk1wsHEpU=",
|
||||
"aarch64-linux": "sha256-0OIn1o6dpqIQ5XgIMzpenMCMqsYzraaYVJk+te5eINU=",
|
||||
"aarch64-darwin": "sha256-sQSQcuox78d8wT1lsKYHqVBl11NusiszQ+gu2XYXZi8=",
|
||||
"x86_64-darwin": "sha256-SINkhMRd4oM1zABSs1Uf3OAzxJ1lgcycl1U4fmi+baY="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -158,7 +158,6 @@
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch"
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -5,15 +5,12 @@ import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { findLast } from "@opencode-ai/core/util/array"
|
||||
import { same } from "@/utils/same"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
@@ -223,31 +220,6 @@ export function SessionContextTab() {
|
||||
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
||||
] satisfies { label: string; value: () => JSX.Element }[]
|
||||
|
||||
const exportSession = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
try {
|
||||
const data = await fetchSessionExport({
|
||||
sessionID,
|
||||
client: sdk().client,
|
||||
})
|
||||
const filename = sessionExportFilename(data.info)
|
||||
downloadSessionExport(filename, data)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("toast.session.export.success.title"),
|
||||
description: language.t("toast.session.export.success.description", { filename }),
|
||||
})
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.export.failed.title"),
|
||||
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
@@ -356,18 +328,7 @@ export function SessionContextTab() {
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="gap-1.5 px-2 text-text-weak hover:text-text-base"
|
||||
onClick={exportSession}
|
||||
>
|
||||
<Icon name="download" size="small" />
|
||||
<span>{language.t("context.export.session")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
||||
<Accordion multiple>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
|
||||
@@ -95,8 +95,6 @@ export const dict = {
|
||||
"command.session.share.description": "Share this session and copy the URL to clipboard",
|
||||
"command.session.unshare": "Unshare session",
|
||||
"command.session.unshare.description": "Stop sharing this session",
|
||||
"command.session.export": "Export session",
|
||||
"command.session.export.description": "Export the full session transcript as JSON",
|
||||
|
||||
"palette.search.placeholder": "Search files, commands, and sessions",
|
||||
"palette.search.placeholder.home": "Search commands and sessions",
|
||||
@@ -491,7 +489,6 @@ export const dict = {
|
||||
|
||||
"context.systemPrompt.title": "System Prompt",
|
||||
"context.rawMessages.title": "Raw messages",
|
||||
"context.export.session": "Export session",
|
||||
|
||||
"context.stats.session": "Session",
|
||||
"context.stats.messages": "Messages",
|
||||
@@ -571,11 +568,6 @@ export const dict = {
|
||||
"toast.session.unshare.failed.title": "Failed to unshare session",
|
||||
"toast.session.unshare.failed.description": "An error occurred while unsharing the session",
|
||||
|
||||
"toast.session.export.success.title": "Session exported",
|
||||
"toast.session.export.success.description": "Saved session to {{filename}}",
|
||||
"toast.session.export.failed.title": "Failed to export session",
|
||||
"toast.session.export.failed.description": "An error occurred while exporting the session",
|
||||
|
||||
"toast.session.listFailed.title": "Failed to load sessions for {{project}}",
|
||||
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
|
||||
|
||||
@@ -810,7 +802,6 @@ export const dict = {
|
||||
"common.moreOptions": "More options",
|
||||
"common.learnMore": "Learn more",
|
||||
"common.rename": "Rename",
|
||||
"common.export": "Export",
|
||||
"common.reset": "Reset",
|
||||
"common.archive": "Archive",
|
||||
"common.delete": "Delete",
|
||||
|
||||
@@ -53,7 +53,6 @@ import type {
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { normalize } from "@opencode-ai/session-ui/session-diff"
|
||||
@@ -807,29 +806,6 @@ export function MessageTimeline(props: {
|
||||
navigate(`/${params.dir}/session`)
|
||||
}
|
||||
|
||||
const exportSession = async (sessionID: string) => {
|
||||
try {
|
||||
const data = await fetchSessionExport({
|
||||
sessionID,
|
||||
client: sdk().client,
|
||||
})
|
||||
const filename = sessionExportFilename(data.info)
|
||||
downloadSessionExport(filename, data)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("toast.session.export.success.title"),
|
||||
description: language.t("toast.session.export.success.description", { filename }),
|
||||
})
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.export.failed.title"),
|
||||
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const archiveSession = async (sessionID: string) => {
|
||||
const session = sync().session.get(sessionID)
|
||||
if (!session) return
|
||||
@@ -1588,9 +1564,6 @@ export function MessageTimeline(props: {
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Item onSelect={() => exportSession(id)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.export")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
@@ -1662,9 +1635,6 @@ export function MessageTimeline(props: {
|
||||
{language.t("session.share.action.share")}...
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item onSelect={() => exportSession(id)}>
|
||||
{language.t("common.export")}...
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
||||
{language.t("common.archive")}
|
||||
</MenuV2.Item>
|
||||
|
||||
@@ -12,11 +12,10 @@ import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { findLast } from "@opencode-ai/core/util/array"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { Message, Part, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionOwnership } from "./session-ownership"
|
||||
import { useLocal } from "@/context/local"
|
||||
@@ -232,31 +231,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
)
|
||||
}
|
||||
|
||||
const exportSession = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
try {
|
||||
const data = await fetchSessionExport({
|
||||
sessionID,
|
||||
client: sdk().client,
|
||||
})
|
||||
const filename = sessionExportFilename(data.info)
|
||||
downloadSessionExport(filename, data)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("toast.session.export.success.title"),
|
||||
description: language.t("toast.session.export.success.description", { filename }),
|
||||
})
|
||||
} catch (err) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.export.failed.title"),
|
||||
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openFile = () => {
|
||||
void openDialog(
|
||||
() => import("@/components/dialog-select-file"),
|
||||
@@ -484,14 +458,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: fork,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.export",
|
||||
title: language.t("command.session.export"),
|
||||
description: language.t("command.session.export.description"),
|
||||
slash: "export",
|
||||
disabled: !params.id,
|
||||
onSelect: exportSession,
|
||||
}),
|
||||
]
|
||||
|
||||
const fileCmds = () => {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { fetchSessionExport, sessionExportFilename } from "./session-export"
|
||||
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
describe("sessionExportFilename", () => {
|
||||
test("generates filename from title", () => {
|
||||
expect(sessionExportFilename({ id: "ses_123", title: "Clone PR in worktree from fork" })).toBe(
|
||||
"clone-pr-in-worktree-from-fork.json",
|
||||
)
|
||||
})
|
||||
|
||||
test("generates filename from slug when title missing", () => {
|
||||
expect(sessionExportFilename({ id: "ses_123", slug: "my-session-slug" })).toBe("my-session-slug.json")
|
||||
})
|
||||
|
||||
test("falls back to id when title and slug are empty", () => {
|
||||
expect(sessionExportFilename({ id: "ses_123" })).toBe("ses_123.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchSessionExport", () => {
|
||||
test("fetches full transcript from client", async () => {
|
||||
const session = { id: "ses_1", title: "Test Session" } as Session
|
||||
const msg = { id: "msg_1", role: "user" } as Message
|
||||
const part = { id: "prt_1", type: "text", text: "hello" } as Part
|
||||
const messages = [{ info: msg, parts: [part] }]
|
||||
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: session }),
|
||||
messages: async () => ({ data: messages }),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await fetchSessionExport({
|
||||
sessionID: "ses_1",
|
||||
client,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
info: session,
|
||||
messages,
|
||||
})
|
||||
})
|
||||
|
||||
test("throws when session not found", async () => {
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: null }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
expect(
|
||||
fetchSessionExport({
|
||||
sessionID: "ses_missing",
|
||||
client,
|
||||
}),
|
||||
).rejects.toThrow("Session not found: ses_missing")
|
||||
})
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
// Matches the exact `{ info, messages: [{ info, parts }] }` structure produced by `opencode export` CLI
|
||||
export type SessionExportData = {
|
||||
info: Session
|
||||
messages: {
|
||||
info: Message
|
||||
parts: Part[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export type SessionExportClient = {
|
||||
session: {
|
||||
get: (input: { sessionID: string }) => Promise<{ data?: Session | null }>
|
||||
messages: (input: { sessionID: string }) => Promise<{ data?: SessionExportData["messages"] | null }>
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSessionExport(input: {
|
||||
sessionID: string
|
||||
client: SessionExportClient
|
||||
}): Promise<SessionExportData> {
|
||||
const [sessionRes, messagesRes] = await Promise.all([
|
||||
input.client.session.get({ sessionID: input.sessionID }),
|
||||
input.client.session.messages({ sessionID: input.sessionID }),
|
||||
])
|
||||
|
||||
if (!sessionRes?.data) {
|
||||
throw new Error(`Session not found: ${input.sessionID}`)
|
||||
}
|
||||
if (!messagesRes?.data) {
|
||||
throw new Error(`Failed to load messages for session: ${input.sessionID}`)
|
||||
}
|
||||
|
||||
return {
|
||||
info: sessionRes.data,
|
||||
messages: messagesRes.data,
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionExportFilename(session: { id: string; title?: string; slug?: string }) {
|
||||
const name = session.title || session.slug || session.id
|
||||
const clean = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/gi, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
return `${clean || session.id}.json`
|
||||
}
|
||||
|
||||
export function downloadSessionExport(filename: string, data: unknown) {
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([json], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"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.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -87,24 +87,7 @@ const createSetupIntent = async (input: { plan: string; workspaceID: string }) =
|
||||
return { error: formError.alreadySubscribed }
|
||||
}
|
||||
|
||||
let customerID = customer?.customerID
|
||||
if (!customerID) {
|
||||
const customer = await Billing.stripe().customers.create({
|
||||
email,
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
})
|
||||
customerID = customer.id
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
customerID,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID)),
|
||||
)
|
||||
}
|
||||
const customerID = await Billing.ensureCustomer(email)
|
||||
|
||||
const intent = await Billing.stripe().setupIntents.create({
|
||||
customer: customerID,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -42,6 +42,39 @@ export namespace Billing {
|
||||
)
|
||||
}
|
||||
|
||||
export const ensureCustomer = async (email?: string) => {
|
||||
const billing = await get()
|
||||
if (billing?.customerID) return billing.customerID
|
||||
|
||||
const workspaceID = Actor.workspace()
|
||||
const stripe = Billing.stripe()
|
||||
const created = await stripe.customers.create(
|
||||
{
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
},
|
||||
{
|
||||
idempotencyKey: `opencode-workspace-customer:${workspaceID}`,
|
||||
},
|
||||
)
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
customerID: created.id,
|
||||
})
|
||||
.where(and(eq(BillingTable.workspaceID, workspaceID), isNull(BillingTable.customerID))),
|
||||
)
|
||||
|
||||
const customerID = (await get())?.customerID
|
||||
if (!customerID) throw new Error(`Workspace with ID ${workspaceID} not found`)
|
||||
if (customerID === created.id && email) {
|
||||
await stripe.customers.update(customerID, { email })
|
||||
}
|
||||
return customerID
|
||||
}
|
||||
|
||||
export const payments = async () => {
|
||||
return await Database.use((tx) =>
|
||||
tx
|
||||
@@ -231,8 +264,9 @@ export namespace Billing {
|
||||
}
|
||||
|
||||
const email = await User.getAuthEmail(user.properties.userID)
|
||||
const customer = await Billing.get()
|
||||
const amountInCents = (amount ?? customer.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
|
||||
const billing = await Billing.get()
|
||||
const customerID = await Billing.ensureCustomer(email ?? undefined)
|
||||
const amountInCents = (amount ?? billing.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
|
||||
const session = await Billing.stripe().checkout.sessions.create({
|
||||
mode: "payment",
|
||||
billing_address_collection: "required",
|
||||
@@ -254,18 +288,11 @@ export namespace Billing {
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
...(customer.customerID
|
||||
? {
|
||||
customer: customer.customerID,
|
||||
customer_update: {
|
||||
name: "auto",
|
||||
address: "auto",
|
||||
},
|
||||
}
|
||||
: {
|
||||
customer_email: email!,
|
||||
customer_creation: "always",
|
||||
}),
|
||||
customer: customerID,
|
||||
customer_update: {
|
||||
name: "auto",
|
||||
address: "auto",
|
||||
},
|
||||
currency: "usd",
|
||||
invoice_creation: {
|
||||
enabled: true,
|
||||
@@ -311,6 +338,7 @@ export namespace Billing {
|
||||
|
||||
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
|
||||
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
|
||||
const customerID = await Billing.ensureCustomer(email)
|
||||
|
||||
const coupons = await Database.use((tx) =>
|
||||
tx
|
||||
@@ -335,17 +363,11 @@ export namespace Billing {
|
||||
Billing.stripe().checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
discounts: coupon ? [{ coupon }] : undefined,
|
||||
...(billing.customerID
|
||||
? {
|
||||
customer: billing.customerID,
|
||||
customer_update: {
|
||||
name: "auto",
|
||||
address: "auto",
|
||||
},
|
||||
}
|
||||
: {
|
||||
customer_email: email,
|
||||
}),
|
||||
customer: customerID,
|
||||
customer_update: {
|
||||
name: "auto",
|
||||
address: "auto",
|
||||
},
|
||||
...(() => {
|
||||
if (method === "alipay") {
|
||||
return {
|
||||
@@ -411,14 +433,14 @@ export namespace Billing {
|
||||
|
||||
// get pending payment intent
|
||||
const intents = await Billing.stripe().paymentIntents.search({
|
||||
query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${billing.customerID}'`,
|
||||
query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${customerID}'`,
|
||||
})
|
||||
if (intents.data.length === 0) throw e
|
||||
|
||||
for (const intent of intents.data) {
|
||||
// get checkout session
|
||||
const sessions = await Billing.stripe().checkout.sessions.list({
|
||||
customer: billing.customerID!,
|
||||
customer: customerID,
|
||||
payment_intent: intent.id,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"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.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -90,6 +90,7 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.1",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as BackgroundJob from "./background-job"
|
||||
|
||||
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
|
||||
import { JobID } from "@opencode-ai/schema/job-id"
|
||||
import { Identifier } from "./id/id"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
|
||||
export type Status = "running" | "completed" | "error" | "cancelled"
|
||||
@@ -202,7 +202,7 @@ export const make = Effect.gen(function* () {
|
||||
const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const id = input.id ?? JobID.create()
|
||||
const id = input.id ?? Identifier.ascending("job")
|
||||
const started_at = yield* Clock.currentTimeMillis
|
||||
const done = yield* Deferred.make<Info>()
|
||||
const promoted = yield* Deferred.make<Info>()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
File to save in: ~/.local/share/opencode/worktree/012780/location-layer-tiers/packages/core/src/effect/
|
||||
@@ -191,6 +191,8 @@ function sameBytes(left: Uint8Array, right: Uint8Array) {
|
||||
return left.every((byte, index) => byte === right[index])
|
||||
}
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,13 @@ const DARWIN_LIBRARY = [
|
||||
const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes", "/.fseventsd"]
|
||||
const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"]
|
||||
|
||||
/** Directory basenames to skip when scanning the home directory. */
|
||||
export function names(): ReadonlySet<string> {
|
||||
if (process.platform === "darwin") return new Set(DARWIN_HOME)
|
||||
if (process.platform === "win32") return new Set(WIN32_HOME)
|
||||
return new Set()
|
||||
}
|
||||
|
||||
/** Absolute paths that should never be watched, stated, or scanned. */
|
||||
export function paths(): string[] {
|
||||
if (process.platform === "darwin")
|
||||
|
||||
@@ -234,4 +234,6 @@ export const fffLayer = Layer.effect(
|
||||
|
||||
const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)))
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] })
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { create as createIdentifier } from "@opencode-ai/schema/identifier"
|
||||
|
||||
const prefixes = {
|
||||
job: "job",
|
||||
event: "evt",
|
||||
session: "ses",
|
||||
message: "msg",
|
||||
permission: "per",
|
||||
question: "que",
|
||||
part: "prt",
|
||||
pty: "pty",
|
||||
tool: "tool",
|
||||
workspace: "wrk",
|
||||
} as const
|
||||
|
||||
export function ascending(prefix: keyof typeof prefixes, given?: string) {
|
||||
return generateID(prefix, "ascending", given)
|
||||
}
|
||||
|
||||
export function descending(prefix: keyof typeof prefixes, given?: string) {
|
||||
return generateID(prefix, "descending", given)
|
||||
}
|
||||
|
||||
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
|
||||
if (!given) {
|
||||
return create(prefixes[prefix], direction)
|
||||
}
|
||||
|
||||
if (!given.startsWith(prefixes[prefix])) {
|
||||
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
|
||||
}
|
||||
return given
|
||||
}
|
||||
|
||||
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
|
||||
return prefix + "_" + createIdentifier(direction === "descending", timestamp)
|
||||
}
|
||||
|
||||
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
|
||||
export function timestamp(id: string): number {
|
||||
const prefix = id.split("_")[0]
|
||||
const hex = id.slice(prefix.length + 1, prefix.length + 13)
|
||||
const encoded = BigInt("0x" + hex)
|
||||
return Number(encoded / BigInt(0x1000))
|
||||
}
|
||||
|
||||
export * as Identifier from "./id"
|
||||
@@ -76,4 +76,6 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
|
||||
|
||||
@@ -153,6 +153,8 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer.pipe(Layer.orDie),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export * as LayerMapExample from "./layer-map.example"
|
||||
|
||||
import { Context, Effect, Layer, LayerMap } from "effect"
|
||||
import { Npm } from "../npm"
|
||||
|
||||
/**
|
||||
* Tutorial: split global services from context-specific services.
|
||||
*
|
||||
* Use this pattern when part of the app should be constructed once at the app edge,
|
||||
* while another part should be cached per request/project/workspace key.
|
||||
*
|
||||
* In this example:
|
||||
* - Npm.Service is the global service. It is not keyed by request context and should
|
||||
* be provided once by the application runtime.
|
||||
* - ConfigService is context-specific. It is built from a RequestContext key and is
|
||||
* cached by LayerMap for that key.
|
||||
* - ConfigServiceMap.layer owns the cache. Provide it once globally, then each
|
||||
* request can provide ConfigServiceMap.get(context) to select the right instance.
|
||||
*
|
||||
* Lifetime model:
|
||||
* - ConfigServiceMap.layer has the app/global lifetime and depends on Npm.Service.
|
||||
* - ConfigServiceMap.get(context) has the request/context lifetime and provides
|
||||
* ConfigService for exactly that context key.
|
||||
* - The cached ConfigService entry stays alive while something is using it. Once idle,
|
||||
* it remains cached for idleTimeToLive, then its scope is finalized.
|
||||
* - invalidate(context) removes the cache entry for future lookups. Active users keep
|
||||
* running on the old instance; the next lookup can create a fresh instance.
|
||||
*
|
||||
* Key model:
|
||||
* - Keys can be strings, structs, classes, arrays, etc.
|
||||
* - Prefer primitive or immutable keys. Effect uses Hash / Equal semantics for cache
|
||||
* lookup, so mutating an object after it has been used as a key is a bug.
|
||||
*/
|
||||
|
||||
export type RequestContext = {
|
||||
readonly directory: string
|
||||
readonly workspace: string
|
||||
}
|
||||
|
||||
export class RequestContextRef extends Context.Service<RequestContextRef, RequestContext>()(
|
||||
"@opencode/example/RequestContextRef",
|
||||
) {}
|
||||
|
||||
export interface ConfigServiceShape {
|
||||
readonly directory: string
|
||||
readonly workspace: string
|
||||
readonly nextUse: () => Effect.Effect<number>
|
||||
readonly which: Npm.Interface["which"]
|
||||
}
|
||||
|
||||
export class ConfigService extends Context.Service<ConfigService, ConfigServiceShape>()(
|
||||
"@opencode/example/ConfigService",
|
||||
) {}
|
||||
|
||||
const configServiceLayer = Layer.effect(
|
||||
ConfigService,
|
||||
Effect.gen(function* () {
|
||||
const context = yield* RequestContextRef
|
||||
const npm = yield* Npm.Service
|
||||
|
||||
let useCount = 0
|
||||
|
||||
return ConfigService.of({
|
||||
directory: context.directory,
|
||||
workspace: context.workspace,
|
||||
nextUse: () => Effect.succeed(++useCount),
|
||||
which: npm.which,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export class ConfigServiceMap extends LayerMap.Service<ConfigServiceMap>()("@opencode/example/ConfigServiceMap", {
|
||||
lookup: (context: RequestContext) =>
|
||||
configServiceLayer.pipe(Layer.provide(Layer.succeed(RequestContextRef, RequestContextRef.of(context)))),
|
||||
idleTimeToLive: "5 minutes",
|
||||
}) {}
|
||||
|
||||
export const appLayer = ConfigServiceMap.layer
|
||||
|
||||
export const readConfig = Effect.fn("LayerMapExample.readConfig")(function* () {
|
||||
const config = yield* ConfigService
|
||||
|
||||
return {
|
||||
directory: config.directory,
|
||||
workspace: config.workspace,
|
||||
useCount: yield* config.nextUse(),
|
||||
}
|
||||
})
|
||||
|
||||
export const handleRequest = Effect.fn("LayerMapExample.handleRequest")(function* (context: RequestContext) {
|
||||
return yield* readConfig().pipe(Effect.provide(ConfigServiceMap.get(context)))
|
||||
})
|
||||
|
||||
export const invalidateContext = (context: RequestContext) => ConfigServiceMap.invalidate(context)
|
||||
@@ -278,6 +278,7 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
|
||||
@@ -313,4 +313,6 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Location.node, Config.node] })
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export * as PublicEventManifest from "./public-event-manifest"
|
||||
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
|
||||
export const Definitions = EventManifest.ServerDefinitions
|
||||
export const Latest = Event.latest(Definitions)
|
||||
@@ -148,4 +148,6 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||
|
||||
@@ -64,4 +64,6 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Reference.node] })
|
||||
|
||||
@@ -31,6 +31,14 @@ export type EnsureInput = {
|
||||
readonly branch?: string
|
||||
}
|
||||
|
||||
export class InvalidRepositoryError extends Schema.TaggedErrorClass<InvalidRepositoryError>()(
|
||||
"RepositoryCacheInvalidRepositoryError",
|
||||
{
|
||||
repository: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()(
|
||||
"RepositoryCacheInvalidBranchError",
|
||||
{
|
||||
@@ -78,6 +86,7 @@ export class CacheOperationError extends Schema.TaggedErrorClass<CacheOperationE
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| InvalidRepositoryError
|
||||
| InvalidBranchError
|
||||
| CloneFailedError
|
||||
| FetchFailedError
|
||||
@@ -92,8 +101,9 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/RepositoryCache") {}
|
||||
|
||||
function isError(error: unknown): error is Error {
|
||||
export function isError(error: unknown): error is Error {
|
||||
return (
|
||||
error instanceof InvalidRepositoryError ||
|
||||
error instanceof InvalidBranchError ||
|
||||
error instanceof CloneFailedError ||
|
||||
error instanceof FetchFailedError ||
|
||||
@@ -104,7 +114,14 @@ function isError(error: unknown): error is Error {
|
||||
)
|
||||
}
|
||||
|
||||
const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
|
||||
export const parseRemote = Effect.fn("RepositoryCache.parseRemote")(function* (repository: string) {
|
||||
return yield* Effect.try({
|
||||
try: () => Repository.parseRemote(repository),
|
||||
catch: (error) => new InvalidRepositoryError({ repository, message: errorMessage(error) }),
|
||||
})
|
||||
})
|
||||
|
||||
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
|
||||
return yield* Effect.try({
|
||||
try: () => Repository.validateBranch(branch),
|
||||
catch: (error) => new InvalidBranchError({ branch, message: errorMessage(error) }),
|
||||
|
||||
@@ -46,6 +46,14 @@ export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchErr
|
||||
|
||||
export type Error = InvalidReferenceError | UnsupportedLocalRepositoryError | InvalidBranchError
|
||||
|
||||
export function isError(error: unknown): error is Error {
|
||||
return (
|
||||
error instanceof InvalidReferenceError ||
|
||||
error instanceof UnsupportedLocalRepositoryError ||
|
||||
error instanceof InvalidBranchError
|
||||
)
|
||||
}
|
||||
|
||||
export function parse(input: string): Reference | undefined {
|
||||
const cleaned = normalizeInput(input)
|
||||
if (!cleaned) return
|
||||
|
||||
@@ -71,4 +71,6 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [SkillV2.node] })
|
||||
|
||||
@@ -227,6 +227,8 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
|
||||
@@ -2,12 +2,12 @@ export * as ToolOutputStore from "./tool-output-store"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
|
||||
import { ascending } from "@opencode-ai/schema/identifier"
|
||||
import { Config } from "./config"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { makeGlobalNode, makeLocationNode } from "./effect/app-node"
|
||||
import { SessionSchema } from "./session/schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import type { ToolOutput } from "@opencode-ai/llm"
|
||||
|
||||
export const MAX_LINES = 2_000
|
||||
@@ -127,7 +127,7 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const write = Effect.fn("ToolOutputStore.write")(function* (content: string) {
|
||||
const file = path.join(directory, `tool_${ascending()}`)
|
||||
const file = path.join(directory, `tool_${Identifier.ascending()}`)
|
||||
yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
|
||||
yield* fs
|
||||
.writeFileString(file, content, { flag: "wx" })
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * as Identifier from "@opencode-ai/schema/identifier"
|
||||
@@ -12,6 +12,12 @@ export function getDirectory(path: string | undefined) {
|
||||
return parts.slice(0, parts.length - 1).join("/") + "/"
|
||||
}
|
||||
|
||||
export function getFileExtension(path: string | undefined) {
|
||||
if (!path) return ""
|
||||
const parts = path.split(".")
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
export function getFilenameTruncated(path: string | undefined, maxLength: number = 20) {
|
||||
const filename = getFilename(path)
|
||||
if (filename.length <= maxLength) return filename
|
||||
@@ -21,3 +27,11 @@ export function getFilenameTruncated(path: string | undefined, maxLength: number
|
||||
if (available <= 0) return filename.slice(0, maxLength - 1) + "…"
|
||||
return filename.slice(0, available) + "…" + ext
|
||||
}
|
||||
|
||||
export function truncateMiddle(text: string, maxLength: number = 20) {
|
||||
if (text.length <= maxLength) return text
|
||||
const available = maxLength - 1 // -1 for ellipsis
|
||||
const start = Math.ceil(available / 2)
|
||||
const end = Math.floor(available / 2)
|
||||
return text.slice(0, start) + "…" + text.slice(-end)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * as V2Schema from "./v2-schema"
|
||||
|
||||
export { DateTimeUtcFromMillis } from "@opencode-ai/schema/schema"
|
||||
@@ -4,6 +4,7 @@ import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
@@ -104,6 +105,9 @@ describe("RepositoryCache", () => {
|
||||
withRemote((fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const invalidRepository = yield* Effect.flip(RepositoryCache.parseRemote("not-a-repo"))
|
||||
expect(invalidRepository).toBeInstanceOf(RepositoryCache.InvalidRepositoryError)
|
||||
|
||||
const invalidBranch = yield* Effect.flip(cache.ensure({ reference: fixture.reference, branch: "../unsafe" }))
|
||||
expect(invalidBranch).toBeInstanceOf(RepositoryCache.InvalidBranchError)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { Reference } from "@opencode-ai/schema/reference"
|
||||
import { SessionTodo } from "@opencode-ai/schema/session-todo"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { AbsolutePath, optional, statics } from "@opencode-ai/schema/schema"
|
||||
import { AbsolutePath, DateTimeUtcFromMillis, optional, statics } from "@opencode-ai/schema/schema"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
|
||||
@@ -51,6 +51,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
coreSessionTodo,
|
||||
corePrompt,
|
||||
coreSkill,
|
||||
coreV2Schema,
|
||||
coreSchema,
|
||||
coreWorkspace,
|
||||
] = await Promise.all([
|
||||
@@ -72,6 +73,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
import("@opencode-ai/core/session/todo"),
|
||||
import("@opencode-ai/core/session/prompt"),
|
||||
import("@opencode-ai/core/skill"),
|
||||
import("@opencode-ai/core/v2-schema"),
|
||||
import("@opencode-ai/core/schema"),
|
||||
import("@opencode-ai/core/workspace"),
|
||||
])
|
||||
@@ -171,6 +173,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[coreSkill.EmbeddedSource, Skill.EmbeddedSource],
|
||||
[coreSkill.Source, Skill.Source],
|
||||
[coreSkill.Info, Skill.Info],
|
||||
[coreV2Schema.DateTimeUtcFromMillis, DateTimeUtcFromMillis],
|
||||
[coreSchema.optional, optional],
|
||||
[coreSchema.statics, statics],
|
||||
[coreWorkspace.ID, Workspace.ID],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { $ } from "bun"
|
||||
import * as path from "node:path"
|
||||
|
||||
import { RUST_TARGET } from "./utils"
|
||||
|
||||
if (!RUST_TARGET) throw new Error("RUST_TARGET not defined")
|
||||
|
||||
const BUNDLE_DIR = "dist"
|
||||
const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles")
|
||||
|
||||
await $`mkdir -p ${BUNDLES_OUT_DIR}`
|
||||
await $`cp -r ${BUNDLE_DIR}/* ${BUNDLES_OUT_DIR}`
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.13",
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
export * as NodeSqliteClient from "./index"
|
||||
|
||||
import { DatabaseSync, type SQLInputValue } from "node:sqlite"
|
||||
import { identity } from "effect/Function"
|
||||
import * as Context from "effect/Context"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Fiber from "effect/Fiber"
|
||||
import * as Layer from "effect/Layer"
|
||||
import * as Scope from "effect/Scope"
|
||||
import * as Semaphore from "effect/Semaphore"
|
||||
import * as Stream from "effect/Stream"
|
||||
import * as Reactivity from "effect/unstable/reactivity/Reactivity"
|
||||
import * as Client from "effect/unstable/sql/SqlClient"
|
||||
import type { Connection } from "effect/unstable/sql/SqlConnection"
|
||||
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
|
||||
import * as Statement from "effect/unstable/sql/Statement"
|
||||
|
||||
const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
||||
|
||||
export const TypeId: TypeId = "~@opencode-ai/effect-sqlite-node/NodeSqliteClient"
|
||||
export type TypeId = "~@opencode-ai/effect-sqlite-node/NodeSqliteClient"
|
||||
|
||||
export interface SqliteClient extends Client.SqlClient {
|
||||
readonly [TypeId]: TypeId
|
||||
readonly config: SqliteClientConfig
|
||||
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
|
||||
readonly updateValues: never
|
||||
}
|
||||
|
||||
export const SqliteClient = Context.Service<SqliteClient>("@opencode-ai/effect-sqlite-node/NodeSqliteClient")
|
||||
|
||||
export interface SqliteClientConfig {
|
||||
readonly filename: string
|
||||
readonly readonly?: boolean | undefined
|
||||
readonly create?: boolean | undefined
|
||||
readonly readwrite?: boolean | undefined
|
||||
readonly disableWAL?: boolean | undefined
|
||||
readonly timeout?: number | undefined
|
||||
readonly allowExtension?: boolean | undefined
|
||||
readonly spanAttributes?: Record<string, unknown> | undefined
|
||||
readonly transformResultNames?: ((str: string) => string) | undefined
|
||||
readonly transformQueryNames?: ((str: string) => string) | undefined
|
||||
}
|
||||
|
||||
interface SqliteConnection extends Connection {
|
||||
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
|
||||
}
|
||||
|
||||
export const make = (
|
||||
options: SqliteClientConfig,
|
||||
): Effect.Effect<SqliteClient, never, Scope.Scope | Reactivity.Reactivity> =>
|
||||
Effect.gen(function* () {
|
||||
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
|
||||
const transformRows = options.transformResultNames
|
||||
? Statement.defaultTransforms(options.transformResultNames).array
|
||||
: undefined
|
||||
|
||||
const makeConnection = Effect.gen(function* () {
|
||||
const db = new DatabaseSync(options.filename, {
|
||||
readOnly: options.readonly,
|
||||
timeout: options.timeout,
|
||||
allowExtension: options.allowExtension,
|
||||
enableForeignKeyConstraints: true,
|
||||
open: true,
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => db.close()))
|
||||
|
||||
if (options.disableWAL !== true && options.readonly !== true) {
|
||||
db.exec("PRAGMA journal_mode = WAL;")
|
||||
}
|
||||
|
||||
const run = (sql: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
|
||||
const statement = db.prepare(sql)
|
||||
statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers))
|
||||
try {
|
||||
return Effect.succeed(statement.all(...(params as SQLInputValue[])) as Array<Record<string, unknown>>)
|
||||
} catch (cause) {
|
||||
return Effect.fail(
|
||||
new SqlError({
|
||||
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const runValues = (sql: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.withFiber<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>((fiber) => {
|
||||
const statement = db.prepare(sql)
|
||||
statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers))
|
||||
statement.setReturnArrays(true)
|
||||
try {
|
||||
return Effect.succeed(
|
||||
statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray<ReadonlyArray<unknown>>,
|
||||
)
|
||||
} catch (cause) {
|
||||
return Effect.fail(
|
||||
new SqlError({
|
||||
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return identity<SqliteConnection>({
|
||||
execute(sql, params, transformRows) {
|
||||
return transformRows ? Effect.map(run(sql, params), transformRows) : run(sql, params)
|
||||
},
|
||||
executeRaw(sql, params) {
|
||||
return run(sql, params)
|
||||
},
|
||||
executeValues(sql, params) {
|
||||
return runValues(sql, params)
|
||||
},
|
||||
executeUnprepared(sql, params, transformRows) {
|
||||
return this.execute(sql, params, transformRows)
|
||||
},
|
||||
executeStream() {
|
||||
return Stream.die("executeStream not implemented")
|
||||
},
|
||||
loadExtension: (path) =>
|
||||
Effect.try({
|
||||
try: () => db.loadExtension(path),
|
||||
catch: (cause) =>
|
||||
new SqlError({
|
||||
reason: classifySqliteError(cause, { message: "Failed to load extension", operation: "loadExtension" }),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
const semaphore = yield* Semaphore.make(1)
|
||||
const connection = yield* makeConnection
|
||||
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
|
||||
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
||||
const fiber = Fiber.getCurrent()!
|
||||
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
|
||||
return Effect.as(
|
||||
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
|
||||
connection,
|
||||
)
|
||||
})
|
||||
|
||||
return Object.assign(
|
||||
(yield* Client.make({
|
||||
acquirer,
|
||||
compiler,
|
||||
transactionAcquirer,
|
||||
spanAttributes: [
|
||||
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
||||
[ATTR_DB_SYSTEM_NAME, "sqlite"],
|
||||
],
|
||||
transformRows,
|
||||
})) as SqliteClient,
|
||||
{
|
||||
[TypeId]: TypeId as TypeId,
|
||||
config: options,
|
||||
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
export const layer = (config: SqliteClientConfig): Layer.Layer<SqliteClient | Client.SqlClient> =>
|
||||
Layer.effectContext(
|
||||
Effect.map(make(config), (client) =>
|
||||
Context.make(SqliteClient, client).pipe(Context.add(Client.SqlClient, client)),
|
||||
),
|
||||
).pipe(Layer.provide(Reactivity.layer))
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "@effect/language-service",
|
||||
"transform": "@effect/language-service/transform",
|
||||
"namespaceImportPackages": ["effect", "@effect/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -32,7 +32,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { descending } from "@opencode-ai/schema/identifier"
|
||||
import { SessionID } from "@opencode-ai/schema/session-id"
|
||||
import { Share } from "../../src/core/share"
|
||||
import { Storage } from "../../src/core/storage"
|
||||
import { Identifier } from "@opencode-ai/core/util/identifier"
|
||||
|
||||
describe.concurrent("core.share", () => {
|
||||
test("should create a share", async () => {
|
||||
const sessionID = SessionID.create()
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
|
||||
expect(share.sessionID).toBe(sessionID)
|
||||
@@ -16,7 +15,7 @@ describe.concurrent("core.share", () => {
|
||||
})
|
||||
|
||||
test("should remove a share as admin", async () => {
|
||||
const share = await Share.create({ sessionID: SessionID.create() })
|
||||
const share = await Share.create({ sessionID: Identifier.descending() })
|
||||
|
||||
await Share.removeAdmin({ id: share.id })
|
||||
|
||||
@@ -24,7 +23,7 @@ describe.concurrent("core.share", () => {
|
||||
})
|
||||
|
||||
test("should sync data to a share", async () => {
|
||||
const sessionID = SessionID.create()
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
|
||||
const data: Share.Data[] = [
|
||||
@@ -46,7 +45,7 @@ describe.concurrent("core.share", () => {
|
||||
})
|
||||
|
||||
test("should sync multiple batches of data", async () => {
|
||||
const sessionID = SessionID.create()
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
|
||||
const data1: Share.Data[] = [
|
||||
@@ -80,7 +79,7 @@ describe.concurrent("core.share", () => {
|
||||
})
|
||||
|
||||
test("should retrieve synced data", async () => {
|
||||
const sessionID = SessionID.create()
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
|
||||
const data: Share.Data[] = [
|
||||
@@ -109,7 +108,7 @@ describe.concurrent("core.share", () => {
|
||||
})
|
||||
|
||||
test("should retrieve data from multiple syncs", async () => {
|
||||
const sessionID = SessionID.create()
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
|
||||
const data1: Share.Data[] = [
|
||||
@@ -155,7 +154,7 @@ describe.concurrent("core.share", () => {
|
||||
})
|
||||
|
||||
test("should return latest data when syncing duplicate parts", async () => {
|
||||
const sessionID = SessionID.create()
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
|
||||
const data1: Share.Data[] = [
|
||||
@@ -193,7 +192,7 @@ describe.concurrent("core.share", () => {
|
||||
})
|
||||
|
||||
test("should return empty array for share with no data", async () => {
|
||||
const sessionID = SessionID.create()
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
|
||||
const result = await Share.data(share.id)
|
||||
@@ -204,7 +203,7 @@ describe.concurrent("core.share", () => {
|
||||
})
|
||||
|
||||
test("should migrate legacy event data into the snapshot", async () => {
|
||||
const sessionID = SessionID.create()
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
const data: Share.Data[] = [
|
||||
{
|
||||
@@ -214,7 +213,7 @@ describe.concurrent("core.share", () => {
|
||||
]
|
||||
|
||||
await Storage.remove(["share_snapshot", share.id])
|
||||
await Storage.write(["share_event", share.id, descending()], data)
|
||||
await Storage.write(["share_event", share.id, Identifier.descending()], data)
|
||||
|
||||
const result = await Share.data(share.id)
|
||||
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
|
||||
@@ -226,7 +225,7 @@ describe.concurrent("core.share", () => {
|
||||
})
|
||||
|
||||
test("should throw error for invalid secret", async () => {
|
||||
const sessionID = SessionID.create()
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
|
||||
const data: Share.Data[] = [
|
||||
@@ -247,7 +246,7 @@ describe.concurrent("core.share", () => {
|
||||
})
|
||||
|
||||
test("should throw error for non-existent share", async () => {
|
||||
const sessionID = SessionID.create()
|
||||
const sessionID = Identifier.descending()
|
||||
const data: Share.Data[] = [
|
||||
{
|
||||
type: "part",
|
||||
@@ -264,7 +263,7 @@ describe.concurrent("core.share", () => {
|
||||
})
|
||||
|
||||
test("should handle different data types", async () => {
|
||||
const sessionID = SessionID.create()
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
|
||||
const data: Share.Data[] = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"$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.14",
|
||||
"version": "1.18.13",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
# LLM Call Site Sketches
|
||||
|
||||
Scratchpad for examples first, abstractions second. Current direction: routes
|
||||
execute, provider facades organize configured route sets, and models carry route
|
||||
values directly.
|
||||
|
||||
## Conversation Summary
|
||||
|
||||
Kit and Aidan want provider-specific LLM behavior to move out of opencode's AI
|
||||
SDK transform path and into `packages/llm` where possible. The goal is not a big
|
||||
generic transform layer; the goal is small composable route definitions backed by
|
||||
recorded golden tests.
|
||||
|
||||
Things to keep testing against:
|
||||
|
||||
- Cache placement: `cache: "auto"`, manual cache breakpoints, provider cache usage.
|
||||
- Images: golden image tests for providers/protocols that claim image support.
|
||||
- Reasoning: canonical reasoning parts/events versus provider-native knobs.
|
||||
- Auth: bearer, custom headers, multiple credentials, query auth, SigV4, OAuth, no auth.
|
||||
- OpenAI-compatible providers: DeepSeek, Together, Groq, Alibaba/DashScope, custom routers.
|
||||
- Provider switching: stale signatures, encrypted reasoning, provider metadata, incompatible parts.
|
||||
- Error quality: typed errors instead of generic SDK/server failures.
|
||||
|
||||
## Final Guide: Routes Execute, Providers Organize
|
||||
|
||||
Do not introduce a first-class `Deployment` abstraction unless it gains real
|
||||
semantics. Provider facades are ergonomic configured route groups, not execution
|
||||
registries. The executable/composable thing is still a route. Do not make route
|
||||
construction publish to a global registry; models should carry their route value
|
||||
directly.
|
||||
|
||||
Keep durable identity separate from runtime capability:
|
||||
|
||||
- Durable identity is small serializable data like `{ providerID, modelID }` for
|
||||
config, sessions, logs, and catalogs.
|
||||
- Runtime capability is a `Model` with a route value, protocol, transport, auth,
|
||||
and defaults. It is allowed to contain functions and schemas.
|
||||
- If persisted identity needs to become executable, resolve it through an app
|
||||
boundary first. Do not make `LLMRequest` recover behavior from a global route
|
||||
side table.
|
||||
|
||||
Keep unconfigured behavior values as values, not factories. A transport like
|
||||
`HttpTransport.sseJson` should be a reusable immutable value. Use a function only
|
||||
when the caller supplies options or when construction needs fresh state.
|
||||
|
||||
Use constants to remove repetition before inventing abstractions. Provider ids
|
||||
are branded once per provider facade and reused across routes; a plain exported
|
||||
object is enough for the provider-facing API unless a helper earns its keep by
|
||||
removing repeated route projection.
|
||||
|
||||
Expose default configured provider instances, and put provider-specific setup on
|
||||
`.configure(...)`. Model selectors stay pure: `model(id)`, `responses(id)`,
|
||||
`chat(id)`, etc. Endpoint/auth/resource/api-version configuration happens before
|
||||
model selection, not as a second argument to model selection.
|
||||
|
||||
Use provider/product facades consistently:
|
||||
|
||||
- One coherent provider/product config surface gets one top-level facade.
|
||||
- APIs/model kinds that share that config are methods on the facade.
|
||||
- Different products with different required config get separate top-level
|
||||
facades, not a shared namespace with unrelated children.
|
||||
- Default facades are exposed only when concrete defaults or lazy env/credential
|
||||
defaults make the facade valid.
|
||||
|
||||
Examples:
|
||||
|
||||
```ts
|
||||
OpenAI.responses("gpt-4o")
|
||||
OpenAI.chat("gpt-4o")
|
||||
OpenAI.responsesWebSocket("gpt-4o")
|
||||
|
||||
Azure.configure({ resourceName, apiKey }).responses("my-deployment")
|
||||
AmazonBedrock.configure({ region, credentials }).model("anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
CloudflareAIGateway.configure({ accountId, gatewayId, gatewayApiKey, apiKey }).model("openai/gpt-4o")
|
||||
CloudflareWorkersAI.configure({ accountId, apiKey }).model("@cf/meta/llama-3.1-8b-instruct")
|
||||
|
||||
OpenAICompatible.configure({
|
||||
provider: "custom",
|
||||
baseURL: "https://custom.example/v1",
|
||||
auth: Auth.bearer(apiKey),
|
||||
}).model("custom-model")
|
||||
```
|
||||
|
||||
Standardize the provider facade contract before abstracting construction. A
|
||||
plain object is enough at first; add a helper only if repeated route projection
|
||||
starts hiding the real provider-specific config.
|
||||
|
||||
`Route.with(...)` patch semantics should be boring and explicit:
|
||||
|
||||
- Omitted fields inherit from the original route.
|
||||
- `endpoint` patches merge with the existing endpoint, so overriding `baseURL`
|
||||
keeps the existing `path`.
|
||||
- `endpoint.query` merges by default; later values win.
|
||||
- `auth` replaces.
|
||||
- `headers` merge by default; undefined values are omitted.
|
||||
- `id` is optional in patches. Route ids are diagnostic/provider API labels, not
|
||||
global runtime registry keys.
|
||||
|
||||
1. **Route**
|
||||
- route id
|
||||
- provider id
|
||||
- protocol
|
||||
- body schema
|
||||
- body builder
|
||||
- stream event schema
|
||||
- parser/state machine
|
||||
- transport
|
||||
- method / IO shape
|
||||
- framing
|
||||
- request preparation
|
||||
- constants when unconfigured; functions only when configured
|
||||
- endpoint
|
||||
- base URL
|
||||
- static path
|
||||
- body/model-derived path
|
||||
- query params
|
||||
- auth
|
||||
- bearer
|
||||
- custom header
|
||||
- multiple credentials
|
||||
- SigV4
|
||||
- none
|
||||
- defaults
|
||||
- headers
|
||||
- generation defaults
|
||||
- provider options
|
||||
- limits
|
||||
2. **Provider Facade**
|
||||
- default configured provider instance
|
||||
- provider-specific `.configure(...)`
|
||||
- plain object/function facade over one or more routes
|
||||
- top-level export only when it represents one coherent config surface
|
||||
- no passive `Provider.make(...)` wrapper unless it gains runtime behavior
|
||||
3. **Model Selector**
|
||||
- route/provider-owned selector
|
||||
- accepts model id only
|
||||
- returns executable models
|
||||
- does not accept endpoint/auth/deployment overrides
|
||||
4. **Model**
|
||||
- model id
|
||||
- route value
|
||||
- provider id
|
||||
- configured route value at selection time
|
||||
5. **LLM Request**
|
||||
- model
|
||||
- messages/tools
|
||||
- generation/cache/reasoning/response-format options
|
||||
- request-level HTTP overlays for per-request headers/query/body additions,
|
||||
not provider endpoint/auth reconfiguration
|
||||
6. **Compile**
|
||||
- read route from model
|
||||
- merge route defaults and request overrides
|
||||
- build final URL from route endpoint
|
||||
- apply auth from the configured route
|
||||
- build body with protocol
|
||||
- execute with transport and parse with protocol
|
||||
|
||||
## Provider Facade Shape
|
||||
|
||||
The provider abstraction is a facade over configured routes, not the runtime
|
||||
execution mechanism:
|
||||
|
||||
```ts
|
||||
type ProviderFacade<APIs, Config> = {
|
||||
readonly id: ProviderID
|
||||
readonly model: (id: string) => Model
|
||||
readonly configure: (input?: Config) => ProviderFacade<APIs, Config>
|
||||
} & APIs
|
||||
```
|
||||
|
||||
Manual construction is fine and should be the default until duplication earns a
|
||||
helper:
|
||||
|
||||
```ts
|
||||
export const OpenAI = {
|
||||
id: openAIProvider,
|
||||
model: openAIResponses.model,
|
||||
responses: openAIResponses.model,
|
||||
chat: openAIChat.model,
|
||||
configure: configureOpenAI,
|
||||
} satisfies ProviderFacade<
|
||||
{
|
||||
responses: (id: string) => Model
|
||||
chat: (id: string) => Model
|
||||
},
|
||||
OpenAIConfig
|
||||
>
|
||||
```
|
||||
|
||||
If several providers repeat the same projection from route values to model
|
||||
methods, the helper can stay deliberately tiny:
|
||||
|
||||
```ts
|
||||
const configureOpenAI = (input: OpenAIConfig = {}) =>
|
||||
Provider.define({
|
||||
id: openAIProvider,
|
||||
routes: {
|
||||
responses: openAIResponses.with(openAIConfig(input)),
|
||||
chat: openAIChat.with(openAIConfig(input)),
|
||||
},
|
||||
default: "responses",
|
||||
configure: configureOpenAI,
|
||||
})
|
||||
|
||||
export const OpenAI = configureOpenAI()
|
||||
```
|
||||
|
||||
`Provider.define(...)` would only project route methods and preserve types:
|
||||
|
||||
```ts
|
||||
OpenAI.model("gpt-4o")
|
||||
OpenAI.responses("gpt-4o")
|
||||
OpenAI.chat("gpt-4o")
|
||||
OpenAI.configure({ apiKey }).responses("gpt-4o")
|
||||
```
|
||||
|
||||
It must not register routes, select routes dynamically, or participate in
|
||||
execution. Execution still reads the route value carried by the model.
|
||||
|
||||
## Ideal Call Sites
|
||||
|
||||
Define concrete routes for a native provider, then project them through a
|
||||
provider facade:
|
||||
|
||||
```ts
|
||||
const openAIProvider = ProviderID.make("openai")
|
||||
|
||||
const openAIResponses = Route.make({
|
||||
id: "openai-responses",
|
||||
provider: openAIProvider,
|
||||
protocol: OpenAIResponses.protocol,
|
||||
transport: HttpTransport.sseJson,
|
||||
endpoint: {
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
path: "/responses",
|
||||
},
|
||||
auth: Auth.envBearer("OPENAI_API_KEY"),
|
||||
})
|
||||
|
||||
const openAIChat = Route.make({
|
||||
id: "openai-chat",
|
||||
provider: openAIProvider,
|
||||
protocol: OpenAIChat.protocol,
|
||||
transport: HttpTransport.sseJson,
|
||||
endpoint: {
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
path: "/chat/completions",
|
||||
},
|
||||
auth: Auth.envBearer("OPENAI_API_KEY"),
|
||||
})
|
||||
|
||||
const openAIResponsesWebSocket = openAIResponses.with({
|
||||
id: "openai-responses-websocket",
|
||||
transport: WebSocketTransport.json,
|
||||
})
|
||||
|
||||
const openAIConfig = (input: OpenAIConfig) => ({
|
||||
endpoint: input.endpoint,
|
||||
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
|
||||
headers: {
|
||||
"OpenAI-Organization": input.organization,
|
||||
"OpenAI-Project": input.project,
|
||||
},
|
||||
})
|
||||
|
||||
const configureOpenAI = (input: OpenAIConfig = {}) => {
|
||||
const responses = openAIResponses.with(openAIConfig(input))
|
||||
const responsesWebSocket = openAIResponsesWebSocket.with(openAIConfig(input))
|
||||
const chat = openAIChat.with(openAIConfig(input))
|
||||
|
||||
return {
|
||||
id: openAIProvider,
|
||||
responses: responses.model,
|
||||
responsesWebSocket: responsesWebSocket.model,
|
||||
chat: chat.model,
|
||||
model: responses.model,
|
||||
configure: configureOpenAI,
|
||||
}
|
||||
}
|
||||
|
||||
export const OpenAI = configureOpenAI()
|
||||
```
|
||||
|
||||
Specialize it functionally for concrete providers:
|
||||
|
||||
```ts
|
||||
const deepSeekProvider = ProviderID.make("deepseek")
|
||||
|
||||
const deepseekChat = openAIChat.with({
|
||||
id: "deepseek-chat",
|
||||
provider: deepSeekProvider,
|
||||
endpoint: {
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
},
|
||||
auth: Auth.envBearer("DEEPSEEK_API_KEY"),
|
||||
})
|
||||
|
||||
const configureDeepSeek = (input: OpenAICompatibleConfig = {}) => {
|
||||
const route = deepseekChat.with({
|
||||
endpoint: input.endpoint,
|
||||
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
|
||||
})
|
||||
|
||||
return {
|
||||
id: deepSeekProvider,
|
||||
model: route.model,
|
||||
configure: configureDeepSeek,
|
||||
}
|
||||
}
|
||||
|
||||
export const DeepSeek = {
|
||||
id: deepSeekProvider,
|
||||
model: deepseekChat.model,
|
||||
configure: configureDeepSeek,
|
||||
}
|
||||
```
|
||||
|
||||
Provider-specific configuration happens before model selection:
|
||||
|
||||
```ts
|
||||
const deepseek = DeepSeek.configure({
|
||||
endpoint: {
|
||||
baseURL: "https://proxy.example.com/v1",
|
||||
},
|
||||
auth: Auth.bearer(apiKey),
|
||||
})
|
||||
|
||||
const model = deepseek.model("deepseek-chat")
|
||||
```
|
||||
|
||||
Final request call site stays boring:
|
||||
|
||||
```ts
|
||||
const response =
|
||||
yield *
|
||||
LLM.generate(
|
||||
LLM.request({
|
||||
model: DeepSeek.model("deepseek-chat"),
|
||||
prompt: "Hello.",
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
HTTP versus WebSocket is represented as named route selectors, not as model or
|
||||
request overrides. Same protocol, different transport, different route:
|
||||
|
||||
```ts
|
||||
OpenAI.responses("gpt-4o")
|
||||
OpenAI.responsesWebSocket("gpt-4o")
|
||||
```
|
||||
|
||||
The client should not require a different public layer just because a selected
|
||||
route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime
|
||||
capabilities available; routes that do not need WebSocket simply never touch it.
|
||||
If a WebSocket route is selected in an environment without WebSocket support,
|
||||
fail with a typed transport configuration error.
|
||||
|
||||
Azure is a route specialization with auth/path/default changes plus input
|
||||
mapping. The public API configures the Azure resource once, then selects
|
||||
deployment ids with pure model selectors:
|
||||
|
||||
```ts
|
||||
const azureProvider = ProviderID.make("azure")
|
||||
|
||||
const azureResponses = openAIResponses.with({
|
||||
id: "azure-openai-responses",
|
||||
provider: azureProvider,
|
||||
auth: Auth.envHeader("api-key", "AZURE_OPENAI_API_KEY"),
|
||||
})
|
||||
|
||||
const configureAzure = (input: AzureConfig = {}) => {
|
||||
const route = azureResponses.with({
|
||||
endpoint: {
|
||||
baseURL:
|
||||
input.baseURL ??
|
||||
Endpoint.envBaseURL(
|
||||
"AZURE_RESOURCE_NAME",
|
||||
(resourceName) => `https://${resourceName}.openai.azure.com/openai/v1`,
|
||||
),
|
||||
query: { "api-version": input.apiVersion ?? "v1" },
|
||||
},
|
||||
auth: input.apiKey ? Auth.header("api-key", input.apiKey) : Auth.envHeader("api-key", "AZURE_OPENAI_API_KEY"),
|
||||
})
|
||||
|
||||
return {
|
||||
id: azureProvider,
|
||||
model: route.model,
|
||||
responses: route.model,
|
||||
configure: configureAzure,
|
||||
}
|
||||
}
|
||||
|
||||
export const Azure = configureAzure()
|
||||
|
||||
const azure = Azure.configure({
|
||||
resourceName: "my-resource",
|
||||
apiVersion: "v1",
|
||||
})
|
||||
|
||||
const model = azure.responses("my-deployment")
|
||||
```
|
||||
|
||||
Default provider facades are only valid when required configuration has a lazy
|
||||
default source. `Azure.responses("my-deployment")` can be valid if endpoint
|
||||
resolution reads `AZURE_RESOURCE_NAME` lazily and fails with a typed
|
||||
configuration error when missing. If a provider has no sensible lazy default,
|
||||
do not expose a default model selector; expose only a configured entrypoint.
|
||||
|
||||
Cloudflare AI Gateway and Workers AI are separate product facades because their
|
||||
configuration surfaces differ. Do not make a root `Cloudflare.configure(...)`
|
||||
pretend there is one coherent Cloudflare provider configuration:
|
||||
|
||||
```ts
|
||||
const cloudflareProvider = ProviderID.make("cloudflare-ai-gateway")
|
||||
|
||||
const cloudflareOpenAIChat = openAIChat.with({
|
||||
id: "cloudflare-ai-gateway-openai-chat",
|
||||
provider: cloudflareProvider,
|
||||
auth: Auth.bearerHeader("cf-aig-authorization").andThen(Auth.bearer()),
|
||||
})
|
||||
|
||||
const configureCloudflareAIGateway = (input: CloudflareAIGatewayConfig) => {
|
||||
const route = cloudflareOpenAIChat.with({
|
||||
endpoint: {
|
||||
baseURL: `https://gateway.ai.cloudflare.com/v1/${input.accountId}/${input.gatewayId}/openai`,
|
||||
},
|
||||
auth: Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey)),
|
||||
})
|
||||
|
||||
return {
|
||||
id: cloudflareProvider,
|
||||
model: (modelID: string) => route.model({ id: modelID }),
|
||||
configure: configureCloudflareAIGateway,
|
||||
}
|
||||
}
|
||||
|
||||
export const CloudflareAIGateway = {
|
||||
id: cloudflareProvider,
|
||||
configure: configureCloudflareAIGateway,
|
||||
}
|
||||
|
||||
const gateway = CloudflareAIGateway.configure({
|
||||
accountId: "account",
|
||||
gatewayId: "gateway",
|
||||
gatewayApiKey,
|
||||
apiKey,
|
||||
})
|
||||
|
||||
const model = gateway.model("openai/gpt-4o")
|
||||
```
|
||||
|
||||
If a Cloudflare product gains a full lazy env default, it can expose a direct
|
||||
selector too. Until then, omitting `CloudflareAIGateway.model(...)` makes missing
|
||||
account/gateway configuration unrepresentable.
|
||||
|
||||
opencode's dynamic runtime should construct executable models at its app
|
||||
boundary instead of exposing a giant unstructured public model constructor or a
|
||||
generic dynamic resolver:
|
||||
|
||||
```ts
|
||||
const model =
|
||||
providerID === "azure"
|
||||
? Azure.configure(resolvedAzureConfig).responses(apiModelID)
|
||||
: endpoint.websocket
|
||||
? OpenAI.responsesWebSocket(apiModelID)
|
||||
: OpenAI.responses(apiModelID)
|
||||
```
|
||||
|
||||
That boundary can branch on durable config/catalog metadata and call typed
|
||||
provider APIs directly. Transport selection belongs there too: map metadata like
|
||||
`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`; otherwise use
|
||||
the normal `OpenAI.responses(apiModelID)` route. The client runtime only executes
|
||||
the route carried by the model.
|
||||
|
||||
## Competitive Shape
|
||||
|
||||
This follows the strongest parts of adjacent libraries:
|
||||
|
||||
- AI SDK: configured provider instances expose provider-specific model methods.
|
||||
- Effect AI: executable models carry provider requirements and can be resolved by
|
||||
an app boundary.
|
||||
- LiteLLM/opencode config: dynamic `providerID/modelID` branching belongs at the
|
||||
app boundary, not in the typed public provider API or a global runtime
|
||||
resolver.
|
||||
- LangChain/LlamaIndex: constructor-style config plus model id is convenient,
|
||||
but we avoid making model selection also configure endpoint/auth.
|
||||
|
||||
The chosen split is:
|
||||
|
||||
```txt
|
||||
Route = execution mechanics
|
||||
Provider facade = configured route group
|
||||
Model = selected executable model carrying route value
|
||||
App boundary = explicit durable-config -> typed-provider call
|
||||
```
|
||||
|
||||
## What This Removes
|
||||
|
||||
- No `Provider.make(...)` as a core abstraction.
|
||||
- No `Provider.make(...)` wrapper just to bind an id to model functions. Use a
|
||||
branded provider id constant and a plain exported provider facade.
|
||||
- No `Deployment.define(...)` unless future examples force it.
|
||||
- No global route registry as the normal execution path.
|
||||
- No import side effects required before a model can execute.
|
||||
- No duplicate `provider.id` object when selected models already carry provider
|
||||
id.
|
||||
- No `model(id, overrides)` escape hatch. Model selection takes the model id;
|
||||
endpoint/auth/deployment customization happens by configuring the route first.
|
||||
- No transport override on model/request. HTTP SSE versus WebSocket is a named
|
||||
route selector such as `responses` versus `responsesWebSocket`.
|
||||
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
|
||||
client layer with the available transport capabilities.
|
||||
- No executable `ModelRef`. The executable handle is `Model`; durable model
|
||||
identity stays separate and cannot execute on its own.
|
||||
|
||||
## Implementation Todo
|
||||
|
||||
- [x] Replace the current executable `ModelRef` with `Model`.
|
||||
- [x] Change `Model.route` to carry a route value, not a `RouteID` string.
|
||||
- [ ] Keep a separate durable model identity type for persisted/session/catalog
|
||||
data, likely `{ providerID, modelID }`, and make it clear that it cannot
|
||||
execute without resolver context.
|
||||
- [x] Change route model selectors so `route.model(id)` returns an executable
|
||||
model with the route value attached, not a globally registered route id.
|
||||
- [x] Remove the standalone `Route.model(route, defaults, mapInput)` helper;
|
||||
configured route instances own model selection.
|
||||
- [x] Remove endpoint/auth escape hatches from route model selection; callers must
|
||||
configure endpoint/auth through `route.with(...)` or provider facades before
|
||||
calling `.model(...)`.
|
||||
- [x] Remove request-shaping defaults from `Model`; selected models now carry only
|
||||
id, provider, and configured route while defaults live on routes or requests.
|
||||
- [x] Rework `LLMClient.prepare` / `stream` / `generate` to read
|
||||
`request.model.route` directly instead of calling `registeredRoute(...)`.
|
||||
- [x] Remove `Route.make(...)` global registration from the normal execution
|
||||
path; keep route ids only as diagnostics/provider API labels.
|
||||
- [x] Model endpoint as `{ baseURL, path, query }` on routes, then remove the
|
||||
current split where host/query live on the model and path lives in route
|
||||
transport setup.
|
||||
- [x] Define `Route.with(...)` with explicit patch semantics for endpoint merge,
|
||||
query merge, header merge, auth replacement, and optional diagnostic id.
|
||||
- [x] Make unconfigured transports reusable constants such as
|
||||
`HttpTransport.sseJson`; keep transport functions only for configured/fresh
|
||||
state construction.
|
||||
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer`
|
||||
exposes available transport capabilities and selected routes fail with typed
|
||||
transport config errors when a required capability is missing.
|
||||
- [x] Convert OpenAI provider APIs to provider-facade shape:
|
||||
`OpenAI.configure(config).responses(id)`, `.chat(id)`, and
|
||||
`.responsesWebSocket(id)`.
|
||||
- [x] Convert Azure to a configured facade where resource/base URL/api version
|
||||
setup happens before selecting deployment ids.
|
||||
- [x] Split Cloudflare products into separate facades such as
|
||||
`CloudflareAIGateway` and `CloudflareWorkersAI`; do not expose a shared root
|
||||
config surface unless one product actually exists.
|
||||
- [x] Migrate remaining built-in provider facades one at a time so configuration
|
||||
happens before model selection and selectors accept only ids:
|
||||
xAI, GitHub Copilot, OpenRouter, OpenAI-compatible families, Anthropic,
|
||||
Google/Gemini, and Amazon Bedrock now use configured facades such as
|
||||
`Provider.configure(options).model(id)` with named selectors where needed.
|
||||
- [ ] Decide whether a tiny `Provider.define(...)` helper is warranted after two
|
||||
or three provider conversions; start with plain objects if duplication is not
|
||||
yet painful.
|
||||
- [x] Update `packages/opencode/src/session/llm/native-request.ts` to construct
|
||||
executable models at the session boundary with explicit provider facade
|
||||
calls, mapping catalog metadata such as `endpoint.websocket` to the correct
|
||||
named route selector.
|
||||
- [ ] Update tests so direct route/provider tests assert route values are carried
|
||||
by executable models, and opencode/native tests assert boundary-based route
|
||||
selection.
|
||||
- [ ] Remove compatibility exports or stale docs only after internal call sites
|
||||
are migrated; do not keep duplicate constructor paths without an external
|
||||
compatibility need.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Default facades with required setup: should providers like Azure and Bedrock
|
||||
expose default model selectors only when all required setup has lazy env or
|
||||
credential-chain defaults? If not, omit the default selector so missing config
|
||||
is impossible at the type/API level.
|
||||
- Lazy endpoint/auth values: should `Endpoint.envBaseURL(...)` and env-backed
|
||||
auth produce typed configuration/authentication errors at compile/prepare time
|
||||
or only when executing the transport?
|
||||
- `Route.with(...)` clearing semantics: endpoint/query/header patches merge by
|
||||
default, but what is the explicit way to remove an inherited value?
|
||||
- Provider facade helper: keep plain objects until duplication hurts, or add a
|
||||
tiny `Provider.define(...)` immediately to enforce shape and method projection?
|
||||
- Auth shape: should auth stay as today's composable `Auth`, or split into an
|
||||
auth placement/strategy and credential sources?
|
||||
- Naming: is `baseURL` still the right endpoint field name, or should it be
|
||||
`origin` / `urlPrefix` to clarify that route `path` is appended?
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -21,7 +21,6 @@ await Bun.build({
|
||||
external: ["jsonc-parser", "@lydell/node-pty"],
|
||||
define: {
|
||||
OPENCODE_MODELS_DEV: generated.modelsData,
|
||||
OPENCODE_VERSION: `'${Script.version}'`,
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
},
|
||||
files: {
|
||||
|
||||
@@ -9,7 +9,7 @@ let embeddedUIPromise: Promise<Record<string, string> | null> | undefined
|
||||
export const UI_UPSTREAM = new URL("https://app.opencode.ai")
|
||||
|
||||
export const csp = (hash = "") =>
|
||||
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:`
|
||||
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src * data:`
|
||||
export const DEFAULT_CSP = csp()
|
||||
|
||||
export function themePreloadHash(body: string) {
|
||||
|
||||
@@ -28,15 +28,6 @@ export const RETRY_BACKOFF_FACTOR = 2
|
||||
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
|
||||
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
|
||||
|
||||
const RETRYABLE_MESSAGE_PATTERNS = [
|
||||
/429|500|502|503|504|524/i,
|
||||
/rate increased too quickly|rate limit|rate-limit|rate_limit|too many requests/i,
|
||||
/overloaded|service unavailable|service_unavailable|service-unavailable|internal error|internal_error|internal server error|server error|server_error|server-error|provider returned error|provider_returned_error|provider-returned-error/i,
|
||||
/terminated|fetch failed|failed to fetch|network error|upstream connect|connection error|connection refused|connection lost|socket connection was closed|socket hang up|reset before headers|getaddrinfo|enotfound|eai_again|econnrefused|econnreset|etimedout/i,
|
||||
/^timeout$|\b(?:request|response|connection|network|stream|read) (?:timeout|timed out|time out)\b/i,
|
||||
/try your request again|retry your request|resource exhausted|resource_exhausted/i,
|
||||
]
|
||||
|
||||
function cap(ms: number) {
|
||||
return Math.min(ms, RETRY_MAX_DELAY)
|
||||
}
|
||||
@@ -81,13 +72,7 @@ export function retryable(error: Err, provider: string) {
|
||||
const status = error.data.statusCode
|
||||
// 5xx errors are transient server failures and should always be retried,
|
||||
// even when the provider SDK doesn't explicitly mark them as retryable.
|
||||
if (
|
||||
!error.data.isRetryable &&
|
||||
!(status !== undefined && status >= 500) &&
|
||||
!matchesRetryableMessage(error.data.message) &&
|
||||
!matchesRetryableMessage(error.data.responseBody)
|
||||
)
|
||||
return undefined
|
||||
if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined
|
||||
if (error.data.responseBody?.includes("FreeUsageLimitError")) {
|
||||
return {
|
||||
message: GO_UPSELL_MESSAGE,
|
||||
@@ -140,16 +125,19 @@ export function retryable(error: Err, provider: string) {
|
||||
const message = isRecord(error.data) ? error.data.message : undefined
|
||||
if (typeof message !== "string") return undefined
|
||||
const lower = message.toLowerCase()
|
||||
if (
|
||||
lower.includes("rate increased too quickly") ||
|
||||
lower.includes("rate limit") ||
|
||||
lower.includes("rate_limit") ||
|
||||
lower.includes("too many requests")
|
||||
) {
|
||||
return { message }
|
||||
}
|
||||
if (lower.includes("too_many_requests")) return { message: "Too Many Requests" }
|
||||
if (lower.includes("exhausted") || lower.includes("unavailable")) return { message: "Provider is overloaded" }
|
||||
if (matchesRetryableMessage(message)) return { message }
|
||||
return undefined
|
||||
}
|
||||
|
||||
function matchesRetryableMessage(value: unknown) {
|
||||
return typeof value === "string" && RETRYABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(value))
|
||||
}
|
||||
|
||||
function str(value: unknown) {
|
||||
if (value === undefined || value === null) return ""
|
||||
return String(value)
|
||||
|
||||
@@ -326,7 +326,7 @@ describe("HttpApi UI fallback", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("allows embedded UI terminal wasm, blob attachments, and theme preload CSP", () =>
|
||||
it.live("allows embedded UI terminal wasm and theme preload CSP", () =>
|
||||
Effect.gen(function* () {
|
||||
const script = 'document.documentElement.dataset.theme = "dark"'
|
||||
|
||||
@@ -351,8 +351,7 @@ describe("HttpApi UI fallback", () => {
|
||||
const csp = response.headers.get("content-security-policy") ?? ""
|
||||
expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'")
|
||||
expect(csp).toContain(`'sha256-${createHash("sha256").update(script).digest("base64")}'`)
|
||||
expect(csp).toContain("img-src 'self' data: https: blob:")
|
||||
expect(csp).toContain("connect-src * data: blob:")
|
||||
expect(csp).toContain("connect-src * data:")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -604,53 +604,6 @@ it.live("session.processor effect tests retry recognized structured json errors"
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests retry OpenAI-compatible midstream server errors", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* llm.push(raw({ chunks: [{ error: { type: "server_error", code: "server_error", message: "xxx" } }] }))
|
||||
yield* llm.text("after")
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "retry midstream server error")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const value = yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies SessionV1.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "retry midstream server error" }],
|
||||
tools: {},
|
||||
})
|
||||
|
||||
const parts = yield* MessageV2.parts(msg.id)
|
||||
|
||||
expect(value).toBe("continue")
|
||||
expect(yield* llm.calls).toBe(2)
|
||||
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
|
||||
expect(handle.message.error).toBeUndefined()
|
||||
}),
|
||||
{ config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests publish retry status updates", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
|
||||
@@ -168,45 +168,6 @@ describe("session.retry.retryable", () => {
|
||||
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
|
||||
})
|
||||
|
||||
test.each([
|
||||
"Internal server error",
|
||||
"internal error",
|
||||
"server-error",
|
||||
"Provider returned error",
|
||||
"provider-returned-error",
|
||||
"terminated",
|
||||
"fetch failed",
|
||||
"connection refused",
|
||||
"connect ECONNREFUSED",
|
||||
"request ETIMEDOUT",
|
||||
"failed to fetch",
|
||||
"EAI_AGAIN",
|
||||
"response timed out",
|
||||
"Please retry your request",
|
||||
"try your request again",
|
||||
"upstream returned status 524",
|
||||
])("retries matching API error text: %s", (message) => {
|
||||
expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message })
|
||||
})
|
||||
|
||||
test("retries hyphenated service-unavailable errors", () => {
|
||||
expect(SessionRetry.retryable(wrap("service-unavailable"), retryProvider)).toEqual({
|
||||
message: "Provider is overloaded",
|
||||
})
|
||||
})
|
||||
|
||||
test("matches retryable API response bodies", () => {
|
||||
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
|
||||
new SessionV1.APIError({
|
||||
message: "Request failed",
|
||||
isRetryable: false,
|
||||
statusCode: 400,
|
||||
responseBody: JSON.stringify({ error: { message: "upstream connection refused" } }),
|
||||
}).toObject(),
|
||||
)
|
||||
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Request failed" })
|
||||
})
|
||||
|
||||
test("retries transport timeout errors", () => {
|
||||
const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID })
|
||||
expect(SessionV1.APIError.isInstance(request)).toBe(true)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import { mkdir, rm } from "node:fs/promises"
|
||||
|
||||
export const FolderWorkspacePlugin: Plugin = async ({ experimental_workspace }) => {
|
||||
experimental_workspace.register("folder", {
|
||||
name: "Folder",
|
||||
description: "Create a blank folder",
|
||||
configure(config) {
|
||||
const rand = "" + Math.random()
|
||||
|
||||
return {
|
||||
...config,
|
||||
directory: `/tmp/folder/folder-${rand}`,
|
||||
}
|
||||
},
|
||||
async create(config) {
|
||||
if (!config.directory) return
|
||||
await mkdir(config.directory, { recursive: true })
|
||||
},
|
||||
async remove(config) {
|
||||
await rm(config.directory!, { recursive: true, force: true })
|
||||
},
|
||||
target(config) {
|
||||
return {
|
||||
type: "local",
|
||||
directory: config.directory!,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
export default FolderWorkspacePlugin
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Plugin } from "./index.js"
|
||||
import { tool } from "./tool.js"
|
||||
|
||||
export const ExamplePlugin: Plugin = async (_ctx) => {
|
||||
return {
|
||||
tool: {
|
||||
mytool: tool({
|
||||
description: "This is a custom tool",
|
||||
args: {
|
||||
foo: tool.schema.string().describe("foo"),
|
||||
},
|
||||
async execute(args) {
|
||||
return `Hello ${args.foo}!`
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Event as SDKEvent } from "@opencode-ai/sdk/v2/types"
|
||||
import type { Stream } from "effect"
|
||||
|
||||
export type EventMap = {
|
||||
[Item in SDKEvent as Item["type"]]: Item
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
subscribe<Type extends keyof EventMap>(type: Type): Stream.Stream<EventMap[Type]>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { FileSystemEntry } from "@opencode-ai/sdk/v2/types"
|
||||
import type { Effect } from "effect"
|
||||
|
||||
export interface FileSystem {
|
||||
read(input: { readonly path: string }): Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
||||
list(input?: { readonly path?: string }): Effect.Effect<FileSystemEntry[]>
|
||||
find(input: {
|
||||
readonly query: string
|
||||
readonly type?: "file" | "directory"
|
||||
readonly limit?: number
|
||||
}): Effect.Effect<FileSystemEntry[]>
|
||||
glob(input: {
|
||||
readonly pattern: string
|
||||
readonly path?: string
|
||||
readonly limit?: number
|
||||
}): Effect.Effect<readonly FileSystemEntry[]>
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface Location {
|
||||
readonly directory: string
|
||||
readonly project: {
|
||||
readonly directory: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Effect } from "effect"
|
||||
|
||||
export interface Npm {
|
||||
add(pkg: string): Effect.Effect<
|
||||
{
|
||||
readonly directory: string
|
||||
readonly entrypoint?: string
|
||||
},
|
||||
unknown
|
||||
>
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface Path {
|
||||
readonly home: string
|
||||
readonly data: string
|
||||
readonly cache: string
|
||||
readonly config: string
|
||||
readonly state: string
|
||||
readonly temp: string
|
||||
}
|
||||
@@ -95,7 +95,7 @@ const SessionsQueryCursor = SessionsCursor.annotate({
|
||||
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||
})
|
||||
|
||||
const SessionsQuery = Schema.Struct({
|
||||
export const SessionsQuery = Schema.Struct({
|
||||
...SessionsQueryFields,
|
||||
directory: AbsolutePath.pipe(Schema.optional),
|
||||
project: Project.ID.pipe(Schema.optional),
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { ascending } from "./identifier"
|
||||
import { statics } from "./schema"
|
||||
|
||||
export const JobID = Schema.String.check(Schema.isStartsWith("job_")).pipe(
|
||||
Schema.brand("JobID"),
|
||||
statics((schema) => ({ create: () => schema.make("job_" + ascending()) })),
|
||||
)
|
||||
export type JobID = typeof JobID.Type
|
||||
@@ -2,12 +2,12 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Agent } from "../src/agent"
|
||||
import { FileSystem } from "../src/filesystem"
|
||||
import { JobID } from "../src/job-id"
|
||||
import { Model } from "../src/model"
|
||||
import { Project } from "../src/project"
|
||||
import { Pty } from "../src/pty"
|
||||
import { Question } from "../src/question"
|
||||
import { Session } from "../src/session"
|
||||
import { SessionEvent } from "../src/session-event"
|
||||
import { SessionTodo } from "../src/session-todo"
|
||||
import { optional } from "../src/schema"
|
||||
|
||||
@@ -29,7 +29,6 @@ describe("contract hygiene", () => {
|
||||
})
|
||||
|
||||
test("current ID constructors expose create", () => {
|
||||
expect(JobID.create()).toStartWith("job_")
|
||||
expect(Question.ID.create()).toStartWith("que_")
|
||||
expect(Pty.ID.create()).toStartWith("pty_")
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Effect } from "effect"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { InvalidRequestError } from "@opencode-ai/protocol/errors"
|
||||
import { SchemaErrorMiddleware } from "@opencode-ai/protocol/middleware/schema-error"
|
||||
export { SchemaErrorMiddleware } from "@opencode-ai/protocol/middleware/schema-error"
|
||||
|
||||
const REASON_LIMIT = 1024
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Layer, Option } from "effect"
|
||||
import { Api } from "./api"
|
||||
@@ -60,3 +61,8 @@ function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config
|
||||
Layer.provide(serviceLayer),
|
||||
)
|
||||
}
|
||||
|
||||
export const routes = createRoutes()
|
||||
|
||||
export const webHandler = () =>
|
||||
HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)), { disableLogger: true })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// @refresh reload
|
||||
import type { Asset, PageEvent } from "@solidjs/start"
|
||||
import { createHandler, StartServer } from "@solidjs/start/server"
|
||||
import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import { dir, localeFromRequest, tag } from "./lib/language"
|
||||
import statsStylesheetUrl from "./routes/index.css?url"
|
||||
|
||||
const statsThemePreloadScript = `;(function () {
|
||||
var preference = "system"
|
||||
@@ -21,12 +18,8 @@ export default createHandler(
|
||||
() => (
|
||||
<StartServer
|
||||
document={({ assets, children, scripts }) => {
|
||||
const event = getRequestEvent() as PageEvent | undefined
|
||||
const event = getRequestEvent()
|
||||
const locale = event ? localeFromRequest(event.request) : "en"
|
||||
const stylesheet = (event?.assets as Asset[] | undefined)?.find(
|
||||
(asset): asset is Extract<Asset, { tag: "link" }> => asset.tag === "link" && asset.attrs.rel === "stylesheet",
|
||||
)
|
||||
const stylesheetHref = import.meta.env.DEV ? statsStylesheetUrl : stylesheet?.attrs.href
|
||||
|
||||
return (
|
||||
<html lang={tag(locale)} dir={dir(locale)} data-locale={locale}>
|
||||
@@ -34,8 +27,6 @@ export default createHandler(
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script id="stats-theme-preload-script">{statsThemePreloadScript}</script>
|
||||
<link rel="preload" href={ibmPlexMonoMediumLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
{stylesheetHref ? <link rel="stylesheet" href={stylesheetHref} /> : null}
|
||||
{assets}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { geoEquirectangular, geoPath } from "d3-geo"
|
||||
import { scaleSqrt } from "d3-scale"
|
||||
import countryCodesSource from "i18n-iso-countries/codes.json?raw"
|
||||
import { feature, mesh } from "topojson-client"
|
||||
import countriesTopologySource from "world-atlas/countries-50m.json?raw"
|
||||
import {
|
||||
getStatsModelData,
|
||||
type CountryEntry,
|
||||
type ModelPeerEntry,
|
||||
type ModelUsagePoint,
|
||||
type StatsModelData,
|
||||
type UsageRange,
|
||||
} from "@opencode-ai/stats-core/domain/home"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createMemo, createSignal, createUniqueId, For, onMount, Show, type JSX } from "solid-js"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson"
|
||||
import type { GeometryCollection, Topology } from "topojson-specification"
|
||||
import { LocaleLinks } from "../../component/locale-links"
|
||||
import { useI18n } from "../../context/i18n"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { localizedUrl } from "../../lib/language"
|
||||
import { findModelCatalogEntry, formatCatalogLabName, loadModelCatalog, type ModelCatalogEntry } from "../model-catalog"
|
||||
import { geoMapHeight, geoMapWidth, worldBorderPath, worldCountryMarkers, worldCountryPaths } from "../geo-map"
|
||||
import {
|
||||
findModelCatalogEntry,
|
||||
formatCatalogLabName,
|
||||
getModelCatalog,
|
||||
type ModelCatalog,
|
||||
type ModelCatalogEntry,
|
||||
} from "../model-catalog"
|
||||
import { SectionHeading } from "../section-heading"
|
||||
import { runStatsEffect } from "../../stats-runtime"
|
||||
import { setStatsPageCacheHeaders } from "../stats-cache"
|
||||
@@ -40,41 +51,45 @@ import {
|
||||
} from "../stats-shell"
|
||||
|
||||
const statsUnfurlPath = "banner.png"
|
||||
const geoMapWidth = 960
|
||||
const geoMapHeight = 430
|
||||
const shortMonths = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] as const
|
||||
|
||||
type IsoCountryCode = readonly [string, string, string]
|
||||
type ModelCatalogOption = Pick<ModelCatalogEntry, "id" | "lab" | "slug" | "name">
|
||||
type ModelPageCatalog = {
|
||||
entry: ModelCatalogEntry | null
|
||||
labs: { id: string; name: string }[]
|
||||
labModels: ModelCatalogOption[]
|
||||
}
|
||||
type StatsModelPageData = Omit<StatsModelData, "country"> & { country: CountryEntry[] }
|
||||
type ModelPageData = { catalog: ModelPageCatalog; stats: StatsModelPageData | null }
|
||||
type WorldCountryProperties = GeoJsonProperties & { name?: string }
|
||||
type WorldTopology = Topology<{ countries: GeometryCollection<WorldCountryProperties> }>
|
||||
|
||||
const countryNumericIds = new Map(
|
||||
(JSON.parse(countryCodesSource) as IsoCountryCode[]).map((country) => [country[0], country[2]] as const),
|
||||
)
|
||||
const worldTopology = JSON.parse(countriesTopologySource) as WorldTopology
|
||||
const worldCountryGeometries: GeometryCollection<WorldCountryProperties> = {
|
||||
...worldTopology.objects.countries,
|
||||
geometries: worldTopology.objects.countries.geometries.filter((country) => String(country.id ?? "") !== "010"),
|
||||
}
|
||||
const worldCountries = feature<WorldCountryProperties>(worldTopology, worldCountryGeometries) as FeatureCollection<
|
||||
GeometryObject,
|
||||
WorldCountryProperties
|
||||
>
|
||||
const worldProjection = geoEquirectangular().fitExtent(
|
||||
[
|
||||
[10, 12],
|
||||
[geoMapWidth - 10, geoMapHeight - 12],
|
||||
],
|
||||
worldCountries,
|
||||
)
|
||||
const worldPath = geoPath(worldProjection)
|
||||
const worldCountryPaths = worldCountries.features.map((country) => ({
|
||||
id: String(country.id ?? "").padStart(3, "0"),
|
||||
path: worldPath(country) ?? "",
|
||||
marker: geoCountryMarker(country),
|
||||
}))
|
||||
const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? ""
|
||||
|
||||
const getModelPageData = query(async (labParam: string, modelParam: string) => {
|
||||
const getModelData = query(async (lab: string, model: string) => {
|
||||
"use server"
|
||||
const catalog = await loadModelCatalog()
|
||||
const entry = findModelCatalogEntry(catalog, modelParam, labParam) ?? null
|
||||
const lab = entry?.lab ?? labParam
|
||||
const model = entry?.slug ?? modelParam
|
||||
const stats = lab && model ? await runStatsEffect(getStatsModelData(model, lab)) : null
|
||||
return {
|
||||
catalog: {
|
||||
entry,
|
||||
labs: catalog.labs.map((item) => ({ id: item.id, name: item.name })),
|
||||
labModels:
|
||||
catalog.labs
|
||||
.find((item) => item.id === (entry?.lab ?? providerSlug(labParam)))
|
||||
?.models.map((item) => ({ id: item.id, lab: item.lab, slug: item.slug, name: item.name })) ?? [],
|
||||
},
|
||||
stats: stats ? { ...stats, country: stats.country["2M"] } : null,
|
||||
} satisfies ModelPageData
|
||||
}, "getStatsModelPageData")
|
||||
return runStatsEffect(getStatsModelData(model, lab))
|
||||
}, "getStatsModelData")
|
||||
|
||||
export default function StatsModel() {
|
||||
const i18n = useI18n()
|
||||
@@ -84,9 +99,18 @@ export default function StatsModel() {
|
||||
const params = useParams()
|
||||
const labParam = createMemo(() => params.lab ?? "")
|
||||
const modelParam = createMemo(() => params.model ?? "")
|
||||
const page = createAsync(() => getModelPageData(labParam(), modelParam()))
|
||||
const catalogEntry = createMemo(() => page()?.catalog.entry)
|
||||
const stats = createMemo(() => page()?.stats)
|
||||
const catalog = createAsync(() => getModelCatalog())
|
||||
const catalogEntry = createMemo(() => {
|
||||
const data = catalog()
|
||||
if (!data) return undefined
|
||||
return findModelCatalogEntry(data, modelParam(), labParam()) ?? null
|
||||
})
|
||||
const stats = createAsync(() => {
|
||||
const entry = catalogEntry()
|
||||
if (catalog() === undefined || entry === undefined) return Promise.resolve(undefined)
|
||||
if (!entry && (!labParam() || !modelParam())) return Promise.resolve(null)
|
||||
return getModelData(labParam(), entry?.slug ?? modelParam())
|
||||
})
|
||||
const githubStars = createAsync(() => getGitHubStars())
|
||||
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
||||
const modelName = createMemo(() => catalogEntry()?.name ?? stats()?.model ?? modelParam() ?? i18n.t("model.fallback"))
|
||||
@@ -155,13 +179,13 @@ export default function StatsModel() {
|
||||
<Header githubStars={githubStars() ?? "150K"} links={modelHeaderLinks()} brandHref={import.meta.env.BASE_URL} />
|
||||
<div data-component="container">
|
||||
<div data-component="content">
|
||||
<Show when={page() !== undefined} fallback={<ModelLoading />}>
|
||||
<Show when={catalogEntry() || stats() !== undefined} fallback={<ModelLoading />}>
|
||||
<Show when={catalogEntry() || stats()} fallback={<ModelNotFound lab={labParam()} model={modelParam()} />}>
|
||||
<>
|
||||
<ModelHero
|
||||
data={stats() ?? null}
|
||||
catalog={catalogEntry() ?? null}
|
||||
catalogData={page()?.catalog ?? null}
|
||||
catalogData={catalog() ?? null}
|
||||
labName={labName()}
|
||||
/>
|
||||
<ModelOverview catalog={catalogEntry() ?? null} />
|
||||
@@ -169,10 +193,10 @@ export default function StatsModel() {
|
||||
<ModelUsageSection data={stats() ?? null} />
|
||||
<ModelUniqueUsersSection data={stats() ?? null} />
|
||||
<ModelEfficiencySection data={stats() ?? null} catalog={catalogEntry() ?? null} />
|
||||
<ModelGeoBreakdownSection data={stats()?.country ?? []} />
|
||||
<ModelGeoBreakdownSection data={stats()?.country ?? emptyCountryRecord()} />
|
||||
<ModelPeersSection data={stats() ?? null} />
|
||||
<ComparisonCardsSection
|
||||
pairs={modelComparisonPairs(page()?.catalog.labModels, catalogEntry() ?? null, stats() ?? null)}
|
||||
pairs={modelComparisonPairs(catalog(), catalogEntry() ?? null, stats() ?? null)}
|
||||
title="Compare This Model"
|
||||
description="Other models to compare with this one."
|
||||
variant="featured"
|
||||
@@ -247,9 +271,9 @@ function ModelNotFound(props: { lab: string; model: string }) {
|
||||
}
|
||||
|
||||
function ModelHero(props: {
|
||||
data: StatsModelPageData | null
|
||||
data: StatsModelData | null
|
||||
catalog: ModelCatalogEntry | null
|
||||
catalogData: ModelPageCatalog | null
|
||||
catalogData: ModelCatalog | null
|
||||
labName: string
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
@@ -258,7 +282,9 @@ function ModelHero(props: {
|
||||
const modelName = () => props.catalog?.name ?? props.data?.model ?? i18n.t("model.fallback")
|
||||
const weights = () => props.catalog?.weights[0]
|
||||
const labs = () => props.catalogData?.labs ?? []
|
||||
const labModels = () => props.catalogData?.labModels ?? (props.catalog ? [props.catalog] : [])
|
||||
const labModels = () =>
|
||||
props.catalogData?.labs.find((lab) => lab.id === providerSlug(labId()))?.models ??
|
||||
(props.catalog ? [props.catalog] : [])
|
||||
return (
|
||||
<section id="overview" data-section="model-hero">
|
||||
<nav data-component="model-hero-breadcrumb" aria-label="Data breadcrumb">
|
||||
@@ -377,7 +403,7 @@ function ModelHeroActionIcon(props: { kind: "weights" | "compare" }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ModelHeroSparkline(props: { data: StatsModelPageData }) {
|
||||
function ModelHeroSparkline(props: { data: StatsModelData }) {
|
||||
const values = () => props.data.usage.slice(-14).map((point) => point.tokens)
|
||||
return (
|
||||
<span data-slot="model-hero-sparkline" aria-hidden="true">
|
||||
@@ -440,7 +466,7 @@ function ModelOverview(props: { catalog: ModelCatalogEntry | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ModelMomentumSection(props: { data: StatsModelPageData | null }) {
|
||||
function ModelMomentumSection(props: { data: StatsModelData | null }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
@@ -479,7 +505,7 @@ function ModelMomentumSection(props: { data: StatsModelPageData | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
function MomentumChart(props: { data: StatsModelPageData; locale: string }) {
|
||||
function MomentumChart(props: { data: StatsModelData; locale: string }) {
|
||||
const chart = createMemo(() => momentumChart(props.data.usage, props.data.updatedAt))
|
||||
const changeState = createMemo(() => (props.data.tokenChange < 0 ? "negative" : "positive"))
|
||||
return (
|
||||
@@ -536,7 +562,7 @@ function MomentumMetric(props: { label: string; value: string; watermark?: strin
|
||||
)
|
||||
}
|
||||
|
||||
function ModelUsageSection(props: { data: StatsModelPageData | null }) {
|
||||
function ModelUsageSection(props: { data: StatsModelData | null }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<ModelTrendSection
|
||||
@@ -556,7 +582,7 @@ function ModelUsageSection(props: { data: StatsModelPageData | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ModelUniqueUsersSection(props: { data: StatsModelPageData | null }) {
|
||||
function ModelUniqueUsersSection(props: { data: StatsModelData | null }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<ModelTrendSection
|
||||
@@ -580,7 +606,7 @@ function ModelUniqueUsersSection(props: { data: StatsModelPageData | null }) {
|
||||
}
|
||||
|
||||
function ModelTrendSection(props: {
|
||||
data: StatsModelPageData | null
|
||||
data: StatsModelData | null
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
@@ -833,7 +859,7 @@ function ModelTrendSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function ModelEfficiencySection(props: { data: StatsModelPageData | null; catalog: ModelCatalogEntry | null }) {
|
||||
function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<section id="efficiency" data-section="model-panel">
|
||||
@@ -884,11 +910,11 @@ function ModelEfficiencySection(props: { data: StatsModelPageData | null; catalo
|
||||
)
|
||||
}
|
||||
|
||||
function ModelGeoBreakdownSection(props: { data: CountryEntry[] }) {
|
||||
function ModelGeoBreakdownSection(props: { data: Record<UsageRange, CountryEntry[]> }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const [activeCountry, setActiveCountry] = createSignal<string>()
|
||||
const data = createMemo(() => props.data)
|
||||
const data = createMemo(() => props.data["2M"])
|
||||
const countryById = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -1005,30 +1031,31 @@ function GeoWorldMap(props: {
|
||||
</For>
|
||||
</g>
|
||||
<g data-slot="geo-country-markers">
|
||||
<For each={worldCountryMarkers}>
|
||||
<For each={worldCountryPaths}>
|
||||
{(country) => {
|
||||
const entry = () => props.countryById.get(country.id)
|
||||
return (
|
||||
<Show when={entry()}>
|
||||
<circle
|
||||
cx={country.marker.x}
|
||||
cy={country.marker.y}
|
||||
data-country-id={country.id}
|
||||
r={entry()?.country === props.activeCountry ? 3.4 : 2.4}
|
||||
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
||||
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
||||
aria-hidden="true"
|
||||
onPointerEnter={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
onClick={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
/>
|
||||
<Show when={country.marker && entry() ? country.marker : undefined}>
|
||||
{(marker) => (
|
||||
<circle
|
||||
cx={marker().x}
|
||||
cy={marker().y}
|
||||
r={entry()?.country === props.activeCountry ? 3.4 : 2.4}
|
||||
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
||||
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
||||
aria-hidden="true"
|
||||
onPointerEnter={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
onClick={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
@@ -1076,7 +1103,7 @@ function GeoCountryList(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function ModelPeersSection(props: { data: StatsModelPageData | null }) {
|
||||
function ModelPeersSection(props: { data: StatsModelData | null }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<section id="peers" data-section="model-panel">
|
||||
@@ -1145,9 +1172,9 @@ function ModelEmptyState(props: { title: string; description: string; compact?:
|
||||
}
|
||||
|
||||
function modelComparisonPairs(
|
||||
catalogModels: ModelCatalogOption[] | undefined,
|
||||
catalog: ModelCatalog | undefined,
|
||||
catalogEntry: ModelCatalogEntry | null,
|
||||
data: StatsModelPageData | null,
|
||||
data: StatsModelData | null,
|
||||
) {
|
||||
const current = modelComparisonRef(catalogEntry, data)
|
||||
if (!current) return []
|
||||
@@ -1165,7 +1192,9 @@ function modelComparisonPairs(
|
||||
},
|
||||
detail: "Usage peer",
|
||||
}))
|
||||
const catalogPairs = (catalogEntry ? (catalogModels ?? []) : [])
|
||||
const catalogPairs = (
|
||||
catalogEntry && catalog ? (catalog.labs.find((lab) => lab.id === catalogEntry.lab)?.models ?? []) : []
|
||||
)
|
||||
.filter((model) => model.id !== catalogEntry?.id)
|
||||
.slice(0, 3)
|
||||
.map((model) => ({
|
||||
@@ -1178,7 +1207,7 @@ function modelComparisonPairs(
|
||||
|
||||
function modelComparisonRef(
|
||||
catalogEntry: ModelCatalogEntry | null,
|
||||
data: StatsModelPageData | null,
|
||||
data: StatsModelData | null,
|
||||
): ComparisonModelRef | undefined {
|
||||
if (catalogEntry) return modelRefFromCatalog(catalogEntry)
|
||||
if (!data) return undefined
|
||||
@@ -1198,10 +1227,31 @@ function getProviderIconId(author: string) {
|
||||
return author.toLowerCase().replace(/[^a-z0-9]+/g, "")
|
||||
}
|
||||
|
||||
function emptyCountryRecord(): Record<UsageRange, CountryEntry[]> {
|
||||
return {
|
||||
"1D": [],
|
||||
"1W": [],
|
||||
"2W": [],
|
||||
"1M": [],
|
||||
"2M": [],
|
||||
"3M": [],
|
||||
YTD: [],
|
||||
ALL: [],
|
||||
}
|
||||
}
|
||||
|
||||
function countryNumericId(country: string) {
|
||||
return countryNumericIds.get(country.toUpperCase())?.padStart(3, "0")
|
||||
}
|
||||
|
||||
function geoCountryMarker(country: (typeof worldCountries.features)[number]) {
|
||||
const bounds = worldPath.bounds(country)
|
||||
const [x, y] = worldPath.centroid(country)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined
|
||||
if (bounds[1][0] - bounds[0][0] >= 3 && bounds[1][1] - bounds[0][1] >= 3) return undefined
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
function formatCountryName(country: string, locale: string, i18n: ReturnType<typeof useI18n>) {
|
||||
const code = country.toUpperCase()
|
||||
if (code === "ZZ") return i18n.t("home.unknown")
|
||||
@@ -1458,7 +1508,7 @@ function formatSparklinePoint(value: number) {
|
||||
return Number(value.toFixed(2)).toString()
|
||||
}
|
||||
|
||||
function formatModelRankMoveLabel(data: StatsModelPageData, i18n: ReturnType<typeof useI18n>) {
|
||||
function formatModelRankMoveLabel(data: StatsModelData, i18n: ReturnType<typeof useI18n>) {
|
||||
if (data.rank === null) return i18n.t("model.noUsageLastWeek")
|
||||
if (data.previousRank === null) return i18n.t("model.newThisWeek")
|
||||
const change = data.previousRank - data.rank
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type LabUsageModelEntry,
|
||||
type MarketDay,
|
||||
type ModelUsagePoint,
|
||||
type StatsHomeData,
|
||||
type StatsLabData,
|
||||
} from "@opencode-ai/stats-core/domain/home"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
catalogSlug,
|
||||
findModelCatalogLab,
|
||||
formatCatalogLabName,
|
||||
loadModelCatalog,
|
||||
getModelCatalog,
|
||||
type ModelCatalogEntry,
|
||||
type ModelCatalogLab,
|
||||
} from "../model-catalog"
|
||||
@@ -41,33 +42,15 @@ import {
|
||||
|
||||
const statsUnfurlPath = "banner.png"
|
||||
|
||||
type RelatedCatalogLab = Pick<ModelCatalogLab, "id" | "name" | "description"> & {
|
||||
models: Pick<ModelCatalogEntry, "name">[]
|
||||
}
|
||||
|
||||
type LabPageData = {
|
||||
lab: ModelCatalogLab | null
|
||||
labs: RelatedCatalogLab[]
|
||||
market: MarketDay[]
|
||||
stats: StatsLabData | null
|
||||
}
|
||||
|
||||
const getLabPageData = query(async (labParam: string) => {
|
||||
const getLabData = query(async (lab: string) => {
|
||||
"use server"
|
||||
const [catalog, home] = await Promise.all([loadModelCatalog(), runStatsEffect(getStatsHomeData())])
|
||||
const lab = findModelCatalogLab(catalog, labParam) ?? null
|
||||
return {
|
||||
lab,
|
||||
labs: catalog.labs.map((entry) => ({
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
description: entry.description,
|
||||
models: entry.models.map((model) => ({ name: model.name })),
|
||||
})),
|
||||
market: home.market["2M"],
|
||||
stats: lab ? await runStatsEffect(getStatsLabData(lab.id)) : null,
|
||||
} satisfies LabPageData
|
||||
}, "getStatsLabPageData")
|
||||
return runStatsEffect(getStatsLabData(lab))
|
||||
}, "getStatsLabData")
|
||||
|
||||
const getHomeData = query(async () => {
|
||||
"use server"
|
||||
return runStatsEffect(getStatsHomeData())
|
||||
}, "getStatsHomeData")
|
||||
|
||||
type LabModelTooltipState = {
|
||||
model: ModelCatalogEntry
|
||||
@@ -84,9 +67,19 @@ export default function StatsLab() {
|
||||
setStatsPageCacheHeaders(event?.response.headers)
|
||||
const params = useParams()
|
||||
const labParam = createMemo(() => params.lab ?? "")
|
||||
const page = createAsync(() => getLabPageData(labParam()))
|
||||
const lab = createMemo(() => page()?.lab)
|
||||
const stats = createMemo(() => page()?.stats)
|
||||
const catalog = createAsync(() => getModelCatalog())
|
||||
const lab = createMemo(() => {
|
||||
const data = catalog()
|
||||
if (!data) return undefined
|
||||
return findModelCatalogLab(data, labParam()) ?? null
|
||||
})
|
||||
const stats = createAsync(() => {
|
||||
const entry = lab()
|
||||
if (catalog() === undefined || entry === undefined) return Promise.resolve(undefined)
|
||||
if (!entry) return Promise.resolve(null)
|
||||
return getLabData(entry.id)
|
||||
})
|
||||
const homeStats = createAsync((): Promise<StatsHomeData | undefined> => getHomeData())
|
||||
const githubStars = createAsync(() => getGitHubStars())
|
||||
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
||||
const labName = createMemo(() => lab()?.name ?? formatCatalogLabName(labParam()))
|
||||
@@ -144,15 +137,19 @@ export default function StatsLab() {
|
||||
<Header githubStars={githubStars() ?? "150K"} links={labHeaderLinks()} brandHref={import.meta.env.BASE_URL} />
|
||||
<div data-component="container">
|
||||
<div data-component="content">
|
||||
<Show when={page() !== undefined} fallback={<LabLoading />}>
|
||||
<Show when={lab()} fallback={<LabNotFound lab={labParam()} labs={page()?.labs ?? []} />}>
|
||||
<Show when={catalog() !== undefined} fallback={<LabLoading />}>
|
||||
<Show when={lab()} fallback={<LabNotFound lab={labParam()} labs={catalog()?.labs ?? []} />}>
|
||||
{(data) => (
|
||||
<>
|
||||
<LabHero lab={data()} labs={page()?.labs ?? []} />
|
||||
<LabHero lab={data()} labs={catalog()?.labs ?? []} />
|
||||
<LabOverview lab={data()} data={stats() ?? null} />
|
||||
<LabUsageSection lab={data()} data={stats() ?? null} />
|
||||
<LabModelsSection lab={data()} usage={stats()?.models ?? []} />
|
||||
<LabRelatedSection lab={data()} labs={page()?.labs ?? []} market={page()?.market ?? []} />
|
||||
<LabRelatedSection
|
||||
lab={data()}
|
||||
labs={catalog()?.labs ?? []}
|
||||
market={homeStats()?.market["2M"] ?? []}
|
||||
/>
|
||||
<ComparisonCardsSection
|
||||
pairs={labComparisonPairs(data(), stats()?.models ?? [])}
|
||||
title={`${data().name} Model Comparisons`}
|
||||
@@ -185,7 +182,7 @@ function LabLoading() {
|
||||
)
|
||||
}
|
||||
|
||||
function LabNotFound(props: { lab: string; labs: RelatedCatalogLab[] }) {
|
||||
function LabNotFound(props: { lab: string; labs: ModelCatalogLab[] }) {
|
||||
const i18n = useI18n()
|
||||
const labName = () => formatCatalogLabName(props.lab)
|
||||
return (
|
||||
@@ -197,7 +194,7 @@ function LabNotFound(props: { lab: string; labs: RelatedCatalogLab[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LabHero(props: { lab: ModelCatalogLab; labs: RelatedCatalogLab[] }) {
|
||||
function LabHero(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[] }) {
|
||||
return (
|
||||
<section id="overview" data-section="lab-hero">
|
||||
<LabHeroBreadcrumb label={props.lab.name} labs={props.labs} />
|
||||
@@ -206,7 +203,7 @@ function LabHero(props: { lab: ModelCatalogLab; labs: RelatedCatalogLab[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LabHeroBreadcrumb(props: { label: string; labs?: RelatedCatalogLab[] }) {
|
||||
function LabHeroBreadcrumb(props: { label: string; labs?: ModelCatalogLab[] }) {
|
||||
const language = useLanguage()
|
||||
const labs = () => props.labs ?? []
|
||||
const current = () => labs().find((lab) => lab.name === props.label)
|
||||
@@ -671,7 +668,7 @@ function LabModelTooltip(props: { state: LabModelTooltipState }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LabRelatedSection(props: { lab: ModelCatalogLab; labs: RelatedCatalogLab[]; market: MarketDay[] }) {
|
||||
function LabRelatedSection(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[]; market: MarketDay[] }) {
|
||||
const related = createMemo(() => relatedLabs(props.lab, props.labs, props.market))
|
||||
return (
|
||||
<section id="related-labs" data-section="model-panel" data-variant="lab-related">
|
||||
@@ -760,9 +757,9 @@ function labComparisonPairs(lab: ModelCatalogLab, usage: LabUsageModelEntry[]) {
|
||||
)
|
||||
}
|
||||
|
||||
type RelatedLabEntry = { lab: RelatedCatalogLab; share: number; tokens: number }
|
||||
type RelatedLabEntry = { lab: ModelCatalogLab; share: number; tokens: number }
|
||||
|
||||
function relatedLabs(current: ModelCatalogLab, labs: RelatedCatalogLab[], market: MarketDay[]): RelatedLabEntry[] {
|
||||
function relatedLabs(current: ModelCatalogLab, labs: ModelCatalogLab[], market: MarketDay[]): RelatedLabEntry[] {
|
||||
const stats = relatedLabStats(labs, market)
|
||||
return labs
|
||||
.filter((lab) => lab.id !== current.id)
|
||||
@@ -771,8 +768,8 @@ function relatedLabs(current: ModelCatalogLab, labs: RelatedCatalogLab[], market
|
||||
.slice(0, 3)
|
||||
}
|
||||
|
||||
function relatedLabStats(labs: RelatedCatalogLab[], market: MarketDay[]) {
|
||||
const labByKey = new Map<string, RelatedCatalogLab>()
|
||||
function relatedLabStats(labs: ModelCatalogLab[], market: MarketDay[]) {
|
||||
const labByKey = new Map<string, ModelCatalogLab>()
|
||||
labs.forEach((lab) => {
|
||||
labByKey.set(lab.id, lab)
|
||||
labByKey.set(catalogSlug(lab.name), lab)
|
||||
@@ -797,7 +794,7 @@ function relatedLabStats(labs: RelatedCatalogLab[], market: MarketDay[]) {
|
||||
)
|
||||
}
|
||||
|
||||
function labRelatedDescription(lab: RelatedCatalogLab) {
|
||||
function labRelatedDescription(lab: ModelCatalogLab) {
|
||||
return lab.description ?? ""
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export type ComparisonPair = {
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function modelRefFromCatalog(entry: Pick<ModelCatalogEntry, "name" | "lab" | "slug">): ComparisonModelRef {
|
||||
export function modelRefFromCatalog(entry: ModelCatalogEntry): ComparisonModelRef {
|
||||
return {
|
||||
name: entry.name,
|
||||
lab: entry.lab,
|
||||
@@ -83,7 +83,11 @@ export function ComparisonCardsSection(props: {
|
||||
|
||||
function FeaturedComparisonCard(props: { pair: ComparisonPair }) {
|
||||
return (
|
||||
<a data-component="compare-home-card" href={canonicalComparisonHref(props.pair.first, props.pair.second)}>
|
||||
<a
|
||||
data-component="compare-home-card"
|
||||
href={canonicalComparisonHref(props.pair.first, props.pair.second)}
|
||||
aria-label={`${props.pair.detail}: ${props.pair.first.name} vs ${props.pair.second.name}`}
|
||||
>
|
||||
<span data-slot="compare-home-card-head">
|
||||
<span>
|
||||
<strong>{props.pair.detail}</strong>
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { geoEquirectangular, geoPath } from "d3-geo"
|
||||
import { feature, mesh } from "topojson-client"
|
||||
import countriesTopologySource from "world-atlas/countries-110m.json?raw"
|
||||
import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson"
|
||||
import type { GeometryCollection, Topology } from "topojson-specification"
|
||||
|
||||
export const geoMapWidth = 960
|
||||
export const geoMapHeight = 430
|
||||
|
||||
type WorldCountryProperties = GeoJsonProperties & { name?: string }
|
||||
type WorldTopology = Topology<{ countries: GeometryCollection<WorldCountryProperties> }>
|
||||
|
||||
const worldTopology = JSON.parse(countriesTopologySource) as WorldTopology
|
||||
const worldCountryGeometries: GeometryCollection<WorldCountryProperties> = {
|
||||
...worldTopology.objects.countries,
|
||||
geometries: worldTopology.objects.countries.geometries.filter((country) => String(country.id ?? "") !== "010"),
|
||||
}
|
||||
const worldCountries = feature<WorldCountryProperties>(worldTopology, worldCountryGeometries) as FeatureCollection<
|
||||
GeometryObject,
|
||||
WorldCountryProperties
|
||||
>
|
||||
const worldProjection = geoEquirectangular().fitExtent(
|
||||
[
|
||||
[10, 12],
|
||||
[geoMapWidth - 10, geoMapHeight - 12],
|
||||
],
|
||||
worldCountries,
|
||||
)
|
||||
const worldPath = geoPath(worldProjection)
|
||||
|
||||
export const worldCountryPaths = worldCountries.features.map((country) => ({
|
||||
id: String(country.id ?? "").padStart(3, "0"),
|
||||
path: worldPath(country) ?? "",
|
||||
}))
|
||||
|
||||
export const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? ""
|
||||
|
||||
function geoCountryMarker(country: (typeof worldCountries.features)[number]) {
|
||||
const bounds = worldPath.bounds(country)
|
||||
const [x, y] = worldPath.centroid(country)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined
|
||||
if (bounds[1][0] - bounds[0][0] >= 3 && bounds[1][1] - bounds[0][1] >= 3) return undefined
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
// The 110m topology omits small regions. Geographic centroids keep those countries interactive without shipping 50m paths.
|
||||
const fallbackCountryMarkerCoordinates = [
|
||||
["016", -170.7179, -14.3046],
|
||||
["020", 1.5606, 42.542],
|
||||
["028", -61.7945, 17.2762],
|
||||
["048", 50.5425, 26.0417],
|
||||
["052", -59.5602, 13.1811],
|
||||
["060", -64.7558, 32.3131],
|
||||
["086", 72.4453, -7.3312],
|
||||
["092", -64.4704, 18.5276],
|
||||
["132", -23.9576, 15.9551],
|
||||
["136", -80.9129, 19.43],
|
||||
["174", 43.6844, -11.879],
|
||||
["184", -159.7871, -21.2195],
|
||||
["212", -61.3576, 15.4394],
|
||||
["234", -6.8808, 62.0527],
|
||||
["239", -36.4863, -54.4641],
|
||||
["248", 19.9528, 60.2153],
|
||||
["258", -144.8045, -14.7283],
|
||||
["296", -167.9217, 0.893],
|
||||
["308", -61.6818, 12.1174],
|
||||
["316", 144.767, 13.4406],
|
||||
["334", 73.52, -53.0872],
|
||||
["336", 12.4343, 41.9021],
|
||||
["344", 114.1143, 22.3983],
|
||||
["438", 9.5357, 47.1367],
|
||||
["446", 113.509, 22.2231],
|
||||
["462", 73.4573, 3.7316],
|
||||
["470", 14.405, 35.9215],
|
||||
["480", 57.5714, -20.2779],
|
||||
["492", 7.4073, 43.7526],
|
||||
["500", -62.1856, 16.7404],
|
||||
["520", 166.9326, -0.5189],
|
||||
["531", -68.9721, 12.1957],
|
||||
["533", -69.9827, 12.521],
|
||||
["534", -63.0572, 18.0509],
|
||||
["570", -169.8704, -19.0489],
|
||||
["574", 167.9497, -29.0516],
|
||||
["580", 145.6193, 15.8288],
|
||||
["583", 153.2966, 7.5361],
|
||||
["584", 170.3313, 7.015],
|
||||
["585", 134.4056, 7.286],
|
||||
["612", -128.3167, -24.3649],
|
||||
["652", -62.841, 17.8988],
|
||||
["654", -9.7009, -12.3548],
|
||||
["659", -62.6873, 17.2647],
|
||||
["660", -63.066, 18.2243],
|
||||
["662", -60.9696, 13.8946],
|
||||
["663", -63.0599, 18.0888],
|
||||
["666", -56.3037, 46.9187],
|
||||
["670", -61.2008, 13.2251],
|
||||
["674", 12.4594, 43.9415],
|
||||
["678", 6.7235, 0.4434],
|
||||
["690", 55.476, -4.6601],
|
||||
["702", 103.817, 1.359],
|
||||
["776", -174.7998, -20.4161],
|
||||
["796", -71.9734, 21.8312],
|
||||
["831", -2.5726, 49.4678],
|
||||
["832", -2.1272, 49.2181],
|
||||
["833", -4.5388, 54.224],
|
||||
["850", -64.8028, 17.9555],
|
||||
["876", -177.3469, -13.8898],
|
||||
["882", -172.1649, -13.7536],
|
||||
] as const
|
||||
|
||||
export const worldCountryMarkers = [
|
||||
...worldCountries.features.flatMap((country) => {
|
||||
const marker = geoCountryMarker(country)
|
||||
return marker ? [{ id: String(country.id ?? "").padStart(3, "0"), marker }] : []
|
||||
}),
|
||||
...fallbackCountryMarkerCoordinates.flatMap(([id, longitude, latitude]) => {
|
||||
const marker = worldProjection([longitude, latitude])
|
||||
return marker ? [{ id, marker: { x: marker[0], y: marker[1] } }] : []
|
||||
}),
|
||||
]
|
||||
@@ -55,7 +55,7 @@ body {
|
||||
--color-background-strong: #161616;
|
||||
--color-background-strong-hover: #242424;
|
||||
--color-text: #5c5c5c;
|
||||
--color-text-weak: #707070;
|
||||
--color-text-weak: #808080;
|
||||
--color-text-strong: #161616;
|
||||
--color-text-inverted: #ffffff;
|
||||
--color-border-weak: #0000001a;
|
||||
@@ -66,10 +66,10 @@ body {
|
||||
--stats-line-strong: #00000033;
|
||||
--stats-text: #161616;
|
||||
--stats-muted: #5c5c5c;
|
||||
--stats-faint: #707070;
|
||||
--stats-faint: #808080;
|
||||
--stats-theme-icon-active: #3a3a3a;
|
||||
--stats-accent: #3b5cf6;
|
||||
--stats-accent-text: #3b5cf6;
|
||||
--stats-accent-text: #6c7dff;
|
||||
--stats-bar-idle: #d4d4d4;
|
||||
--stats-dot: #d4d4d4;
|
||||
--stats-hero-muted: #5c5c5c;
|
||||
@@ -92,18 +92,6 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="visually-hidden"] {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
[data-page="stats"][data-layout="compare-detail"] {
|
||||
/* The table contains its own wide rows; keep the page itself out of the horizontal scroll chain. */
|
||||
overflow-x: visible;
|
||||
@@ -7091,7 +7079,7 @@ body {
|
||||
--color-background-strong: #ffffff;
|
||||
--color-background-strong-hover: #eeeeee;
|
||||
--color-text: #d4d4d4;
|
||||
--color-text-weak: #a3a3a3;
|
||||
--color-text-weak: #808080;
|
||||
--color-text-strong: #ffffff;
|
||||
--color-text-inverted: #161616;
|
||||
--color-border-weak: #ffffff1a;
|
||||
@@ -7102,12 +7090,11 @@ body {
|
||||
--stats-line-strong: #ffffff33;
|
||||
--stats-text: #ffffff;
|
||||
--stats-muted: #d4d4d4;
|
||||
--stats-faint: #a3a3a3;
|
||||
--stats-accent-text: #8190ff;
|
||||
--stats-faint: #808080;
|
||||
--stats-theme-icon-active: #fafafa;
|
||||
--stats-bar-idle: #303030;
|
||||
--stats-dot: #303030;
|
||||
--stats-hero-muted: #a3a3a3;
|
||||
--stats-hero-muted: #808080;
|
||||
--stats-hero-pattern: #303030;
|
||||
--stats-logo-bg: #f1ecec;
|
||||
--stats-logo-fill: #b7b1b1;
|
||||
@@ -7267,7 +7254,7 @@ body {
|
||||
--color-background-strong: #ffffff;
|
||||
--color-background-strong-hover: #eeeeee;
|
||||
--color-text: #d4d4d4;
|
||||
--color-text-weak: #a3a3a3;
|
||||
--color-text-weak: #808080;
|
||||
--color-text-strong: #ffffff;
|
||||
--color-text-inverted: #161616;
|
||||
--color-border-weak: #ffffff1a;
|
||||
@@ -7278,12 +7265,11 @@ body {
|
||||
--stats-line-strong: #ffffff33;
|
||||
--stats-text: #ffffff;
|
||||
--stats-muted: #d4d4d4;
|
||||
--stats-faint: #a3a3a3;
|
||||
--stats-accent-text: #8190ff;
|
||||
--stats-faint: #808080;
|
||||
--stats-theme-icon-active: #fafafa;
|
||||
--stats-bar-idle: #303030;
|
||||
--stats-dot: #303030;
|
||||
--stats-hero-muted: #a3a3a3;
|
||||
--stats-hero-muted: #808080;
|
||||
--stats-hero-pattern: #303030;
|
||||
--stats-logo-bg: #f1ecec;
|
||||
--stats-logo-fill: #b7b1b1;
|
||||
@@ -8407,7 +8393,7 @@ body {
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] {
|
||||
--top-models-mobile-bar-width: 18px;
|
||||
--top-models-mobile-bar-width: 12px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] [data-slot="top-models-axis"],
|
||||
@@ -8416,7 +8402,7 @@ body {
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="market-share"][data-dense-labels="true"] {
|
||||
--market-mobile-bar-width: 18px;
|
||||
--market-mobile-bar-width: 12px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="market-share"][data-dense-labels="true"] [data-slot="market-labels"],
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { Link, Meta, Title } from "@solidjs/meta"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { geoEquirectangular, geoPath } from "d3-geo"
|
||||
import { scaleSqrt } from "d3-scale"
|
||||
import countryCodesSource from "i18n-iso-countries/codes.json?raw"
|
||||
import { feature, mesh } from "topojson-client"
|
||||
import countriesTopologySource from "world-atlas/countries-50m.json?raw"
|
||||
import ibmPlexMonoRegularLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2?url"
|
||||
import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url"
|
||||
import ibmPlexMonoSemiBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2?url"
|
||||
import ibmPlexMonoBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Bold-Latin1.woff2?url"
|
||||
import {
|
||||
getStatsHomeData,
|
||||
type CacheRatioEntry,
|
||||
type CountryEntry,
|
||||
type LeaderboardEntry,
|
||||
type MarketDay,
|
||||
type StatsHomeData,
|
||||
type SessionCostEntry,
|
||||
type TokenCostEntry,
|
||||
type UsagePoint,
|
||||
@@ -15,13 +23,14 @@ import {
|
||||
import { createAsync, query } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson"
|
||||
import type { GeometryCollection, Topology } from "topojson-specification"
|
||||
import { runStatsEffect } from "../stats-runtime"
|
||||
import { LocaleLinks } from "../component/locale-links"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { useLanguage } from "../context/language"
|
||||
import { localizedUrl } from "../lib/language"
|
||||
import { findModelCatalogEntry, loadModelCatalog, type ModelCatalog } from "./model-catalog"
|
||||
import { geoMapHeight, geoMapWidth, worldBorderPath, worldCountryMarkers, worldCountryPaths } from "./geo-map"
|
||||
import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog"
|
||||
import { SectionHeading } from "./section-heading"
|
||||
import { setStatsPageCacheHeaders } from "./stats-cache"
|
||||
import { ComparisonCardsSection, uniqueComparisonPairs, type ComparisonModelRef } from "./compare-cards"
|
||||
@@ -36,6 +45,9 @@ import {
|
||||
type ThemePreference,
|
||||
} from "./stats-shell"
|
||||
|
||||
const products = ["All Users", "Zen", "Go"] as const
|
||||
const tokenProducts = ["Zen", "Go"] as const
|
||||
const ranges = ["1D", "1W", "2W", "1M", "2M"] as const
|
||||
const comparisonPairIndexes = [
|
||||
[0, 1, "Top two by recent usage"],
|
||||
[0, 2, "Leader vs challenger"],
|
||||
@@ -57,40 +69,60 @@ const usageColors = [
|
||||
"#ff6467",
|
||||
]
|
||||
const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"]
|
||||
const geoMapWidth = 960
|
||||
const geoMapHeight = 430
|
||||
|
||||
type UsageRange = "1D" | "1W" | "2W" | "1M" | "2M"
|
||||
type UsageProduct = (typeof products)[number]
|
||||
type TokenProduct = (typeof tokenProducts)[number]
|
||||
type UsageRange = (typeof ranges)[number]
|
||||
type IsoCountryCode = readonly [string, string, string]
|
||||
type WorldCountryProperties = GeoJsonProperties & { name?: string }
|
||||
type WorldTopology = Topology<{ countries: GeometryCollection<WorldCountryProperties> }>
|
||||
|
||||
type StatsHomePageData = {
|
||||
updatedAt: string | null
|
||||
usage: UsagePoint[]
|
||||
users: UsagePoint[]
|
||||
leaderboard: LeaderboardEntry[]
|
||||
market: MarketDay[]
|
||||
tokenCost: TokenCostEntry[]
|
||||
cacheRatio: CacheRatioEntry[]
|
||||
sessionCost: SessionCostEntry[]
|
||||
country: CountryEntry[]
|
||||
function productLabel(product: UsageProduct | TokenProduct, i18n: ReturnType<typeof useI18n>) {
|
||||
if (product === "All Users") return i18n.t("product.allUsers")
|
||||
if (product === "Zen") return i18n.t("product.zen")
|
||||
return i18n.t("product.go")
|
||||
}
|
||||
|
||||
function rangeLabel(range: UsageRange, i18n: ReturnType<typeof useI18n>) {
|
||||
if (range === "1D") return i18n.t("range.1D")
|
||||
if (range === "1W") return i18n.t("range.1W")
|
||||
if (range === "2W") return i18n.t("range.2W")
|
||||
if (range === "1M") return i18n.t("range.1M")
|
||||
return i18n.t("range.2M")
|
||||
}
|
||||
|
||||
const countryNumericIds = new Map(
|
||||
(JSON.parse(countryCodesSource) as IsoCountryCode[]).map((country) => [country[0], country[2]] as const),
|
||||
)
|
||||
const worldTopology = JSON.parse(countriesTopologySource) as WorldTopology
|
||||
const worldCountryGeometries: GeometryCollection<WorldCountryProperties> = {
|
||||
...worldTopology.objects.countries,
|
||||
geometries: worldTopology.objects.countries.geometries.filter((country) => String(country.id ?? "") !== "010"),
|
||||
}
|
||||
const worldCountries = feature<WorldCountryProperties>(worldTopology, worldCountryGeometries) as FeatureCollection<
|
||||
GeometryObject,
|
||||
WorldCountryProperties
|
||||
>
|
||||
const worldProjection = geoEquirectangular().fitExtent(
|
||||
[
|
||||
[10, 12],
|
||||
[geoMapWidth - 10, geoMapHeight - 12],
|
||||
],
|
||||
worldCountries,
|
||||
)
|
||||
const worldPath = geoPath(worldProjection)
|
||||
const worldCountryPaths = worldCountries.features.map((country) => ({
|
||||
id: String(country.id ?? "").padStart(3, "0"),
|
||||
path: worldPath(country) ?? "",
|
||||
marker: geoCountryMarker(country),
|
||||
}))
|
||||
const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? ""
|
||||
|
||||
const getData = query(async () => {
|
||||
"use server"
|
||||
const [stats, catalog] = await Promise.all([runStatsEffect(getStatsHomeData()), loadModelCatalog()])
|
||||
return {
|
||||
updatedAt: stats.updatedAt,
|
||||
usage: stats.usage.Go["2M"],
|
||||
users: stats.users.Go["2M"],
|
||||
leaderboard: stats.leaderboard.Go["2M"],
|
||||
market: stats.market["2M"],
|
||||
tokenCost: priceTokenCostFromCatalog(stats.tokenCost.Go, catalog),
|
||||
cacheRatio: stats.cacheRatio.Go,
|
||||
sessionCost: stats.sessionCost.Go,
|
||||
country: stats.country["2M"],
|
||||
} satisfies StatsHomePageData
|
||||
return runStatsEffect(getStatsHomeData())
|
||||
}, "getStatsHomeData")
|
||||
|
||||
export default function StatsHome() {
|
||||
@@ -101,6 +133,7 @@ export default function StatsHome() {
|
||||
const statsHomeUrl = localizedUrl(language.locale(), "/data/")
|
||||
const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString()
|
||||
const data = createAsync(() => getData())
|
||||
const catalog = createAsync(() => getModelCatalog())
|
||||
const githubStars = createAsync(() => getGitHubStars())
|
||||
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
||||
const updateThemePreference = (preference: ThemePreference) => {
|
||||
@@ -138,6 +171,10 @@ export default function StatsHome() {
|
||||
<Meta name="twitter:description" content={i18n.t("app.description")} />
|
||||
<Meta name="twitter:image" content={statsUnfurlUrl} />
|
||||
<Meta name="twitter:image:alt" content={i18n.t("app.unfurlAlt")} />
|
||||
<Link rel="preload" href={ibmPlexMonoRegularLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
<Link rel="preload" href={ibmPlexMonoMediumLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
<Link rel="preload" href={ibmPlexMonoSemiBoldLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
<Link rel="preload" href={ibmPlexMonoBoldLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
<Header githubStars={githubStars() ?? githubLink.fallbackStars} />
|
||||
<div data-component="container">
|
||||
<div data-component="content">
|
||||
@@ -148,12 +185,12 @@ export default function StatsHome() {
|
||||
<TopModelsSection data={stats().usage} leaderboard={stats().leaderboard} />
|
||||
<UniqueUsersSection data={stats().users} />
|
||||
<SessionCostSection data={stats().sessionCost} />
|
||||
<TokenCostSection data={stats().tokenCost} />
|
||||
<TokenCostSection data={stats().tokenCost} catalog={catalog() ?? null} />
|
||||
<CacheRatioSection data={stats().cacheRatio} />
|
||||
<MarketShareSection data={stats().market} />
|
||||
<GeoBreakdownSection data={stats().country} />
|
||||
<ComparisonCardsSection
|
||||
pairs={homeComparisonPairs(stats().leaderboard)}
|
||||
pairs={homeComparisonPairs(stats().leaderboard["All Users"]["2M"])}
|
||||
title="Model Comparisons"
|
||||
description="Popular model pairs from the leaderboard."
|
||||
variant="featured"
|
||||
@@ -219,8 +256,7 @@ function Hero(props: { updatedAt: string | null }) {
|
||||
|
||||
return (
|
||||
<section data-section="hero">
|
||||
<p data-slot="hero-meta" role="status" aria-atomic="true">
|
||||
<span data-slot="visually-hidden">{currentUpdatedLabel()}</span>
|
||||
<p data-slot="hero-meta" aria-live="polite" aria-atomic="true" aria-label={currentUpdatedLabel()}>
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
@@ -365,9 +401,32 @@ function formatUpdatedAtLabel(value: { date: string; time: string }) {
|
||||
return `${value.date}, ${value.time}`
|
||||
}
|
||||
|
||||
function TopModelsSection(props: { data: UsagePoint[]; leaderboard: LeaderboardEntry[] }) {
|
||||
function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: StatsHomeData["leaderboard"] }) {
|
||||
const i18n = useI18n()
|
||||
const [product, setProduct] = createSignal<UsageProduct>("Go")
|
||||
const [range, setRange] = createSignal<UsageRange>("2M")
|
||||
const [sheet, setSheet] = createSignal<"product" | "range">()
|
||||
const [activeModel, setActiveModel] = createSignal<string>()
|
||||
const data = createMemo(() => props.data[product()][range()])
|
||||
const leaderboard = createMemo(() => props.leaderboard[product()][range()])
|
||||
|
||||
createEffect(() => {
|
||||
if (!sheet()) return
|
||||
if (typeof document === "undefined") return
|
||||
const htmlOverflow = document.documentElement.style.overflow
|
||||
const bodyOverflow = document.body.style.overflow
|
||||
document.documentElement.style.overflow = "hidden"
|
||||
document.body.style.overflow = "hidden"
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setSheet(undefined)
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown)
|
||||
onCleanup(() => {
|
||||
document.documentElement.style.overflow = htmlOverflow
|
||||
document.body.style.overflow = bodyOverflow
|
||||
document.removeEventListener("keydown", onKeyDown)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<section id="top-models" data-section="top-models">
|
||||
@@ -379,23 +438,201 @@ function TopModelsSection(props: { data: UsagePoint[]; leaderboard: LeaderboardE
|
||||
description={i18n.t("home.topModelsDescription")}
|
||||
/>
|
||||
<Show
|
||||
when={props.data.some((item) => usageTotal(item) > 0)}
|
||||
when={data().some((item) => usageTotal(item) > 0)}
|
||||
fallback={<EmptyState title={i18n.t("home.noUsageTitle")} description={i18n.t("home.noUsageDescription")} />}
|
||||
>
|
||||
<TopModelsChart data={props.data} range="2M" activeModel={activeModel()} onActiveModelChange={setActiveModel} />
|
||||
<TopModelsChart
|
||||
data={data()}
|
||||
range={range()}
|
||||
activeModel={activeModel()}
|
||||
onActiveModelChange={setActiveModel}
|
||||
/>
|
||||
</Show>
|
||||
<Show
|
||||
when={props.leaderboard.length > 0}
|
||||
when={leaderboard().length > 0}
|
||||
fallback={
|
||||
<EmptyState title={i18n.t("home.noLeaderboardTitle")} description={i18n.t("home.noLeaderboardDescription")} />
|
||||
}
|
||||
>
|
||||
<Leaderboard data={props.leaderboard} activeModel={activeModel()} onActiveModelChange={setActiveModel} />
|
||||
<Leaderboard data={leaderboard()} activeModel={activeModel()} onActiveModelChange={setActiveModel} />
|
||||
</Show>
|
||||
<div data-slot="chart-footer" hidden>
|
||||
<StatsFilters product={product()} range={range()} onProductSelect={setProduct} onRangeSelect={setRange} />
|
||||
<div data-slot="top-models-mobile-controls">
|
||||
<MobileFilterButton
|
||||
label={i18n.t("home.productFilter")}
|
||||
value={productLabel(product(), i18n)}
|
||||
expanded={sheet() === "product"}
|
||||
onClick={() => setSheet(sheet() === "product" ? undefined : "product")}
|
||||
/>
|
||||
<MobileFilterButton
|
||||
label={i18n.t("home.dateRange")}
|
||||
value={rangeLabel(range(), i18n)}
|
||||
expanded={sheet() === "range"}
|
||||
onClick={() => setSheet(sheet() === "range" ? undefined : "range")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sheet()}>
|
||||
{(kind) => (
|
||||
<MobileFilterSheet
|
||||
kind={kind()}
|
||||
product={product()}
|
||||
range={range()}
|
||||
onProductSelect={(value) => {
|
||||
setProduct(value)
|
||||
setSheet(undefined)
|
||||
}}
|
||||
onRangeSelect={(value) => {
|
||||
setRange(value)
|
||||
setSheet(undefined)
|
||||
}}
|
||||
onClose={() => setSheet(undefined)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileFilterButton(props: { label: string; value: string; expanded: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
data-slot="mobile-filter-button"
|
||||
type="button"
|
||||
aria-label={props.label}
|
||||
aria-expanded={props.expanded ? "true" : "false"}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<span>{props.value}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileFilterSheet(props: {
|
||||
kind: "product" | "range"
|
||||
product: UsageProduct
|
||||
range: UsageRange
|
||||
onProductSelect: (product: UsageProduct) => void
|
||||
onRangeSelect: (range: UsageRange) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<div data-component="mobile-filter-sheet" role="presentation" onClick={props.onClose}>
|
||||
<div
|
||||
data-slot="filter-sheet-panel"
|
||||
role="radiogroup"
|
||||
aria-label={props.kind === "product" ? i18n.t("home.productFilter") : i18n.t("home.dateRange")}
|
||||
>
|
||||
<Show
|
||||
when={props.kind === "product"}
|
||||
fallback={
|
||||
<For each={ranges}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={props.range === item}
|
||||
data-active={props.range === item ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onRangeSelect(item)
|
||||
}}
|
||||
>
|
||||
{rangeLabel(item, i18n)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<For each={products}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={props.product === item}
|
||||
data-active={props.product === item ? "true" : undefined}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onProductSelect(item)
|
||||
}}
|
||||
>
|
||||
{productLabel(item, i18n)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronDown() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" fill="none">
|
||||
<path d="M5 7L8 10L11 7" stroke="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsFilters(props: {
|
||||
product: UsageProduct
|
||||
range: UsageRange
|
||||
onProductSelect: (product: UsageProduct) => void
|
||||
onRangeSelect: (range: UsageRange) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<>
|
||||
<FilterPills
|
||||
items={products}
|
||||
selected={props.product}
|
||||
label={i18n.t("home.productFilter")}
|
||||
variant="product"
|
||||
formatLabel={(item) => productLabel(item, i18n)}
|
||||
onSelect={props.onProductSelect}
|
||||
/>
|
||||
<FilterPills
|
||||
items={ranges}
|
||||
selected={props.range}
|
||||
label={i18n.t("home.dateRange")}
|
||||
variant="range"
|
||||
formatLabel={(item) => rangeLabel(item, i18n)}
|
||||
onSelect={props.onRangeSelect}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterPills<T extends string>(props: {
|
||||
items: readonly T[]
|
||||
selected: T
|
||||
label: string
|
||||
variant: "product" | "range"
|
||||
formatLabel?: (item: T) => string
|
||||
onSelect: (item: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div data-component="usage-filter" data-variant={props.variant} role="radiogroup" aria-label={props.label}>
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={props.selected === item}
|
||||
data-active={props.selected === item ? "true" : undefined}
|
||||
onClick={() => props.onSelect(item)}
|
||||
>
|
||||
{props.formatLabel ? props.formatLabel(item) : item}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TopModelsChart(props: {
|
||||
data: UsagePoint[]
|
||||
range: UsageRange
|
||||
@@ -586,9 +823,10 @@ function TopModelsChart(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function UniqueUsersSection(props: { data: UsagePoint[] }) {
|
||||
function UniqueUsersSection(props: { data: StatsHomeData["users"] }) {
|
||||
const i18n = useI18n()
|
||||
const [activeModel, setActiveModel] = createSignal<string>()
|
||||
const data = createMemo(() => props.data.Go["2M"])
|
||||
|
||||
return (
|
||||
<section id="unique-users" data-section="unique-users">
|
||||
@@ -599,13 +837,13 @@ function UniqueUsersSection(props: { data: UsagePoint[] }) {
|
||||
description={i18n.t("home.uniqueUsersDescription")}
|
||||
/>
|
||||
<Show
|
||||
when={props.data.some((item) => usageTotal(item) > 0)}
|
||||
when={data().some((item) => usageTotal(item) > 0)}
|
||||
fallback={
|
||||
<EmptyState title={i18n.t("home.noUserDataTitle")} description={i18n.t("home.noUserDataDescription")} />
|
||||
}
|
||||
>
|
||||
<TopModelsChart
|
||||
data={props.data}
|
||||
data={data()}
|
||||
range="2M"
|
||||
metric="users"
|
||||
ariaLabel={i18n.t("home.uniqueUsersChart")}
|
||||
@@ -689,7 +927,7 @@ function isDenseColumnRange(range: UsageRange) {
|
||||
function scrollDenseChartToEnd(element: HTMLDivElement | undefined, range: UsageRange, count: number) {
|
||||
if (!element || count <= 0 || !isDenseColumnRange(range) || typeof window === "undefined") return
|
||||
window.requestAnimationFrame(() => {
|
||||
element.scrollLeft = Number.MAX_SAFE_INTEGER
|
||||
element.scrollLeft = element.scrollWidth - element.clientWidth
|
||||
})
|
||||
}
|
||||
|
||||
@@ -728,11 +966,12 @@ function Leaderboard(props: {
|
||||
activeModel: string | undefined
|
||||
onActiveModelChange: (model: string | undefined) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const featured = createMemo(() => props.data.slice(0, 3))
|
||||
const compact = createMemo(() => props.data.slice(3))
|
||||
|
||||
return (
|
||||
<div id="leaderboard" data-component="leaderboard">
|
||||
<div id="leaderboard" data-component="leaderboard" role="list" aria-label={i18n.t("chart.leaderboardAria")}>
|
||||
<div data-slot="leaderboard-featured">
|
||||
<For each={featured()}>
|
||||
{(entry) => (
|
||||
@@ -758,7 +997,7 @@ function Leaderboard(props: {
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div data-slot="leaderboard-mobile">
|
||||
<div data-slot="leaderboard-mobile" aria-label={i18n.t("chart.scrollableLeaderboardAria")}>
|
||||
<For each={props.data}>
|
||||
{(entry) => (
|
||||
<LeaderboardCard
|
||||
@@ -790,6 +1029,9 @@ function LeaderboardCard(props: {
|
||||
href={language.route(
|
||||
`${import.meta.env.BASE_URL}${modelSlug(props.entry.provider)}/${modelSlug(props.entry.model)}`,
|
||||
)}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
aria-label={`${String(props.entry.rank).padStart(2, "0")} ${props.entry.model} ${i18n.t("chart.byAuthor", { author: props.entry.author })}`}
|
||||
onPointerEnter={() => props.onActiveModelChange(props.entry.model)}
|
||||
onPointerLeave={(event) => {
|
||||
if (event.pointerType === "touch") return
|
||||
@@ -842,14 +1084,16 @@ function formatChange(value: number | null, i18n: ReturnType<typeof useI18n>) {
|
||||
return `${value}%`
|
||||
}
|
||||
|
||||
function MarketShareSection(props: { data: MarketDay[] }) {
|
||||
function MarketShareSection(props: { data: StatsHomeData["market"] }) {
|
||||
const i18n = useI18n()
|
||||
const [range, setRange] = createSignal<UsageRange>("2M")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const [activeAuthor, setActiveAuthor] = createSignal<string>()
|
||||
const [inspecting, setInspecting] = createSignal(false)
|
||||
const authorOrder = createMemo(() => getMarketAuthorOrder(props.data))
|
||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(props.data.length - 1, 0)))
|
||||
const activeDay = createMemo(() => props.data[selectedIndex()])
|
||||
const data = createMemo(() => props.data[range()])
|
||||
const authorOrder = createMemo(() => getMarketAuthorOrder(data()))
|
||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
|
||||
const activeDay = createMemo(() => data()[selectedIndex()])
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -874,8 +1118,8 @@ function MarketShareSection(props: { data: MarketDay[] }) {
|
||||
{(day) => (
|
||||
<>
|
||||
<MarketShare
|
||||
data={props.data}
|
||||
range="2M"
|
||||
data={data()}
|
||||
range={range()}
|
||||
authorOrder={authorOrder()}
|
||||
activeIndex={selectedIndex()}
|
||||
activeAuthor={activeAuthor()}
|
||||
@@ -907,9 +1151,22 @@ function MarketShareSection(props: { data: MarketDay[] }) {
|
||||
<strong>
|
||||
{inspecting()
|
||||
? formatMarketDate(activeDay(), i18n.t("home.noData"))
|
||||
: formatMarketRange(props.data, i18n.t("home.noData"))}
|
||||
: formatMarketRange(data(), i18n.t("home.noData"))}
|
||||
</strong>
|
||||
</p>
|
||||
<div hidden>
|
||||
<FilterPills
|
||||
items={ranges}
|
||||
selected={range()}
|
||||
label={i18n.t("home.dateRange")}
|
||||
variant="range"
|
||||
onSelect={(item) => {
|
||||
setRange(item)
|
||||
setActiveAuthor(undefined)
|
||||
setInspecting(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
@@ -945,7 +1202,6 @@ function MarketShare(props: {
|
||||
{(day, index) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${day.date} ${formatTrillions(day.total)}`}
|
||||
data-active={props.inspecting && props.activeIndex === index() ? "true" : undefined}
|
||||
data-label-hidden={isColumnLabelHidden(index(), props.data.length) ? "true" : undefined}
|
||||
data-mobile-hidden={isMarketMobileLabelHidden(index(), props.data.length) ? "true" : undefined}
|
||||
@@ -1021,11 +1277,14 @@ function MarketShareList(props: {
|
||||
activeAuthor: string | undefined
|
||||
onActiveAuthorChange: (author: string) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<ol data-component="market-share-list">
|
||||
<For each={props.data}>
|
||||
{(item, index) => {
|
||||
const label = () =>
|
||||
`${item.author} ${formatTrillions(item.tokens)} ${item.share.toFixed(1)} ${i18n.t("chart.percent")}`
|
||||
const content = () => (
|
||||
<>
|
||||
<span>{String(index() + 1).padStart(2, "0")}</span>
|
||||
@@ -1047,6 +1306,7 @@ function MarketShareList(props: {
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label()}
|
||||
onClick={() => props.onActiveAuthorChange(item.author)}
|
||||
onFocus={() => props.onActiveAuthorChange(item.author)}
|
||||
>
|
||||
@@ -1057,6 +1317,7 @@ function MarketShareList(props: {
|
||||
{(href) => (
|
||||
<a
|
||||
href={href()}
|
||||
aria-label={label()}
|
||||
onClick={() => props.onActiveAuthorChange(item.author)}
|
||||
onFocus={() => props.onActiveAuthorChange(item.author)}
|
||||
>
|
||||
@@ -1072,22 +1333,23 @@ function MarketShareList(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function GeoBreakdownSection(props: { data: CountryEntry[] }) {
|
||||
function GeoBreakdownSection(props: { data: StatsHomeData["country"] }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const [activeCountry, setActiveCountry] = createSignal<string>()
|
||||
const data = createMemo(() => props.data["2M"])
|
||||
const countryById = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
props.data.flatMap((country) => {
|
||||
data().flatMap((country) => {
|
||||
const id = countryNumericId(country.country)
|
||||
return id ? [[id, country] as const] : []
|
||||
}),
|
||||
),
|
||||
)
|
||||
const maxTokens = createMemo(() => Math.max(0, ...props.data.map((country) => country.tokens)) || 1)
|
||||
const topCountries = createMemo(() => props.data.slice(0, 15))
|
||||
const active = createMemo(() => props.data.find((country) => country.country === activeCountry()) ?? props.data[0])
|
||||
const maxTokens = createMemo(() => Math.max(0, ...data().map((country) => country.tokens)) || 1)
|
||||
const topCountries = createMemo(() => data().slice(0, 15))
|
||||
const active = createMemo(() => data().find((country) => country.country === activeCountry()) ?? data()[0])
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -1101,7 +1363,7 @@ function GeoBreakdownSection(props: { data: CountryEntry[] }) {
|
||||
<SectionBridge label={i18n.t("nav.marketShare").toUpperCase()} href="#market-share" />
|
||||
<SectionTitle id="geo-breakdown" title={i18n.t("home.geoTitle")} description={i18n.t("home.geoDescription")} />
|
||||
<Show
|
||||
when={props.data.length > 0}
|
||||
when={data().length > 0}
|
||||
fallback={<EmptyState title={i18n.t("home.noGeoTitle")} description={i18n.t("home.noGeoDescription")} />}
|
||||
>
|
||||
<div data-component="geo-breakdown">
|
||||
@@ -1191,30 +1453,31 @@ function GeoWorldMap(props: {
|
||||
</For>
|
||||
</g>
|
||||
<g data-slot="geo-country-markers">
|
||||
<For each={worldCountryMarkers}>
|
||||
<For each={worldCountryPaths}>
|
||||
{(country) => {
|
||||
const entry = () => props.countryById.get(country.id)
|
||||
return (
|
||||
<Show when={entry()}>
|
||||
<circle
|
||||
cx={country.marker.x}
|
||||
cy={country.marker.y}
|
||||
data-country-id={country.id}
|
||||
r={entry()?.country === props.activeCountry ? 3.4 : 2.4}
|
||||
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
||||
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
||||
aria-hidden="true"
|
||||
onPointerEnter={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
onClick={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
/>
|
||||
<Show when={country.marker && entry() ? country.marker : undefined}>
|
||||
{(marker) => (
|
||||
<circle
|
||||
cx={marker().x}
|
||||
cy={marker().y}
|
||||
r={entry()?.country === props.activeCountry ? 3.4 : 2.4}
|
||||
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
||||
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
||||
aria-hidden="true"
|
||||
onPointerEnter={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
onClick={() => {
|
||||
const item = entry()
|
||||
if (!item) return
|
||||
props.onActiveCountryChange(item.country)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
@@ -1244,6 +1507,7 @@ function GeoCountryList(props: {
|
||||
type="button"
|
||||
data-active={props.activeCountry === country.country ? "true" : undefined}
|
||||
style={{ "--geo-row-opacity": String(opacityScale()(country.tokens)) } as JSX.CSSProperties}
|
||||
aria-label={`${formatCountryName(country.country, language.tag(language.locale()), i18n.t("home.unknown"))} ${formatGeoTokens(country.tokens)} ${formatGeoShare(country.share)}`}
|
||||
onClick={() => props.onActiveCountryChange(country.country)}
|
||||
onPointerEnter={() => props.onActiveCountryChange(country.country)}
|
||||
onFocus={() => props.onActiveCountryChange(country.country)}
|
||||
@@ -1267,6 +1531,14 @@ function countryNumericId(country: string) {
|
||||
return countryNumericIds.get(country.toUpperCase())?.padStart(3, "0")
|
||||
}
|
||||
|
||||
function geoCountryMarker(country: (typeof worldCountries.features)[number]) {
|
||||
const bounds = worldPath.bounds(country)
|
||||
const [x, y] = worldPath.centroid(country)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined
|
||||
if (bounds[1][0] - bounds[0][0] >= 3 && bounds[1][1] - bounds[0][1] >= 3) return undefined
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
function formatCountryName(country: string, locale: string, unknown: string) {
|
||||
const code = country.toUpperCase()
|
||||
if (code === "ZZ") return unknown
|
||||
@@ -1364,10 +1636,12 @@ function marketDateParts(label: string) {
|
||||
return { start: start ?? label, end: end ?? start ?? label }
|
||||
}
|
||||
|
||||
function TokenCostSection(props: { data: TokenCostEntry[] }) {
|
||||
function TokenCostSection(props: { data: StatsHomeData["tokenCost"]; catalog: ModelCatalog | null }) {
|
||||
const i18n = useI18n()
|
||||
const [product, setProduct] = createSignal<TokenProduct>("Go")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const visible = createMemo(() => props.data.slice(0, 13))
|
||||
const data = createMemo(() => priceTokenCostFromCatalog(props.data[product()], props.catalog))
|
||||
const visible = createMemo(() => data().slice(0, 13))
|
||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0)))
|
||||
|
||||
return (
|
||||
@@ -1386,6 +1660,17 @@ function TokenCostSection(props: { data: TokenCostEntry[] }) {
|
||||
>
|
||||
<TokenCostChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||
</Show>
|
||||
<div data-slot="token-footer" hidden>
|
||||
<FilterPills
|
||||
items={tokenProducts}
|
||||
selected={product()}
|
||||
label={i18n.t("home.productFilter")}
|
||||
variant="product"
|
||||
formatLabel={(item) => productLabel(item, i18n)}
|
||||
onSelect={setProduct}
|
||||
/>
|
||||
<LiveIndicator />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1438,10 +1723,12 @@ function TokenCostChart(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function CacheRatioSection(props: { data: CacheRatioEntry[] }) {
|
||||
function CacheRatioSection(props: { data: StatsHomeData["cacheRatio"] }) {
|
||||
const i18n = useI18n()
|
||||
const [product, setProduct] = createSignal<TokenProduct>("Go")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const visible = createMemo(() => props.data.slice(0, 16))
|
||||
const data = createMemo(() => props.data[product()])
|
||||
const visible = createMemo(() => data().slice(0, 16))
|
||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0)))
|
||||
|
||||
return (
|
||||
@@ -1458,6 +1745,17 @@ function CacheRatioSection(props: { data: CacheRatioEntry[] }) {
|
||||
>
|
||||
<CacheRatioChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||
</Show>
|
||||
<div data-slot="token-footer" hidden>
|
||||
<FilterPills
|
||||
items={tokenProducts}
|
||||
selected={product()}
|
||||
label={i18n.t("home.productFilter")}
|
||||
variant="product"
|
||||
formatLabel={(item) => productLabel(item, i18n)}
|
||||
onSelect={setProduct}
|
||||
/>
|
||||
<LiveIndicator />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1555,10 +1853,12 @@ function MetricBar(props: { value: number; max: number; active: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SessionCostSection(props: { data: SessionCostEntry[] }) {
|
||||
function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
|
||||
const i18n = useI18n()
|
||||
const [product, setProduct] = createSignal<TokenProduct>("Go")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const visible = createMemo(() => props.data.slice(0, 16))
|
||||
const data = createMemo(() => props.data[product()])
|
||||
const visible = createMemo(() => data().slice(0, 16))
|
||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0)))
|
||||
|
||||
return (
|
||||
@@ -1577,6 +1877,17 @@ function SessionCostSection(props: { data: SessionCostEntry[] }) {
|
||||
>
|
||||
<SessionCostChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||
</Show>
|
||||
<div data-slot="token-footer" hidden>
|
||||
<FilterPills
|
||||
items={tokenProducts}
|
||||
selected={product()}
|
||||
label={i18n.t("home.productFilter")}
|
||||
variant="product"
|
||||
formatLabel={(item) => productLabel(item, i18n)}
|
||||
onSelect={setProduct}
|
||||
/>
|
||||
<LiveIndicator />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1638,6 +1949,11 @@ function SessionCostChart(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function LiveIndicator() {
|
||||
const i18n = useI18n()
|
||||
return <span data-component="live-filter">{i18n.t("chart.live")}</span>
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number) {
|
||||
if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}M`
|
||||
return `${Math.round(value / 1_000)}K`
|
||||
|
||||
@@ -18,7 +18,6 @@ export default defineConfig({
|
||||
allowedHosts: true,
|
||||
},
|
||||
build: {
|
||||
minify: "esbuild",
|
||||
cssMinify: true,
|
||||
minify: false,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"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.14",
|
||||
"version": "1.18.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
[14:25:48.462] [INFO] storybook v10.2.10
|
||||
[14:25:48.749] [DEBUG] Getting package.json info for /Users/davidhill/Documents/Local/opencode/packages/storybook/package.json...
|
||||
[14:25:48.997] [INFO] Starting...
|
||||
[14:25:49.095] [DEBUG] Starting preview..
|
||||
[14:25:49.098] [WARN] 🚨 Unable to index files:
|
||||
- ./../ui/src/components/accordion.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/accordion.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/app-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/app-icon.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/avatar.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/avatar.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/basic-tool.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/basic-tool.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/checkbox.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/checkbox.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/code.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/code.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/collapsible.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/collapsible.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/context-menu.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/context-menu.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/dialog.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dialog.stories.tsx (line 10, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/diff-changes.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff-changes.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/diff-ssr.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff-ssr.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/diff.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/dock-prompt.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dock-prompt.stories.tsx (line 15, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/dropdown-menu.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dropdown-menu.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/favicon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/favicon.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/file-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/file-icon.stories.tsx (line 13, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/font.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/font.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/hover-card.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/hover-card.stories.tsx (line 13, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/icon-button.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/icon-button.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/icon.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/image-preview.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/image-preview.stories.tsx (line 13, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/inline-input.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/inline-input.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/keybind.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/keybind.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/line-comment.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/line-comment.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/list.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/list.stories.tsx (line 15, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/logo.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/logo.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/markdown.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/markdown.stories.tsx (line 12, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/message-nav.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/message-nav.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/message-part.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/message-part.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/popover.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/popover.stories.tsx (line 16, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/progress-circle.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/progress-circle.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/progress.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/progress.stories.tsx (line 15, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/provider-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/provider-icon.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/radio-group.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/radio-group.stories.tsx (line 13, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/resize-handle.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/resize-handle.stories.tsx (line 17, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/select.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/select.stories.tsx (line 16, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/session-review.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-review.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/session-turn.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/spinner.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/spinner.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/sticky-accordion-header.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/sticky-accordion-header.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/switch.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/switch.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/tabs.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tabs.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/tag.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tag.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/text-field.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/text-field.stories.tsx (line 14, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/text-shimmer.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/text-shimmer.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/toast.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/toast.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/tooltip.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tooltip.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/typewriter.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/typewriter.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
[14:25:49.109] [ERROR] Failed to build the preview
|
||||
[14:25:49.110] [ERROR] Error: [38;2;241;97;97mUnable to index files:
|
||||
- ./../ui/src/components/accordion.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/accordion.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/app-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/app-icon.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/avatar.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/avatar.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/basic-tool.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/basic-tool.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/checkbox.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/checkbox.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/code.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/code.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/collapsible.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/collapsible.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/context-menu.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/context-menu.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/dialog.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dialog.stories.tsx (line 10, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/diff-changes.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff-changes.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/diff-ssr.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff-ssr.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/diff.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/diff.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/dock-prompt.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dock-prompt.stories.tsx (line 15, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/dropdown-menu.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/dropdown-menu.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/favicon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/favicon.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/file-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/file-icon.stories.tsx (line 13, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/font.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/font.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/hover-card.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/hover-card.stories.tsx (line 13, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/icon-button.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/icon-button.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/icon.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/image-preview.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/image-preview.stories.tsx (line 13, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/inline-input.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/inline-input.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/keybind.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/keybind.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/line-comment.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/line-comment.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/list.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/list.stories.tsx (line 15, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/logo.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/logo.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/markdown.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/markdown.stories.tsx (line 12, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/message-nav.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/message-nav.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/message-part.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/message-part.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/popover.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/popover.stories.tsx (line 16, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/progress-circle.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/progress-circle.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/progress.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/progress.stories.tsx (line 15, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/provider-icon.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/provider-icon.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/radio-group.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/radio-group.stories.tsx (line 13, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/resize-handle.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/resize-handle.stories.tsx (line 17, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/select.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/select.stories.tsx (line 16, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/session-review.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-review.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/session-turn.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/spinner.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/spinner.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/sticky-accordion-header.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/sticky-accordion-header.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/switch.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/switch.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/tabs.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tabs.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/tag.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tag.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/text-field.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/text-field.stories.tsx (line 14, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/text-shimmer.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/text-shimmer.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/toast.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/toast.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/tooltip.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/tooltip.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export
|
||||
- ./../ui/src/components/typewriter.stories.tsx: CSF: default export must be an object /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/typewriter.stories.tsx (line 6, col 0)
|
||||
|
||||
More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export[39m
|
||||
at _StoryIndexGenerator.getIndexAndStats (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/core-server/index.js:6085:15)
|
||||
at async _StoryIndexGenerator.getIndex (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/core-server/index.js:6074:13)
|
||||
at async getOptimizeDeps (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/@storybook+builder-vite@10.2.10+a2a25316dbcddd7f/node_modules/@storybook/builder-vite/dist/index.js:1862:15)
|
||||
at async createViteServer (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/@storybook+builder-vite@10.2.10+a2a25316dbcddd7f/node_modules/@storybook/builder-vite/dist/index.js:1888:19)
|
||||
at async Module.start (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/@storybook+builder-vite@10.2.10+a2a25316dbcddd7f/node_modules/@storybook/builder-vite/dist/index.js:1923:17)
|
||||
at async storybookDevServer (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/core-server/index.js:7241:83)
|
||||
at async buildOrThrow (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/core-server/index.js:4504:12)
|
||||
at async buildDevStandalone (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/core-server/index.js:7611:66)
|
||||
at async withTelemetry (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/_node-chunks/chunk-S3MWHNYJ.js:218:12)
|
||||
at async dev (file:///Users/davidhill/Documents/Local/opencode/node_modules/.bun/storybook@10.2.10+4edd68b244e756bb/node_modules/storybook/dist/bin/core.js:2734:3)
|
||||
[14:25:49.118] [WARN] Broken build, fix the error above.
|
||||
You may need to refresh the browser.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index dca128d3a790378c51a24a16d92585178343b278..da75f9d64acd2b607abd15079ce2d03b7a8d675a 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -696,7 +696,7 @@ var OpenAICompatibleChatLanguageModel = class {
|
||||
finishReason = { unified: "error", raw: void 0 };
|
||||
controller.enqueue({
|
||||
type: "error",
|
||||
- error: chunk.value.error.message
|
||||
+ error: chunk.value.error
|
||||
});
|
||||
return;
|
||||
}
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 3b1e1b6bdec5032e3b4fa5ffbcc8cdf3dfe1cc40..eaffc446f80552ea0b26573b89daa4dfc7776e6e 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -683,7 +683,7 @@ var OpenAICompatibleChatLanguageModel = class {
|
||||
finishReason = { unified: "error", raw: void 0 };
|
||||
controller.enqueue({
|
||||
type: "error",
|
||||
- error: chunk.value.error.message
|
||||
+ error: chunk.value.error
|
||||
});
|
||||
return;
|
||||
}
|
||||
diff --git a/src/chat/openai-compatible-chat-language-model.ts b/src/chat/openai-compatible-chat-language-model.ts
|
||||
index 8c622db23c2d9a7373701f5a1b0c2ba109e24602..643c3db68a6043e097edc1122e0eb53fd13495c5 100644
|
||||
--- a/src/chat/openai-compatible-chat-language-model.ts
|
||||
+++ b/src/chat/openai-compatible-chat-language-model.ts
|
||||
@@ -442,7 +442,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
finishReason = { unified: 'error', raw: undefined };
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
- error: chunk.value.error.message,
|
||||
+ error: chunk.value.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.18.14",
|
||||
"version": "1.18.13",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user