mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
18 Commits
midstream-errs
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| def7220bfc | |||
| f6c5afe5a9 | |||
| 847771fe06 | |||
| b8bd88901a | |||
| f1adabcddc | |||
| 24470e52a5 | |||
| 23bbc5cd14 | |||
| ebf6fc07a1 | |||
| 04513d9692 | |||
| 146720e197 | |||
| b1f8cc04af | |||
| 082fe93e16 | |||
| 709c195905 | |||
| 3355b78d91 | |||
| b84c63d034 | |||
| 057b5a9dee | |||
| 61aefc0759 | |||
| f929f8f100 |
@@ -322,6 +322,7 @@ jobs:
|
|||||||
working-directory: packages/desktop
|
working-directory: packages/desktop
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@corvu/drawer": "catalog:",
|
"@corvu/drawer": "catalog:",
|
||||||
"@dnd-kit/abstract": "0.5.0",
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lildax": "./bin/lildax.cjs",
|
"lildax": "./bin/lildax.cjs",
|
||||||
},
|
},
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
},
|
},
|
||||||
"packages/codemode": {
|
"packages/codemode": {
|
||||||
"name": "@opencode-ai/codemode",
|
"name": "@opencode-ai/codemode",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"acorn": "8.15.0",
|
"acorn": "8.15.0",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -267,7 +267,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/support": {
|
"packages/console/support": {
|
||||||
"name": "@opencode-ai/console-support",
|
"name": "@opencode-ai/console-support",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@opencode-ai/console-core": "workspace:*",
|
"@opencode-ai/console-core": "workspace:*",
|
||||||
@@ -287,7 +287,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -381,7 +381,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
@@ -435,7 +435,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-drizzle-sqlite": {
|
"packages/effect-drizzle-sqlite": {
|
||||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -449,7 +449,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-sqlite-node": {
|
"packages/effect-sqlite-node": {
|
||||||
"name": "@opencode-ai/effect-sqlite-node",
|
"name": "@opencode-ai/effect-sqlite-node",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -461,7 +461,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/standard-validator": "catalog:",
|
"@hono/standard-validator": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -509,7 +509,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.83",
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||||
@@ -540,7 +540,7 @@
|
|||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
@@ -559,7 +559,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -690,7 +690,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -766,7 +766,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -781,7 +781,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@opencode-ai/server",
|
"name": "@opencode-ai/server",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
@@ -796,7 +796,7 @@
|
|||||||
},
|
},
|
||||||
"packages/session-ui": {
|
"packages/session-ui": {
|
||||||
"name": "@opencode-ai/session-ui",
|
"name": "@opencode-ai/session-ui",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||||
@@ -836,7 +836,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -849,7 +849,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/app": {
|
"packages/stats/app": {
|
||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
@@ -883,7 +883,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/core": {
|
"packages/stats/core": {
|
||||||
"name": "@opencode-ai/stats-core",
|
"name": "@opencode-ai/stats-core",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-athena": "3.933.0",
|
"@aws-sdk/client-athena": "3.933.0",
|
||||||
"@planetscale/database": "1.19.0",
|
"@planetscale/database": "1.19.0",
|
||||||
@@ -902,7 +902,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/server": {
|
"packages/stats/server": {
|
||||||
"name": "@opencode-ai/stats-server",
|
"name": "@opencode-ai/stats-server",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-firehose": "3.933.0",
|
"@aws-sdk/client-firehose": "3.933.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
@@ -944,7 +944,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
@@ -971,7 +971,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
@@ -1022,7 +1022,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -1078,6 +1078,7 @@
|
|||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.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",
|
"@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": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-GRjnvvyj37H36RqiCB7dz5ALAEwvw16izwuk1wsHEpU=",
|
"x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=",
|
||||||
"aarch64-linux": "sha256-0OIn1o6dpqIQ5XgIMzpenMCMqsYzraaYVJk+te5eINU=",
|
"aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=",
|
||||||
"aarch64-darwin": "sha256-sQSQcuox78d8wT1lsKYHqVBl11NusiszQ+gu2XYXZi8=",
|
"aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=",
|
||||||
"x86_64-darwin": "sha256-SINkhMRd4oM1zABSs1Uf3OAzxJ1lgcycl1U4fmi+baY="
|
"x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -158,6 +158,7 @@
|
|||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@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",
|
"@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",
|
"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"
|
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import { checksum } from "@opencode-ai/core/util/encode"
|
|||||||
import { findLast } from "@opencode-ai/core/util/array"
|
import { findLast } from "@opencode-ai/core/util/array"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
import { File } from "@opencode-ai/session-ui/file"
|
import { File } from "@opencode-ai/session-ui/file"
|
||||||
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
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 { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
@@ -220,6 +223,31 @@ export function SessionContextTab() {
|
|||||||
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
||||||
] satisfies { label: string; value: () => JSX.Element }[]
|
] 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 scroll: HTMLDivElement | undefined
|
||||||
let frame: number | undefined
|
let frame: number | undefined
|
||||||
let pending: { x: number; y: number } | undefined
|
let pending: { x: number; y: number } | undefined
|
||||||
@@ -328,7 +356,18 @@ export function SessionContextTab() {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
<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>
|
||||||
<Accordion multiple>
|
<Accordion multiple>
|
||||||
<For each={messages()}>
|
<For each={messages()}>
|
||||||
{(message) => (
|
{(message) => (
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export const dict = {
|
|||||||
"command.session.share.description": "Share this session and copy the URL to clipboard",
|
"command.session.share.description": "Share this session and copy the URL to clipboard",
|
||||||
"command.session.unshare": "Unshare session",
|
"command.session.unshare": "Unshare session",
|
||||||
"command.session.unshare.description": "Stop sharing this 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": "Search files, commands, and sessions",
|
||||||
"palette.search.placeholder.home": "Search commands and sessions",
|
"palette.search.placeholder.home": "Search commands and sessions",
|
||||||
@@ -489,6 +491,7 @@ export const dict = {
|
|||||||
|
|
||||||
"context.systemPrompt.title": "System Prompt",
|
"context.systemPrompt.title": "System Prompt",
|
||||||
"context.rawMessages.title": "Raw messages",
|
"context.rawMessages.title": "Raw messages",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
|
||||||
"context.stats.session": "Session",
|
"context.stats.session": "Session",
|
||||||
"context.stats.messages": "Messages",
|
"context.stats.messages": "Messages",
|
||||||
@@ -568,6 +571,11 @@ export const dict = {
|
|||||||
"toast.session.unshare.failed.title": "Failed to unshare session",
|
"toast.session.unshare.failed.title": "Failed to unshare session",
|
||||||
"toast.session.unshare.failed.description": "An error occurred while unsharing the 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.session.listFailed.title": "Failed to load sessions for {{project}}",
|
||||||
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
|
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
|
||||||
|
|
||||||
@@ -802,6 +810,7 @@ export const dict = {
|
|||||||
"common.moreOptions": "More options",
|
"common.moreOptions": "More options",
|
||||||
"common.learnMore": "Learn more",
|
"common.learnMore": "Learn more",
|
||||||
"common.rename": "Rename",
|
"common.rename": "Rename",
|
||||||
|
"common.export": "Export",
|
||||||
"common.reset": "Reset",
|
"common.reset": "Reset",
|
||||||
"common.archive": "Archive",
|
"common.archive": "Archive",
|
||||||
"common.delete": "Delete",
|
"common.delete": "Delete",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import type {
|
|||||||
UserMessage,
|
UserMessage,
|
||||||
} from "@opencode-ai/sdk/v2"
|
} from "@opencode-ai/sdk/v2"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||||
import { normalize } from "@opencode-ai/session-ui/session-diff"
|
import { normalize } from "@opencode-ai/session-ui/session-diff"
|
||||||
@@ -806,6 +807,29 @@ export function MessageTimeline(props: {
|
|||||||
navigate(`/${params.dir}/session`)
|
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 archiveSession = async (sessionID: string) => {
|
||||||
const session = sync().session.get(sessionID)
|
const session = sync().session.get(sessionID)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
@@ -1564,6 +1588,9 @@ export function MessageTimeline(props: {
|
|||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
|
<DropdownMenu.Item onSelect={() => exportSession(id)}>
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("common.export")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
@@ -1635,6 +1662,9 @@ export function MessageTimeline(props: {
|
|||||||
{language.t("session.share.action.share")}...
|
{language.t("session.share.action.share")}...
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
|
<MenuV2.Item onSelect={() => exportSession(id)}>
|
||||||
|
{language.t("common.export")}...
|
||||||
|
</MenuV2.Item>
|
||||||
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
||||||
{language.t("common.archive")}
|
{language.t("common.archive")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import { useSettings } from "@/context/settings"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||||
import { findLast } from "@opencode-ai/core/util/array"
|
import { findLast } from "@opencode-ai/core/util/array"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
import { Message, Part, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionOwnership } from "./session-ownership"
|
import { createSessionOwnership } from "./session-ownership"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
@@ -231,6 +232,31 @@ 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 = () => {
|
const openFile = () => {
|
||||||
void openDialog(
|
void openDialog(
|
||||||
() => import("@/components/dialog-select-file"),
|
() => import("@/components/dialog-select-file"),
|
||||||
@@ -458,6 +484,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
disabled: !params.id || visibleUserMessages().length === 0,
|
disabled: !params.id || visibleUserMessages().length === 0,
|
||||||
onSelect: fork,
|
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 = () => {
|
const fileCmds = () => {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/codemode",
|
"name": "@opencode-ai/codemode",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"description": "Effect-native confined code execution over schema-described tools",
|
"description": "Effect-native confined code execution over schema-described tools",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -54,15 +54,15 @@ export default {
|
|||||||
console.log(JSON.stringify(data, null, 2))
|
console.log(JSON.stringify(data, null, 2))
|
||||||
|
|
||||||
const lakeIngest = getLakeIngest()
|
const lakeIngest = getLakeIngest()
|
||||||
const [honeycomb, lake] = await Promise.all([
|
const [lake] = await Promise.all([
|
||||||
fetch("https://api.honeycomb.io/1/batch/zen", {
|
// fetch("https://api.honeycomb.io/1/batch/zen", {
|
||||||
method: "POST",
|
// method: "POST",
|
||||||
headers: {
|
// headers: {
|
||||||
"Content-Type": "application/json",
|
// "Content-Type": "application/json",
|
||||||
"X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value,
|
// "X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value,
|
||||||
},
|
// },
|
||||||
body: JSON.stringify(events),
|
// body: JSON.stringify(events),
|
||||||
}),
|
// }),
|
||||||
...(lakeIngest
|
...(lakeIngest
|
||||||
? [
|
? [
|
||||||
fetch(lakeIngest.url, {
|
fetch(lakeIngest.url, {
|
||||||
@@ -76,8 +76,8 @@ export default {
|
|||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
])
|
])
|
||||||
console.log(honeycomb.status)
|
// console.log(honeycomb.status)
|
||||||
console.log(await honeycomb.text())
|
// console.log(await honeycomb.text())
|
||||||
if (lake) {
|
if (lake) {
|
||||||
console.log(lake.status)
|
console.log(lake.status)
|
||||||
console.log(await lake.text())
|
console.log(await lake.text())
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-support",
|
"name": "@opencode-ai/console-support",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -189,6 +189,11 @@ async function writeZip(output: string, entries: Entry[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initConsoleTransport() {
|
function initConsoleTransport() {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
log.transports.console.level = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const write = log.transports.console.writeFn.bind(log.transports.console)
|
const write = log.transports.console.writeFn.bind(log.transports.console)
|
||||||
log.transports.console.writeFn = (options) => {
|
log.transports.console.writeFn = (options) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"name": "@opencode-ai/effect-sqlite-node",
|
"name": "@opencode-ai/effect-sqlite-node",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
|
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ await Bun.build({
|
|||||||
external: ["jsonc-parser", "@lydell/node-pty"],
|
external: ["jsonc-parser", "@lydell/node-pty"],
|
||||||
define: {
|
define: {
|
||||||
OPENCODE_MODELS_DEV: generated.modelsData,
|
OPENCODE_MODELS_DEV: generated.modelsData,
|
||||||
|
OPENCODE_VERSION: `'${Script.version}'`,
|
||||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||||
},
|
},
|
||||||
files: {
|
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 UI_UPSTREAM = new URL("https://app.opencode.ai")
|
||||||
|
|
||||||
export const csp = (hash = "") =>
|
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:; font-src 'self' data:; media-src 'self' data:; connect-src * data:`
|
`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:`
|
||||||
export const DEFAULT_CSP = csp()
|
export const DEFAULT_CSP = csp()
|
||||||
|
|
||||||
export function themePreloadHash(body: string) {
|
export function themePreloadHash(body: string) {
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export const RETRY_BACKOFF_FACTOR = 2
|
|||||||
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
|
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
|
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) {
|
function cap(ms: number) {
|
||||||
return Math.min(ms, RETRY_MAX_DELAY)
|
return Math.min(ms, RETRY_MAX_DELAY)
|
||||||
}
|
}
|
||||||
@@ -72,7 +81,13 @@ export function retryable(error: Err, provider: string) {
|
|||||||
const status = error.data.statusCode
|
const status = error.data.statusCode
|
||||||
// 5xx errors are transient server failures and should always be retried,
|
// 5xx errors are transient server failures and should always be retried,
|
||||||
// even when the provider SDK doesn't explicitly mark them as retryable.
|
// even when the provider SDK doesn't explicitly mark them as retryable.
|
||||||
if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined
|
if (
|
||||||
|
!error.data.isRetryable &&
|
||||||
|
!(status !== undefined && status >= 500) &&
|
||||||
|
!matchesRetryableMessage(error.data.message) &&
|
||||||
|
!matchesRetryableMessage(error.data.responseBody)
|
||||||
|
)
|
||||||
|
return undefined
|
||||||
if (error.data.responseBody?.includes("FreeUsageLimitError")) {
|
if (error.data.responseBody?.includes("FreeUsageLimitError")) {
|
||||||
return {
|
return {
|
||||||
message: GO_UPSELL_MESSAGE,
|
message: GO_UPSELL_MESSAGE,
|
||||||
@@ -122,35 +137,19 @@ export function retryable(error: Err, provider: string) {
|
|||||||
return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message }
|
return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for rate limit patterns in plain text error messages
|
const message = isRecord(error.data) ? error.data.message : undefined
|
||||||
const msg = isRecord(error.data) ? error.data.message : undefined
|
if (typeof message !== "string") return undefined
|
||||||
if (typeof msg === "string") {
|
const lower = message.toLowerCase()
|
||||||
const lower = msg.toLowerCase()
|
if (lower.includes("too_many_requests")) return { message: "Too Many Requests" }
|
||||||
if (
|
if (lower.includes("exhausted") || lower.includes("unavailable")) return { message: "Provider is overloaded" }
|
||||||
lower.includes("rate increased too quickly") ||
|
if (matchesRetryableMessage(message)) return { message }
|
||||||
lower.includes("rate limit") ||
|
|
||||||
lower.includes("too many requests")
|
|
||||||
) {
|
|
||||||
return { message: msg }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = parseJSON(msg)
|
|
||||||
if (!json || typeof json !== "object") return undefined
|
|
||||||
const code = typeof json.code === "string" ? json.code : ""
|
|
||||||
|
|
||||||
if (json.type === "error" && json.error?.type === "too_many_requests") {
|
|
||||||
return { message: "Too Many Requests" }
|
|
||||||
}
|
|
||||||
if (code.includes("exhausted") || code.includes("unavailable")) {
|
|
||||||
return { message: "Provider is overloaded" }
|
|
||||||
}
|
|
||||||
if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
|
|
||||||
return { message: "Rate Limited" }
|
|
||||||
}
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesRetryableMessage(value: unknown) {
|
||||||
|
return typeof value === "string" && RETRYABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(value))
|
||||||
|
}
|
||||||
|
|
||||||
function str(value: unknown) {
|
function str(value: unknown) {
|
||||||
if (value === undefined || value === null) return ""
|
if (value === undefined || value === null) return ""
|
||||||
return String(value)
|
return String(value)
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ describe("HttpApi UI fallback", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("allows embedded UI terminal wasm and theme preload CSP", () =>
|
it.live("allows embedded UI terminal wasm, blob attachments, and theme preload CSP", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const script = 'document.documentElement.dataset.theme = "dark"'
|
const script = 'document.documentElement.dataset.theme = "dark"'
|
||||||
|
|
||||||
@@ -351,7 +351,8 @@ describe("HttpApi UI fallback", () => {
|
|||||||
const csp = response.headers.get("content-security-policy") ?? ""
|
const csp = response.headers.get("content-security-policy") ?? ""
|
||||||
expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'")
|
expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'")
|
||||||
expect(csp).toContain(`'sha256-${createHash("sha256").update(script).digest("base64")}'`)
|
expect(csp).toContain(`'sha256-${createHash("sha256").update(script).digest("base64")}'`)
|
||||||
expect(csp).toContain("connect-src * data:")
|
expect(csp).toContain("img-src 'self' data: https: blob:")
|
||||||
|
expect(csp).toContain("connect-src * data: blob:")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -604,6 +604,53 @@ 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", () =>
|
it.live("session.processor effect tests publish retry status updates", () =>
|
||||||
provideTmpdirServer(
|
provideTmpdirServer(
|
||||||
({ dir, llm }) =>
|
({ dir, llm }) =>
|
||||||
|
|||||||
@@ -118,16 +118,21 @@ describe("session.retry.delay", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("session.retry.retryable", () => {
|
describe("session.retry.retryable", () => {
|
||||||
test("maps too_many_requests json messages", () => {
|
test("retries serialized too_many_requests messages", () => {
|
||||||
const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
|
const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
|
||||||
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" })
|
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("maps overloaded provider codes", () => {
|
test("retries serialized overloaded provider codes", () => {
|
||||||
const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
|
const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
|
||||||
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" })
|
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("retries serialized rate_limit messages", () => {
|
||||||
|
const message = JSON.stringify({ type: "error", error: { code: "rate_limit_exceeded" } })
|
||||||
|
expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message })
|
||||||
|
})
|
||||||
|
|
||||||
test("does not retry unknown json messages", () => {
|
test("does not retry unknown json messages", () => {
|
||||||
const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
|
const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
|
||||||
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
|
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
|
||||||
@@ -163,6 +168,45 @@ describe("session.retry.retryable", () => {
|
|||||||
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
|
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", () => {
|
test("retries transport timeout errors", () => {
|
||||||
const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID })
|
const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID })
|
||||||
expect(SessionV1.APIError.isInstance(request)).toBe(true)
|
expect(SessionV1.APIError.isInstance(request)).toBe(true)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/server",
|
"name": "@opencode-ai/server",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/session-ui",
|
"name": "@opencode-ai/session-ui",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
// @refresh reload
|
// @refresh reload
|
||||||
|
import type { Asset, PageEvent } from "@solidjs/start"
|
||||||
import { createHandler, StartServer } from "@solidjs/start/server"
|
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 { getRequestEvent } from "solid-js/web"
|
||||||
import { dir, localeFromRequest, tag } from "./lib/language"
|
import { dir, localeFromRequest, tag } from "./lib/language"
|
||||||
|
import statsStylesheetUrl from "./routes/index.css?url"
|
||||||
|
|
||||||
const statsThemePreloadScript = `;(function () {
|
const statsThemePreloadScript = `;(function () {
|
||||||
var preference = "system"
|
var preference = "system"
|
||||||
@@ -18,8 +21,12 @@ export default createHandler(
|
|||||||
() => (
|
() => (
|
||||||
<StartServer
|
<StartServer
|
||||||
document={({ assets, children, scripts }) => {
|
document={({ assets, children, scripts }) => {
|
||||||
const event = getRequestEvent()
|
const event = getRequestEvent() as PageEvent | undefined
|
||||||
const locale = event ? localeFromRequest(event.request) : "en"
|
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 (
|
return (
|
||||||
<html lang={tag(locale)} dir={dir(locale)} data-locale={locale}>
|
<html lang={tag(locale)} dir={dir(locale)} data-locale={locale}>
|
||||||
@@ -27,6 +34,8 @@ export default createHandler(
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<script id="stats-theme-preload-script">{statsThemePreloadScript}</script>
|
<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}
|
{assets}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,34 +1,23 @@
|
|||||||
import { Meta, Title } from "@solidjs/meta"
|
import { Meta, Title } from "@solidjs/meta"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { geoEquirectangular, geoPath } from "d3-geo"
|
|
||||||
import { scaleSqrt } from "d3-scale"
|
import { scaleSqrt } from "d3-scale"
|
||||||
import countryCodesSource from "i18n-iso-countries/codes.json?raw"
|
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 {
|
import {
|
||||||
getStatsModelData,
|
getStatsModelData,
|
||||||
type CountryEntry,
|
type CountryEntry,
|
||||||
type ModelPeerEntry,
|
type ModelPeerEntry,
|
||||||
type ModelUsagePoint,
|
type ModelUsagePoint,
|
||||||
type StatsModelData,
|
type StatsModelData,
|
||||||
type UsageRange,
|
|
||||||
} from "@opencode-ai/stats-core/domain/home"
|
} from "@opencode-ai/stats-core/domain/home"
|
||||||
import { createAsync, query, useParams } from "@solidjs/router"
|
import { createAsync, query, useParams } from "@solidjs/router"
|
||||||
import { createMemo, createSignal, createUniqueId, For, onMount, Show, type JSX } from "solid-js"
|
import { createMemo, createSignal, createUniqueId, For, onMount, Show, type JSX } from "solid-js"
|
||||||
import { getRequestEvent } from "solid-js/web"
|
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 { LocaleLinks } from "../../component/locale-links"
|
||||||
import { useI18n } from "../../context/i18n"
|
import { useI18n } from "../../context/i18n"
|
||||||
import { useLanguage } from "../../context/language"
|
import { useLanguage } from "../../context/language"
|
||||||
import { localizedUrl } from "../../lib/language"
|
import { localizedUrl } from "../../lib/language"
|
||||||
import {
|
import { findModelCatalogEntry, formatCatalogLabName, loadModelCatalog, type ModelCatalogEntry } from "../model-catalog"
|
||||||
findModelCatalogEntry,
|
import { geoMapHeight, geoMapWidth, worldBorderPath, worldCountryMarkers, worldCountryPaths } from "../geo-map"
|
||||||
formatCatalogLabName,
|
|
||||||
getModelCatalog,
|
|
||||||
type ModelCatalog,
|
|
||||||
type ModelCatalogEntry,
|
|
||||||
} from "../model-catalog"
|
|
||||||
import { SectionHeading } from "../section-heading"
|
import { SectionHeading } from "../section-heading"
|
||||||
import { runStatsEffect } from "../../stats-runtime"
|
import { runStatsEffect } from "../../stats-runtime"
|
||||||
import { setStatsPageCacheHeaders } from "../stats-cache"
|
import { setStatsPageCacheHeaders } from "../stats-cache"
|
||||||
@@ -51,45 +40,41 @@ import {
|
|||||||
} from "../stats-shell"
|
} from "../stats-shell"
|
||||||
|
|
||||||
const statsUnfurlPath = "banner.png"
|
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
|
const shortMonths = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] as const
|
||||||
|
|
||||||
type IsoCountryCode = readonly [string, string, string]
|
type IsoCountryCode = readonly [string, string, string]
|
||||||
type WorldCountryProperties = GeoJsonProperties & { name?: string }
|
type ModelCatalogOption = Pick<ModelCatalogEntry, "id" | "lab" | "slug" | "name">
|
||||||
type WorldTopology = Topology<{ countries: GeometryCollection<WorldCountryProperties> }>
|
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 }
|
||||||
|
|
||||||
const countryNumericIds = new Map(
|
const countryNumericIds = new Map(
|
||||||
(JSON.parse(countryCodesSource) as IsoCountryCode[]).map((country) => [country[0], country[2]] as const),
|
(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 getModelData = query(async (lab: string, model: string) => {
|
const getModelPageData = query(async (labParam: string, modelParam: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
return runStatsEffect(getStatsModelData(model, lab))
|
const catalog = await loadModelCatalog()
|
||||||
}, "getStatsModelData")
|
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")
|
||||||
|
|
||||||
export default function StatsModel() {
|
export default function StatsModel() {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
@@ -99,18 +84,9 @@ export default function StatsModel() {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const labParam = createMemo(() => params.lab ?? "")
|
const labParam = createMemo(() => params.lab ?? "")
|
||||||
const modelParam = createMemo(() => params.model ?? "")
|
const modelParam = createMemo(() => params.model ?? "")
|
||||||
const catalog = createAsync(() => getModelCatalog())
|
const page = createAsync(() => getModelPageData(labParam(), modelParam()))
|
||||||
const catalogEntry = createMemo(() => {
|
const catalogEntry = createMemo(() => page()?.catalog.entry)
|
||||||
const data = catalog()
|
const stats = createMemo(() => page()?.stats)
|
||||||
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 githubStars = createAsync(() => getGitHubStars())
|
||||||
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
||||||
const modelName = createMemo(() => catalogEntry()?.name ?? stats()?.model ?? modelParam() ?? i18n.t("model.fallback"))
|
const modelName = createMemo(() => catalogEntry()?.name ?? stats()?.model ?? modelParam() ?? i18n.t("model.fallback"))
|
||||||
@@ -179,13 +155,13 @@ export default function StatsModel() {
|
|||||||
<Header githubStars={githubStars() ?? "150K"} links={modelHeaderLinks()} brandHref={import.meta.env.BASE_URL} />
|
<Header githubStars={githubStars() ?? "150K"} links={modelHeaderLinks()} brandHref={import.meta.env.BASE_URL} />
|
||||||
<div data-component="container">
|
<div data-component="container">
|
||||||
<div data-component="content">
|
<div data-component="content">
|
||||||
<Show when={catalogEntry() || stats() !== undefined} fallback={<ModelLoading />}>
|
<Show when={page() !== undefined} fallback={<ModelLoading />}>
|
||||||
<Show when={catalogEntry() || stats()} fallback={<ModelNotFound lab={labParam()} model={modelParam()} />}>
|
<Show when={catalogEntry() || stats()} fallback={<ModelNotFound lab={labParam()} model={modelParam()} />}>
|
||||||
<>
|
<>
|
||||||
<ModelHero
|
<ModelHero
|
||||||
data={stats() ?? null}
|
data={stats() ?? null}
|
||||||
catalog={catalogEntry() ?? null}
|
catalog={catalogEntry() ?? null}
|
||||||
catalogData={catalog() ?? null}
|
catalogData={page()?.catalog ?? null}
|
||||||
labName={labName()}
|
labName={labName()}
|
||||||
/>
|
/>
|
||||||
<ModelOverview catalog={catalogEntry() ?? null} />
|
<ModelOverview catalog={catalogEntry() ?? null} />
|
||||||
@@ -193,10 +169,10 @@ export default function StatsModel() {
|
|||||||
<ModelUsageSection data={stats() ?? null} />
|
<ModelUsageSection data={stats() ?? null} />
|
||||||
<ModelUniqueUsersSection data={stats() ?? null} />
|
<ModelUniqueUsersSection data={stats() ?? null} />
|
||||||
<ModelEfficiencySection data={stats() ?? null} catalog={catalogEntry() ?? null} />
|
<ModelEfficiencySection data={stats() ?? null} catalog={catalogEntry() ?? null} />
|
||||||
<ModelGeoBreakdownSection data={stats()?.country ?? emptyCountryRecord()} />
|
<ModelGeoBreakdownSection data={stats()?.country ?? []} />
|
||||||
<ModelPeersSection data={stats() ?? null} />
|
<ModelPeersSection data={stats() ?? null} />
|
||||||
<ComparisonCardsSection
|
<ComparisonCardsSection
|
||||||
pairs={modelComparisonPairs(catalog(), catalogEntry() ?? null, stats() ?? null)}
|
pairs={modelComparisonPairs(page()?.catalog.labModels, catalogEntry() ?? null, stats() ?? null)}
|
||||||
title="Compare This Model"
|
title="Compare This Model"
|
||||||
description="Other models to compare with this one."
|
description="Other models to compare with this one."
|
||||||
variant="featured"
|
variant="featured"
|
||||||
@@ -271,9 +247,9 @@ function ModelNotFound(props: { lab: string; model: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ModelHero(props: {
|
function ModelHero(props: {
|
||||||
data: StatsModelData | null
|
data: StatsModelPageData | null
|
||||||
catalog: ModelCatalogEntry | null
|
catalog: ModelCatalogEntry | null
|
||||||
catalogData: ModelCatalog | null
|
catalogData: ModelPageCatalog | null
|
||||||
labName: string
|
labName: string
|
||||||
}) {
|
}) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
@@ -282,9 +258,7 @@ function ModelHero(props: {
|
|||||||
const modelName = () => props.catalog?.name ?? props.data?.model ?? i18n.t("model.fallback")
|
const modelName = () => props.catalog?.name ?? props.data?.model ?? i18n.t("model.fallback")
|
||||||
const weights = () => props.catalog?.weights[0]
|
const weights = () => props.catalog?.weights[0]
|
||||||
const labs = () => props.catalogData?.labs ?? []
|
const labs = () => props.catalogData?.labs ?? []
|
||||||
const labModels = () =>
|
const labModels = () => props.catalogData?.labModels ?? (props.catalog ? [props.catalog] : [])
|
||||||
props.catalogData?.labs.find((lab) => lab.id === providerSlug(labId()))?.models ??
|
|
||||||
(props.catalog ? [props.catalog] : [])
|
|
||||||
return (
|
return (
|
||||||
<section id="overview" data-section="model-hero">
|
<section id="overview" data-section="model-hero">
|
||||||
<nav data-component="model-hero-breadcrumb" aria-label="Data breadcrumb">
|
<nav data-component="model-hero-breadcrumb" aria-label="Data breadcrumb">
|
||||||
@@ -403,7 +377,7 @@ function ModelHeroActionIcon(props: { kind: "weights" | "compare" }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelHeroSparkline(props: { data: StatsModelData }) {
|
function ModelHeroSparkline(props: { data: StatsModelPageData }) {
|
||||||
const values = () => props.data.usage.slice(-14).map((point) => point.tokens)
|
const values = () => props.data.usage.slice(-14).map((point) => point.tokens)
|
||||||
return (
|
return (
|
||||||
<span data-slot="model-hero-sparkline" aria-hidden="true">
|
<span data-slot="model-hero-sparkline" aria-hidden="true">
|
||||||
@@ -466,7 +440,7 @@ function ModelOverview(props: { catalog: ModelCatalogEntry | null }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelMomentumSection(props: { data: StatsModelData | null }) {
|
function ModelMomentumSection(props: { data: StatsModelPageData | null }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return (
|
return (
|
||||||
@@ -505,7 +479,7 @@ function ModelMomentumSection(props: { data: StatsModelData | null }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MomentumChart(props: { data: StatsModelData; locale: string }) {
|
function MomentumChart(props: { data: StatsModelPageData; locale: string }) {
|
||||||
const chart = createMemo(() => momentumChart(props.data.usage, props.data.updatedAt))
|
const chart = createMemo(() => momentumChart(props.data.usage, props.data.updatedAt))
|
||||||
const changeState = createMemo(() => (props.data.tokenChange < 0 ? "negative" : "positive"))
|
const changeState = createMemo(() => (props.data.tokenChange < 0 ? "negative" : "positive"))
|
||||||
return (
|
return (
|
||||||
@@ -562,7 +536,7 @@ function MomentumMetric(props: { label: string; value: string; watermark?: strin
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelUsageSection(props: { data: StatsModelData | null }) {
|
function ModelUsageSection(props: { data: StatsModelPageData | null }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
return (
|
return (
|
||||||
<ModelTrendSection
|
<ModelTrendSection
|
||||||
@@ -582,7 +556,7 @@ function ModelUsageSection(props: { data: StatsModelData | null }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelUniqueUsersSection(props: { data: StatsModelData | null }) {
|
function ModelUniqueUsersSection(props: { data: StatsModelPageData | null }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
return (
|
return (
|
||||||
<ModelTrendSection
|
<ModelTrendSection
|
||||||
@@ -606,7 +580,7 @@ function ModelUniqueUsersSection(props: { data: StatsModelData | null }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ModelTrendSection(props: {
|
function ModelTrendSection(props: {
|
||||||
data: StatsModelData | null
|
data: StatsModelPageData | null
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
@@ -859,7 +833,7 @@ function ModelTrendSection(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null }) {
|
function ModelEfficiencySection(props: { data: StatsModelPageData | null; catalog: ModelCatalogEntry | null }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
return (
|
return (
|
||||||
<section id="efficiency" data-section="model-panel">
|
<section id="efficiency" data-section="model-panel">
|
||||||
@@ -910,11 +884,11 @@ function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: M
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelGeoBreakdownSection(props: { data: Record<UsageRange, CountryEntry[]> }) {
|
function ModelGeoBreakdownSection(props: { data: CountryEntry[] }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [activeCountry, setActiveCountry] = createSignal<string>()
|
const [activeCountry, setActiveCountry] = createSignal<string>()
|
||||||
const data = createMemo(() => props.data["2M"])
|
const data = createMemo(() => props.data)
|
||||||
const countryById = createMemo(
|
const countryById = createMemo(
|
||||||
() =>
|
() =>
|
||||||
new Map(
|
new Map(
|
||||||
@@ -1031,31 +1005,30 @@ function GeoWorldMap(props: {
|
|||||||
</For>
|
</For>
|
||||||
</g>
|
</g>
|
||||||
<g data-slot="geo-country-markers">
|
<g data-slot="geo-country-markers">
|
||||||
<For each={worldCountryPaths}>
|
<For each={worldCountryMarkers}>
|
||||||
{(country) => {
|
{(country) => {
|
||||||
const entry = () => props.countryById.get(country.id)
|
const entry = () => props.countryById.get(country.id)
|
||||||
return (
|
return (
|
||||||
<Show when={country.marker && entry() ? country.marker : undefined}>
|
<Show when={entry()}>
|
||||||
{(marker) => (
|
<circle
|
||||||
<circle
|
cx={country.marker.x}
|
||||||
cx={marker().x}
|
cy={country.marker.y}
|
||||||
cy={marker().y}
|
data-country-id={country.id}
|
||||||
r={entry()?.country === props.activeCountry ? 3.4 : 2.4}
|
r={entry()?.country === props.activeCountry ? 3.4 : 2.4}
|
||||||
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
||||||
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
onPointerEnter={() => {
|
onPointerEnter={() => {
|
||||||
const item = entry()
|
const item = entry()
|
||||||
if (!item) return
|
if (!item) return
|
||||||
props.onActiveCountryChange(item.country)
|
props.onActiveCountryChange(item.country)
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const item = entry()
|
const item = entry()
|
||||||
if (!item) return
|
if (!item) return
|
||||||
props.onActiveCountryChange(item.country)
|
props.onActiveCountryChange(item.country)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -1103,7 +1076,7 @@ function GeoCountryList(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelPeersSection(props: { data: StatsModelData | null }) {
|
function ModelPeersSection(props: { data: StatsModelPageData | null }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
return (
|
return (
|
||||||
<section id="peers" data-section="model-panel">
|
<section id="peers" data-section="model-panel">
|
||||||
@@ -1172,9 +1145,9 @@ function ModelEmptyState(props: { title: string; description: string; compact?:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function modelComparisonPairs(
|
function modelComparisonPairs(
|
||||||
catalog: ModelCatalog | undefined,
|
catalogModels: ModelCatalogOption[] | undefined,
|
||||||
catalogEntry: ModelCatalogEntry | null,
|
catalogEntry: ModelCatalogEntry | null,
|
||||||
data: StatsModelData | null,
|
data: StatsModelPageData | null,
|
||||||
) {
|
) {
|
||||||
const current = modelComparisonRef(catalogEntry, data)
|
const current = modelComparisonRef(catalogEntry, data)
|
||||||
if (!current) return []
|
if (!current) return []
|
||||||
@@ -1192,9 +1165,7 @@ function modelComparisonPairs(
|
|||||||
},
|
},
|
||||||
detail: "Usage peer",
|
detail: "Usage peer",
|
||||||
}))
|
}))
|
||||||
const catalogPairs = (
|
const catalogPairs = (catalogEntry ? (catalogModels ?? []) : [])
|
||||||
catalogEntry && catalog ? (catalog.labs.find((lab) => lab.id === catalogEntry.lab)?.models ?? []) : []
|
|
||||||
)
|
|
||||||
.filter((model) => model.id !== catalogEntry?.id)
|
.filter((model) => model.id !== catalogEntry?.id)
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((model) => ({
|
.map((model) => ({
|
||||||
@@ -1207,7 +1178,7 @@ function modelComparisonPairs(
|
|||||||
|
|
||||||
function modelComparisonRef(
|
function modelComparisonRef(
|
||||||
catalogEntry: ModelCatalogEntry | null,
|
catalogEntry: ModelCatalogEntry | null,
|
||||||
data: StatsModelData | null,
|
data: StatsModelPageData | null,
|
||||||
): ComparisonModelRef | undefined {
|
): ComparisonModelRef | undefined {
|
||||||
if (catalogEntry) return modelRefFromCatalog(catalogEntry)
|
if (catalogEntry) return modelRefFromCatalog(catalogEntry)
|
||||||
if (!data) return undefined
|
if (!data) return undefined
|
||||||
@@ -1227,31 +1198,10 @@ function getProviderIconId(author: string) {
|
|||||||
return author.toLowerCase().replace(/[^a-z0-9]+/g, "")
|
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) {
|
function countryNumericId(country: string) {
|
||||||
return countryNumericIds.get(country.toUpperCase())?.padStart(3, "0")
|
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>) {
|
function formatCountryName(country: string, locale: string, i18n: ReturnType<typeof useI18n>) {
|
||||||
const code = country.toUpperCase()
|
const code = country.toUpperCase()
|
||||||
if (code === "ZZ") return i18n.t("home.unknown")
|
if (code === "ZZ") return i18n.t("home.unknown")
|
||||||
@@ -1508,7 +1458,7 @@ function formatSparklinePoint(value: number) {
|
|||||||
return Number(value.toFixed(2)).toString()
|
return Number(value.toFixed(2)).toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatModelRankMoveLabel(data: StatsModelData, i18n: ReturnType<typeof useI18n>) {
|
function formatModelRankMoveLabel(data: StatsModelPageData, i18n: ReturnType<typeof useI18n>) {
|
||||||
if (data.rank === null) return i18n.t("model.noUsageLastWeek")
|
if (data.rank === null) return i18n.t("model.noUsageLastWeek")
|
||||||
if (data.previousRank === null) return i18n.t("model.newThisWeek")
|
if (data.previousRank === null) return i18n.t("model.newThisWeek")
|
||||||
const change = data.previousRank - data.rank
|
const change = data.previousRank - data.rank
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
type LabUsageModelEntry,
|
type LabUsageModelEntry,
|
||||||
type MarketDay,
|
type MarketDay,
|
||||||
type ModelUsagePoint,
|
type ModelUsagePoint,
|
||||||
type StatsHomeData,
|
|
||||||
type StatsLabData,
|
type StatsLabData,
|
||||||
} from "@opencode-ai/stats-core/domain/home"
|
} from "@opencode-ai/stats-core/domain/home"
|
||||||
import { createAsync, query, useParams } from "@solidjs/router"
|
import { createAsync, query, useParams } from "@solidjs/router"
|
||||||
@@ -20,7 +19,7 @@ import {
|
|||||||
catalogSlug,
|
catalogSlug,
|
||||||
findModelCatalogLab,
|
findModelCatalogLab,
|
||||||
formatCatalogLabName,
|
formatCatalogLabName,
|
||||||
getModelCatalog,
|
loadModelCatalog,
|
||||||
type ModelCatalogEntry,
|
type ModelCatalogEntry,
|
||||||
type ModelCatalogLab,
|
type ModelCatalogLab,
|
||||||
} from "../model-catalog"
|
} from "../model-catalog"
|
||||||
@@ -42,15 +41,33 @@ import {
|
|||||||
|
|
||||||
const statsUnfurlPath = "banner.png"
|
const statsUnfurlPath = "banner.png"
|
||||||
|
|
||||||
const getLabData = query(async (lab: string) => {
|
type RelatedCatalogLab = Pick<ModelCatalogLab, "id" | "name" | "description"> & {
|
||||||
"use server"
|
models: Pick<ModelCatalogEntry, "name">[]
|
||||||
return runStatsEffect(getStatsLabData(lab))
|
}
|
||||||
}, "getStatsLabData")
|
|
||||||
|
|
||||||
const getHomeData = query(async () => {
|
type LabPageData = {
|
||||||
|
lab: ModelCatalogLab | null
|
||||||
|
labs: RelatedCatalogLab[]
|
||||||
|
market: MarketDay[]
|
||||||
|
stats: StatsLabData | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLabPageData = query(async (labParam: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
return runStatsEffect(getStatsHomeData())
|
const [catalog, home] = await Promise.all([loadModelCatalog(), runStatsEffect(getStatsHomeData())])
|
||||||
}, "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")
|
||||||
|
|
||||||
type LabModelTooltipState = {
|
type LabModelTooltipState = {
|
||||||
model: ModelCatalogEntry
|
model: ModelCatalogEntry
|
||||||
@@ -67,19 +84,9 @@ export default function StatsLab() {
|
|||||||
setStatsPageCacheHeaders(event?.response.headers)
|
setStatsPageCacheHeaders(event?.response.headers)
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const labParam = createMemo(() => params.lab ?? "")
|
const labParam = createMemo(() => params.lab ?? "")
|
||||||
const catalog = createAsync(() => getModelCatalog())
|
const page = createAsync(() => getLabPageData(labParam()))
|
||||||
const lab = createMemo(() => {
|
const lab = createMemo(() => page()?.lab)
|
||||||
const data = catalog()
|
const stats = createMemo(() => page()?.stats)
|
||||||
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 githubStars = createAsync(() => getGitHubStars())
|
||||||
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
||||||
const labName = createMemo(() => lab()?.name ?? formatCatalogLabName(labParam()))
|
const labName = createMemo(() => lab()?.name ?? formatCatalogLabName(labParam()))
|
||||||
@@ -137,19 +144,15 @@ export default function StatsLab() {
|
|||||||
<Header githubStars={githubStars() ?? "150K"} links={labHeaderLinks()} brandHref={import.meta.env.BASE_URL} />
|
<Header githubStars={githubStars() ?? "150K"} links={labHeaderLinks()} brandHref={import.meta.env.BASE_URL} />
|
||||||
<div data-component="container">
|
<div data-component="container">
|
||||||
<div data-component="content">
|
<div data-component="content">
|
||||||
<Show when={catalog() !== undefined} fallback={<LabLoading />}>
|
<Show when={page() !== undefined} fallback={<LabLoading />}>
|
||||||
<Show when={lab()} fallback={<LabNotFound lab={labParam()} labs={catalog()?.labs ?? []} />}>
|
<Show when={lab()} fallback={<LabNotFound lab={labParam()} labs={page()?.labs ?? []} />}>
|
||||||
{(data) => (
|
{(data) => (
|
||||||
<>
|
<>
|
||||||
<LabHero lab={data()} labs={catalog()?.labs ?? []} />
|
<LabHero lab={data()} labs={page()?.labs ?? []} />
|
||||||
<LabOverview lab={data()} data={stats() ?? null} />
|
<LabOverview lab={data()} data={stats() ?? null} />
|
||||||
<LabUsageSection lab={data()} data={stats() ?? null} />
|
<LabUsageSection lab={data()} data={stats() ?? null} />
|
||||||
<LabModelsSection lab={data()} usage={stats()?.models ?? []} />
|
<LabModelsSection lab={data()} usage={stats()?.models ?? []} />
|
||||||
<LabRelatedSection
|
<LabRelatedSection lab={data()} labs={page()?.labs ?? []} market={page()?.market ?? []} />
|
||||||
lab={data()}
|
|
||||||
labs={catalog()?.labs ?? []}
|
|
||||||
market={homeStats()?.market["2M"] ?? []}
|
|
||||||
/>
|
|
||||||
<ComparisonCardsSection
|
<ComparisonCardsSection
|
||||||
pairs={labComparisonPairs(data(), stats()?.models ?? [])}
|
pairs={labComparisonPairs(data(), stats()?.models ?? [])}
|
||||||
title={`${data().name} Model Comparisons`}
|
title={`${data().name} Model Comparisons`}
|
||||||
@@ -182,7 +185,7 @@ function LabLoading() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LabNotFound(props: { lab: string; labs: ModelCatalogLab[] }) {
|
function LabNotFound(props: { lab: string; labs: RelatedCatalogLab[] }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const labName = () => formatCatalogLabName(props.lab)
|
const labName = () => formatCatalogLabName(props.lab)
|
||||||
return (
|
return (
|
||||||
@@ -194,7 +197,7 @@ function LabNotFound(props: { lab: string; labs: ModelCatalogLab[] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LabHero(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[] }) {
|
function LabHero(props: { lab: ModelCatalogLab; labs: RelatedCatalogLab[] }) {
|
||||||
return (
|
return (
|
||||||
<section id="overview" data-section="lab-hero">
|
<section id="overview" data-section="lab-hero">
|
||||||
<LabHeroBreadcrumb label={props.lab.name} labs={props.labs} />
|
<LabHeroBreadcrumb label={props.lab.name} labs={props.labs} />
|
||||||
@@ -203,7 +206,7 @@ function LabHero(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LabHeroBreadcrumb(props: { label: string; labs?: ModelCatalogLab[] }) {
|
function LabHeroBreadcrumb(props: { label: string; labs?: RelatedCatalogLab[] }) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const labs = () => props.labs ?? []
|
const labs = () => props.labs ?? []
|
||||||
const current = () => labs().find((lab) => lab.name === props.label)
|
const current = () => labs().find((lab) => lab.name === props.label)
|
||||||
@@ -668,7 +671,7 @@ function LabModelTooltip(props: { state: LabModelTooltipState }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LabRelatedSection(props: { lab: ModelCatalogLab; labs: ModelCatalogLab[]; market: MarketDay[] }) {
|
function LabRelatedSection(props: { lab: ModelCatalogLab; labs: RelatedCatalogLab[]; market: MarketDay[] }) {
|
||||||
const related = createMemo(() => relatedLabs(props.lab, props.labs, props.market))
|
const related = createMemo(() => relatedLabs(props.lab, props.labs, props.market))
|
||||||
return (
|
return (
|
||||||
<section id="related-labs" data-section="model-panel" data-variant="lab-related">
|
<section id="related-labs" data-section="model-panel" data-variant="lab-related">
|
||||||
@@ -757,9 +760,9 @@ function labComparisonPairs(lab: ModelCatalogLab, usage: LabUsageModelEntry[]) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type RelatedLabEntry = { lab: ModelCatalogLab; share: number; tokens: number }
|
type RelatedLabEntry = { lab: RelatedCatalogLab; share: number; tokens: number }
|
||||||
|
|
||||||
function relatedLabs(current: ModelCatalogLab, labs: ModelCatalogLab[], market: MarketDay[]): RelatedLabEntry[] {
|
function relatedLabs(current: ModelCatalogLab, labs: RelatedCatalogLab[], market: MarketDay[]): RelatedLabEntry[] {
|
||||||
const stats = relatedLabStats(labs, market)
|
const stats = relatedLabStats(labs, market)
|
||||||
return labs
|
return labs
|
||||||
.filter((lab) => lab.id !== current.id)
|
.filter((lab) => lab.id !== current.id)
|
||||||
@@ -768,8 +771,8 @@ function relatedLabs(current: ModelCatalogLab, labs: ModelCatalogLab[], market:
|
|||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
function relatedLabStats(labs: ModelCatalogLab[], market: MarketDay[]) {
|
function relatedLabStats(labs: RelatedCatalogLab[], market: MarketDay[]) {
|
||||||
const labByKey = new Map<string, ModelCatalogLab>()
|
const labByKey = new Map<string, RelatedCatalogLab>()
|
||||||
labs.forEach((lab) => {
|
labs.forEach((lab) => {
|
||||||
labByKey.set(lab.id, lab)
|
labByKey.set(lab.id, lab)
|
||||||
labByKey.set(catalogSlug(lab.name), lab)
|
labByKey.set(catalogSlug(lab.name), lab)
|
||||||
@@ -794,7 +797,7 @@ function relatedLabStats(labs: ModelCatalogLab[], market: MarketDay[]) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function labRelatedDescription(lab: ModelCatalogLab) {
|
function labRelatedDescription(lab: RelatedCatalogLab) {
|
||||||
return lab.description ?? ""
|
return lab.description ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export type ComparisonPair = {
|
|||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function modelRefFromCatalog(entry: ModelCatalogEntry): ComparisonModelRef {
|
export function modelRefFromCatalog(entry: Pick<ModelCatalogEntry, "name" | "lab" | "slug">): ComparisonModelRef {
|
||||||
return {
|
return {
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
lab: entry.lab,
|
lab: entry.lab,
|
||||||
@@ -83,11 +83,7 @@ export function ComparisonCardsSection(props: {
|
|||||||
|
|
||||||
function FeaturedComparisonCard(props: { pair: ComparisonPair }) {
|
function FeaturedComparisonCard(props: { pair: ComparisonPair }) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a data-component="compare-home-card" href={canonicalComparisonHref(props.pair.first, props.pair.second)}>
|
||||||
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 data-slot="compare-home-card-head">
|
||||||
<span>
|
<span>
|
||||||
<strong>{props.pair.detail}</strong>
|
<strong>{props.pair.detail}</strong>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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: #161616;
|
||||||
--color-background-strong-hover: #242424;
|
--color-background-strong-hover: #242424;
|
||||||
--color-text: #5c5c5c;
|
--color-text: #5c5c5c;
|
||||||
--color-text-weak: #808080;
|
--color-text-weak: #707070;
|
||||||
--color-text-strong: #161616;
|
--color-text-strong: #161616;
|
||||||
--color-text-inverted: #ffffff;
|
--color-text-inverted: #ffffff;
|
||||||
--color-border-weak: #0000001a;
|
--color-border-weak: #0000001a;
|
||||||
@@ -66,10 +66,10 @@ body {
|
|||||||
--stats-line-strong: #00000033;
|
--stats-line-strong: #00000033;
|
||||||
--stats-text: #161616;
|
--stats-text: #161616;
|
||||||
--stats-muted: #5c5c5c;
|
--stats-muted: #5c5c5c;
|
||||||
--stats-faint: #808080;
|
--stats-faint: #707070;
|
||||||
--stats-theme-icon-active: #3a3a3a;
|
--stats-theme-icon-active: #3a3a3a;
|
||||||
--stats-accent: #3b5cf6;
|
--stats-accent: #3b5cf6;
|
||||||
--stats-accent-text: #6c7dff;
|
--stats-accent-text: #3b5cf6;
|
||||||
--stats-bar-idle: #d4d4d4;
|
--stats-bar-idle: #d4d4d4;
|
||||||
--stats-dot: #d4d4d4;
|
--stats-dot: #d4d4d4;
|
||||||
--stats-hero-muted: #5c5c5c;
|
--stats-hero-muted: #5c5c5c;
|
||||||
@@ -92,6 +92,18 @@ body {
|
|||||||
display: none !important;
|
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"] {
|
[data-page="stats"][data-layout="compare-detail"] {
|
||||||
/* The table contains its own wide rows; keep the page itself out of the horizontal scroll chain. */
|
/* The table contains its own wide rows; keep the page itself out of the horizontal scroll chain. */
|
||||||
overflow-x: visible;
|
overflow-x: visible;
|
||||||
@@ -7079,7 +7091,7 @@ body {
|
|||||||
--color-background-strong: #ffffff;
|
--color-background-strong: #ffffff;
|
||||||
--color-background-strong-hover: #eeeeee;
|
--color-background-strong-hover: #eeeeee;
|
||||||
--color-text: #d4d4d4;
|
--color-text: #d4d4d4;
|
||||||
--color-text-weak: #808080;
|
--color-text-weak: #a3a3a3;
|
||||||
--color-text-strong: #ffffff;
|
--color-text-strong: #ffffff;
|
||||||
--color-text-inverted: #161616;
|
--color-text-inverted: #161616;
|
||||||
--color-border-weak: #ffffff1a;
|
--color-border-weak: #ffffff1a;
|
||||||
@@ -7090,11 +7102,12 @@ body {
|
|||||||
--stats-line-strong: #ffffff33;
|
--stats-line-strong: #ffffff33;
|
||||||
--stats-text: #ffffff;
|
--stats-text: #ffffff;
|
||||||
--stats-muted: #d4d4d4;
|
--stats-muted: #d4d4d4;
|
||||||
--stats-faint: #808080;
|
--stats-faint: #a3a3a3;
|
||||||
|
--stats-accent-text: #8190ff;
|
||||||
--stats-theme-icon-active: #fafafa;
|
--stats-theme-icon-active: #fafafa;
|
||||||
--stats-bar-idle: #303030;
|
--stats-bar-idle: #303030;
|
||||||
--stats-dot: #303030;
|
--stats-dot: #303030;
|
||||||
--stats-hero-muted: #808080;
|
--stats-hero-muted: #a3a3a3;
|
||||||
--stats-hero-pattern: #303030;
|
--stats-hero-pattern: #303030;
|
||||||
--stats-logo-bg: #f1ecec;
|
--stats-logo-bg: #f1ecec;
|
||||||
--stats-logo-fill: #b7b1b1;
|
--stats-logo-fill: #b7b1b1;
|
||||||
@@ -7254,7 +7267,7 @@ body {
|
|||||||
--color-background-strong: #ffffff;
|
--color-background-strong: #ffffff;
|
||||||
--color-background-strong-hover: #eeeeee;
|
--color-background-strong-hover: #eeeeee;
|
||||||
--color-text: #d4d4d4;
|
--color-text: #d4d4d4;
|
||||||
--color-text-weak: #808080;
|
--color-text-weak: #a3a3a3;
|
||||||
--color-text-strong: #ffffff;
|
--color-text-strong: #ffffff;
|
||||||
--color-text-inverted: #161616;
|
--color-text-inverted: #161616;
|
||||||
--color-border-weak: #ffffff1a;
|
--color-border-weak: #ffffff1a;
|
||||||
@@ -7265,11 +7278,12 @@ body {
|
|||||||
--stats-line-strong: #ffffff33;
|
--stats-line-strong: #ffffff33;
|
||||||
--stats-text: #ffffff;
|
--stats-text: #ffffff;
|
||||||
--stats-muted: #d4d4d4;
|
--stats-muted: #d4d4d4;
|
||||||
--stats-faint: #808080;
|
--stats-faint: #a3a3a3;
|
||||||
|
--stats-accent-text: #8190ff;
|
||||||
--stats-theme-icon-active: #fafafa;
|
--stats-theme-icon-active: #fafafa;
|
||||||
--stats-bar-idle: #303030;
|
--stats-bar-idle: #303030;
|
||||||
--stats-dot: #303030;
|
--stats-dot: #303030;
|
||||||
--stats-hero-muted: #808080;
|
--stats-hero-muted: #a3a3a3;
|
||||||
--stats-hero-pattern: #303030;
|
--stats-hero-pattern: #303030;
|
||||||
--stats-logo-bg: #f1ecec;
|
--stats-logo-bg: #f1ecec;
|
||||||
--stats-logo-fill: #b7b1b1;
|
--stats-logo-fill: #b7b1b1;
|
||||||
@@ -8393,7 +8407,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] {
|
[data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] {
|
||||||
--top-models-mobile-bar-width: 12px;
|
--top-models-mobile-bar-width: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] [data-slot="top-models-axis"],
|
[data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] [data-slot="top-models-axis"],
|
||||||
@@ -8402,7 +8416,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-component="market-share"][data-dense-labels="true"] {
|
[data-page="stats"] [data-component="market-share"][data-dense-labels="true"] {
|
||||||
--market-mobile-bar-width: 12px;
|
--market-mobile-bar-width: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-page="stats"] [data-component="market-share"][data-dense-labels="true"] [data-slot="market-labels"],
|
[data-page="stats"] [data-component="market-share"][data-dense-labels="true"] [data-slot="market-labels"],
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
import { Link, Meta, Title } from "@solidjs/meta"
|
import { Meta, Title } from "@solidjs/meta"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { geoEquirectangular, geoPath } from "d3-geo"
|
|
||||||
import { scaleSqrt } from "d3-scale"
|
import { scaleSqrt } from "d3-scale"
|
||||||
import countryCodesSource from "i18n-iso-countries/codes.json?raw"
|
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 {
|
import {
|
||||||
getStatsHomeData,
|
getStatsHomeData,
|
||||||
type CacheRatioEntry,
|
type CacheRatioEntry,
|
||||||
type CountryEntry,
|
type CountryEntry,
|
||||||
type LeaderboardEntry,
|
type LeaderboardEntry,
|
||||||
type MarketDay,
|
type MarketDay,
|
||||||
type StatsHomeData,
|
|
||||||
type SessionCostEntry,
|
type SessionCostEntry,
|
||||||
type TokenCostEntry,
|
type TokenCostEntry,
|
||||||
type UsagePoint,
|
type UsagePoint,
|
||||||
@@ -23,14 +15,13 @@ import {
|
|||||||
import { createAsync, query } from "@solidjs/router"
|
import { createAsync, query } from "@solidjs/router"
|
||||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||||
import { getRequestEvent } from "solid-js/web"
|
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 { runStatsEffect } from "../stats-runtime"
|
||||||
import { LocaleLinks } from "../component/locale-links"
|
import { LocaleLinks } from "../component/locale-links"
|
||||||
import { useI18n } from "../context/i18n"
|
import { useI18n } from "../context/i18n"
|
||||||
import { useLanguage } from "../context/language"
|
import { useLanguage } from "../context/language"
|
||||||
import { localizedUrl } from "../lib/language"
|
import { localizedUrl } from "../lib/language"
|
||||||
import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog"
|
import { findModelCatalogEntry, loadModelCatalog, type ModelCatalog } from "./model-catalog"
|
||||||
|
import { geoMapHeight, geoMapWidth, worldBorderPath, worldCountryMarkers, worldCountryPaths } from "./geo-map"
|
||||||
import { SectionHeading } from "./section-heading"
|
import { SectionHeading } from "./section-heading"
|
||||||
import { setStatsPageCacheHeaders } from "./stats-cache"
|
import { setStatsPageCacheHeaders } from "./stats-cache"
|
||||||
import { ComparisonCardsSection, uniqueComparisonPairs, type ComparisonModelRef } from "./compare-cards"
|
import { ComparisonCardsSection, uniqueComparisonPairs, type ComparisonModelRef } from "./compare-cards"
|
||||||
@@ -45,9 +36,6 @@ import {
|
|||||||
type ThemePreference,
|
type ThemePreference,
|
||||||
} from "./stats-shell"
|
} 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 = [
|
const comparisonPairIndexes = [
|
||||||
[0, 1, "Top two by recent usage"],
|
[0, 1, "Top two by recent usage"],
|
||||||
[0, 2, "Leader vs challenger"],
|
[0, 2, "Leader vs challenger"],
|
||||||
@@ -69,60 +57,40 @@ const usageColors = [
|
|||||||
"#ff6467",
|
"#ff6467",
|
||||||
]
|
]
|
||||||
const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"]
|
const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"]
|
||||||
const geoMapWidth = 960
|
|
||||||
const geoMapHeight = 430
|
|
||||||
|
|
||||||
type UsageProduct = (typeof products)[number]
|
type UsageRange = "1D" | "1W" | "2W" | "1M" | "2M"
|
||||||
type TokenProduct = (typeof tokenProducts)[number]
|
|
||||||
type UsageRange = (typeof ranges)[number]
|
|
||||||
type IsoCountryCode = readonly [string, string, string]
|
type IsoCountryCode = readonly [string, string, string]
|
||||||
type WorldCountryProperties = GeoJsonProperties & { name?: string }
|
|
||||||
type WorldTopology = Topology<{ countries: GeometryCollection<WorldCountryProperties> }>
|
|
||||||
|
|
||||||
function productLabel(product: UsageProduct | TokenProduct, i18n: ReturnType<typeof useI18n>) {
|
type StatsHomePageData = {
|
||||||
if (product === "All Users") return i18n.t("product.allUsers")
|
updatedAt: string | null
|
||||||
if (product === "Zen") return i18n.t("product.zen")
|
usage: UsagePoint[]
|
||||||
return i18n.t("product.go")
|
users: UsagePoint[]
|
||||||
}
|
leaderboard: LeaderboardEntry[]
|
||||||
|
market: MarketDay[]
|
||||||
function rangeLabel(range: UsageRange, i18n: ReturnType<typeof useI18n>) {
|
tokenCost: TokenCostEntry[]
|
||||||
if (range === "1D") return i18n.t("range.1D")
|
cacheRatio: CacheRatioEntry[]
|
||||||
if (range === "1W") return i18n.t("range.1W")
|
sessionCost: SessionCostEntry[]
|
||||||
if (range === "2W") return i18n.t("range.2W")
|
country: CountryEntry[]
|
||||||
if (range === "1M") return i18n.t("range.1M")
|
|
||||||
return i18n.t("range.2M")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const countryNumericIds = new Map(
|
const countryNumericIds = new Map(
|
||||||
(JSON.parse(countryCodesSource) as IsoCountryCode[]).map((country) => [country[0], country[2]] as const),
|
(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 () => {
|
const getData = query(async () => {
|
||||||
"use server"
|
"use server"
|
||||||
return runStatsEffect(getStatsHomeData())
|
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
|
||||||
}, "getStatsHomeData")
|
}, "getStatsHomeData")
|
||||||
|
|
||||||
export default function StatsHome() {
|
export default function StatsHome() {
|
||||||
@@ -133,7 +101,6 @@ export default function StatsHome() {
|
|||||||
const statsHomeUrl = localizedUrl(language.locale(), "/data/")
|
const statsHomeUrl = localizedUrl(language.locale(), "/data/")
|
||||||
const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString()
|
const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString()
|
||||||
const data = createAsync(() => getData())
|
const data = createAsync(() => getData())
|
||||||
const catalog = createAsync(() => getModelCatalog())
|
|
||||||
const githubStars = createAsync(() => getGitHubStars())
|
const githubStars = createAsync(() => getGitHubStars())
|
||||||
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
||||||
const updateThemePreference = (preference: ThemePreference) => {
|
const updateThemePreference = (preference: ThemePreference) => {
|
||||||
@@ -171,10 +138,6 @@ export default function StatsHome() {
|
|||||||
<Meta name="twitter:description" content={i18n.t("app.description")} />
|
<Meta name="twitter:description" content={i18n.t("app.description")} />
|
||||||
<Meta name="twitter:image" content={statsUnfurlUrl} />
|
<Meta name="twitter:image" content={statsUnfurlUrl} />
|
||||||
<Meta name="twitter:image:alt" content={i18n.t("app.unfurlAlt")} />
|
<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} />
|
<Header githubStars={githubStars() ?? githubLink.fallbackStars} />
|
||||||
<div data-component="container">
|
<div data-component="container">
|
||||||
<div data-component="content">
|
<div data-component="content">
|
||||||
@@ -185,12 +148,12 @@ export default function StatsHome() {
|
|||||||
<TopModelsSection data={stats().usage} leaderboard={stats().leaderboard} />
|
<TopModelsSection data={stats().usage} leaderboard={stats().leaderboard} />
|
||||||
<UniqueUsersSection data={stats().users} />
|
<UniqueUsersSection data={stats().users} />
|
||||||
<SessionCostSection data={stats().sessionCost} />
|
<SessionCostSection data={stats().sessionCost} />
|
||||||
<TokenCostSection data={stats().tokenCost} catalog={catalog() ?? null} />
|
<TokenCostSection data={stats().tokenCost} />
|
||||||
<CacheRatioSection data={stats().cacheRatio} />
|
<CacheRatioSection data={stats().cacheRatio} />
|
||||||
<MarketShareSection data={stats().market} />
|
<MarketShareSection data={stats().market} />
|
||||||
<GeoBreakdownSection data={stats().country} />
|
<GeoBreakdownSection data={stats().country} />
|
||||||
<ComparisonCardsSection
|
<ComparisonCardsSection
|
||||||
pairs={homeComparisonPairs(stats().leaderboard["All Users"]["2M"])}
|
pairs={homeComparisonPairs(stats().leaderboard)}
|
||||||
title="Model Comparisons"
|
title="Model Comparisons"
|
||||||
description="Popular model pairs from the leaderboard."
|
description="Popular model pairs from the leaderboard."
|
||||||
variant="featured"
|
variant="featured"
|
||||||
@@ -256,7 +219,8 @@ function Hero(props: { updatedAt: string | null }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section data-section="hero">
|
<section data-section="hero">
|
||||||
<p data-slot="hero-meta" aria-live="polite" aria-atomic="true" aria-label={currentUpdatedLabel()}>
|
<p data-slot="hero-meta" role="status" aria-atomic="true">
|
||||||
|
<span data-slot="visually-hidden">{currentUpdatedLabel()}</span>
|
||||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
|
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
@@ -401,32 +365,9 @@ function formatUpdatedAtLabel(value: { date: string; time: string }) {
|
|||||||
return `${value.date}, ${value.time}`
|
return `${value.date}, ${value.time}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: StatsHomeData["leaderboard"] }) {
|
function TopModelsSection(props: { data: UsagePoint[]; leaderboard: LeaderboardEntry[] }) {
|
||||||
const i18n = useI18n()
|
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 [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 (
|
return (
|
||||||
<section id="top-models" data-section="top-models">
|
<section id="top-models" data-section="top-models">
|
||||||
@@ -438,201 +379,23 @@ function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: St
|
|||||||
description={i18n.t("home.topModelsDescription")}
|
description={i18n.t("home.topModelsDescription")}
|
||||||
/>
|
/>
|
||||||
<Show
|
<Show
|
||||||
when={data().some((item) => usageTotal(item) > 0)}
|
when={props.data.some((item) => usageTotal(item) > 0)}
|
||||||
fallback={<EmptyState title={i18n.t("home.noUsageTitle")} description={i18n.t("home.noUsageDescription")} />}
|
fallback={<EmptyState title={i18n.t("home.noUsageTitle")} description={i18n.t("home.noUsageDescription")} />}
|
||||||
>
|
>
|
||||||
<TopModelsChart
|
<TopModelsChart data={props.data} range="2M" activeModel={activeModel()} onActiveModelChange={setActiveModel} />
|
||||||
data={data()}
|
|
||||||
range={range()}
|
|
||||||
activeModel={activeModel()}
|
|
||||||
onActiveModelChange={setActiveModel}
|
|
||||||
/>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={leaderboard().length > 0}
|
when={props.leaderboard.length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<EmptyState title={i18n.t("home.noLeaderboardTitle")} description={i18n.t("home.noLeaderboardDescription")} />
|
<EmptyState title={i18n.t("home.noLeaderboardTitle")} description={i18n.t("home.noLeaderboardDescription")} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Leaderboard data={leaderboard()} activeModel={activeModel()} onActiveModelChange={setActiveModel} />
|
<Leaderboard data={props.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>
|
</Show>
|
||||||
</section>
|
</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: {
|
function TopModelsChart(props: {
|
||||||
data: UsagePoint[]
|
data: UsagePoint[]
|
||||||
range: UsageRange
|
range: UsageRange
|
||||||
@@ -823,10 +586,9 @@ function TopModelsChart(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function UniqueUsersSection(props: { data: StatsHomeData["users"] }) {
|
function UniqueUsersSection(props: { data: UsagePoint[] }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const [activeModel, setActiveModel] = createSignal<string>()
|
const [activeModel, setActiveModel] = createSignal<string>()
|
||||||
const data = createMemo(() => props.data.Go["2M"])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="unique-users" data-section="unique-users">
|
<section id="unique-users" data-section="unique-users">
|
||||||
@@ -837,13 +599,13 @@ function UniqueUsersSection(props: { data: StatsHomeData["users"] }) {
|
|||||||
description={i18n.t("home.uniqueUsersDescription")}
|
description={i18n.t("home.uniqueUsersDescription")}
|
||||||
/>
|
/>
|
||||||
<Show
|
<Show
|
||||||
when={data().some((item) => usageTotal(item) > 0)}
|
when={props.data.some((item) => usageTotal(item) > 0)}
|
||||||
fallback={
|
fallback={
|
||||||
<EmptyState title={i18n.t("home.noUserDataTitle")} description={i18n.t("home.noUserDataDescription")} />
|
<EmptyState title={i18n.t("home.noUserDataTitle")} description={i18n.t("home.noUserDataDescription")} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TopModelsChart
|
<TopModelsChart
|
||||||
data={data()}
|
data={props.data}
|
||||||
range="2M"
|
range="2M"
|
||||||
metric="users"
|
metric="users"
|
||||||
ariaLabel={i18n.t("home.uniqueUsersChart")}
|
ariaLabel={i18n.t("home.uniqueUsersChart")}
|
||||||
@@ -927,7 +689,7 @@ function isDenseColumnRange(range: UsageRange) {
|
|||||||
function scrollDenseChartToEnd(element: HTMLDivElement | undefined, range: UsageRange, count: number) {
|
function scrollDenseChartToEnd(element: HTMLDivElement | undefined, range: UsageRange, count: number) {
|
||||||
if (!element || count <= 0 || !isDenseColumnRange(range) || typeof window === "undefined") return
|
if (!element || count <= 0 || !isDenseColumnRange(range) || typeof window === "undefined") return
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
element.scrollLeft = element.scrollWidth - element.clientWidth
|
element.scrollLeft = Number.MAX_SAFE_INTEGER
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -966,12 +728,11 @@ function Leaderboard(props: {
|
|||||||
activeModel: string | undefined
|
activeModel: string | undefined
|
||||||
onActiveModelChange: (model: string | undefined) => void
|
onActiveModelChange: (model: string | undefined) => void
|
||||||
}) {
|
}) {
|
||||||
const i18n = useI18n()
|
|
||||||
const featured = createMemo(() => props.data.slice(0, 3))
|
const featured = createMemo(() => props.data.slice(0, 3))
|
||||||
const compact = createMemo(() => props.data.slice(3))
|
const compact = createMemo(() => props.data.slice(3))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="leaderboard" data-component="leaderboard" role="list" aria-label={i18n.t("chart.leaderboardAria")}>
|
<div id="leaderboard" data-component="leaderboard">
|
||||||
<div data-slot="leaderboard-featured">
|
<div data-slot="leaderboard-featured">
|
||||||
<For each={featured()}>
|
<For each={featured()}>
|
||||||
{(entry) => (
|
{(entry) => (
|
||||||
@@ -997,7 +758,7 @@ function Leaderboard(props: {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
<div data-slot="leaderboard-mobile" aria-label={i18n.t("chart.scrollableLeaderboardAria")}>
|
<div data-slot="leaderboard-mobile">
|
||||||
<For each={props.data}>
|
<For each={props.data}>
|
||||||
{(entry) => (
|
{(entry) => (
|
||||||
<LeaderboardCard
|
<LeaderboardCard
|
||||||
@@ -1029,9 +790,6 @@ function LeaderboardCard(props: {
|
|||||||
href={language.route(
|
href={language.route(
|
||||||
`${import.meta.env.BASE_URL}${modelSlug(props.entry.provider)}/${modelSlug(props.entry.model)}`,
|
`${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)}
|
onPointerEnter={() => props.onActiveModelChange(props.entry.model)}
|
||||||
onPointerLeave={(event) => {
|
onPointerLeave={(event) => {
|
||||||
if (event.pointerType === "touch") return
|
if (event.pointerType === "touch") return
|
||||||
@@ -1084,16 +842,14 @@ function formatChange(value: number | null, i18n: ReturnType<typeof useI18n>) {
|
|||||||
return `${value}%`
|
return `${value}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarketShareSection(props: { data: StatsHomeData["market"] }) {
|
function MarketShareSection(props: { data: MarketDay[] }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const [range, setRange] = createSignal<UsageRange>("2M")
|
|
||||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||||
const [activeAuthor, setActiveAuthor] = createSignal<string>()
|
const [activeAuthor, setActiveAuthor] = createSignal<string>()
|
||||||
const [inspecting, setInspecting] = createSignal(false)
|
const [inspecting, setInspecting] = createSignal(false)
|
||||||
const data = createMemo(() => props.data[range()])
|
const authorOrder = createMemo(() => getMarketAuthorOrder(props.data))
|
||||||
const authorOrder = createMemo(() => getMarketAuthorOrder(data()))
|
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(props.data.length - 1, 0)))
|
||||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
|
const activeDay = createMemo(() => props.data[selectedIndex()])
|
||||||
const activeDay = createMemo(() => data()[selectedIndex()])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -1118,8 +874,8 @@ function MarketShareSection(props: { data: StatsHomeData["market"] }) {
|
|||||||
{(day) => (
|
{(day) => (
|
||||||
<>
|
<>
|
||||||
<MarketShare
|
<MarketShare
|
||||||
data={data()}
|
data={props.data}
|
||||||
range={range()}
|
range="2M"
|
||||||
authorOrder={authorOrder()}
|
authorOrder={authorOrder()}
|
||||||
activeIndex={selectedIndex()}
|
activeIndex={selectedIndex()}
|
||||||
activeAuthor={activeAuthor()}
|
activeAuthor={activeAuthor()}
|
||||||
@@ -1151,22 +907,9 @@ function MarketShareSection(props: { data: StatsHomeData["market"] }) {
|
|||||||
<strong>
|
<strong>
|
||||||
{inspecting()
|
{inspecting()
|
||||||
? formatMarketDate(activeDay(), i18n.t("home.noData"))
|
? formatMarketDate(activeDay(), i18n.t("home.noData"))
|
||||||
: formatMarketRange(data(), i18n.t("home.noData"))}
|
: formatMarketRange(props.data, i18n.t("home.noData"))}
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
@@ -1202,6 +945,7 @@ function MarketShare(props: {
|
|||||||
{(day, index) => (
|
{(day, index) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={`${day.date} ${formatTrillions(day.total)}`}
|
||||||
data-active={props.inspecting && props.activeIndex === index() ? "true" : undefined}
|
data-active={props.inspecting && props.activeIndex === index() ? "true" : undefined}
|
||||||
data-label-hidden={isColumnLabelHidden(index(), props.data.length) ? "true" : undefined}
|
data-label-hidden={isColumnLabelHidden(index(), props.data.length) ? "true" : undefined}
|
||||||
data-mobile-hidden={isMarketMobileLabelHidden(index(), props.data.length) ? "true" : undefined}
|
data-mobile-hidden={isMarketMobileLabelHidden(index(), props.data.length) ? "true" : undefined}
|
||||||
@@ -1277,14 +1021,11 @@ function MarketShareList(props: {
|
|||||||
activeAuthor: string | undefined
|
activeAuthor: string | undefined
|
||||||
onActiveAuthorChange: (author: string) => void
|
onActiveAuthorChange: (author: string) => void
|
||||||
}) {
|
}) {
|
||||||
const i18n = useI18n()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return (
|
return (
|
||||||
<ol data-component="market-share-list">
|
<ol data-component="market-share-list">
|
||||||
<For each={props.data}>
|
<For each={props.data}>
|
||||||
{(item, index) => {
|
{(item, index) => {
|
||||||
const label = () =>
|
|
||||||
`${item.author} ${formatTrillions(item.tokens)} ${item.share.toFixed(1)} ${i18n.t("chart.percent")}`
|
|
||||||
const content = () => (
|
const content = () => (
|
||||||
<>
|
<>
|
||||||
<span>{String(index() + 1).padStart(2, "0")}</span>
|
<span>{String(index() + 1).padStart(2, "0")}</span>
|
||||||
@@ -1306,7 +1047,6 @@ function MarketShareList(props: {
|
|||||||
fallback={
|
fallback={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={label()}
|
|
||||||
onClick={() => props.onActiveAuthorChange(item.author)}
|
onClick={() => props.onActiveAuthorChange(item.author)}
|
||||||
onFocus={() => props.onActiveAuthorChange(item.author)}
|
onFocus={() => props.onActiveAuthorChange(item.author)}
|
||||||
>
|
>
|
||||||
@@ -1317,7 +1057,6 @@ function MarketShareList(props: {
|
|||||||
{(href) => (
|
{(href) => (
|
||||||
<a
|
<a
|
||||||
href={href()}
|
href={href()}
|
||||||
aria-label={label()}
|
|
||||||
onClick={() => props.onActiveAuthorChange(item.author)}
|
onClick={() => props.onActiveAuthorChange(item.author)}
|
||||||
onFocus={() => props.onActiveAuthorChange(item.author)}
|
onFocus={() => props.onActiveAuthorChange(item.author)}
|
||||||
>
|
>
|
||||||
@@ -1333,23 +1072,22 @@ function MarketShareList(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GeoBreakdownSection(props: { data: StatsHomeData["country"] }) {
|
function GeoBreakdownSection(props: { data: CountryEntry[] }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [activeCountry, setActiveCountry] = createSignal<string>()
|
const [activeCountry, setActiveCountry] = createSignal<string>()
|
||||||
const data = createMemo(() => props.data["2M"])
|
|
||||||
const countryById = createMemo(
|
const countryById = createMemo(
|
||||||
() =>
|
() =>
|
||||||
new Map(
|
new Map(
|
||||||
data().flatMap((country) => {
|
props.data.flatMap((country) => {
|
||||||
const id = countryNumericId(country.country)
|
const id = countryNumericId(country.country)
|
||||||
return id ? [[id, country] as const] : []
|
return id ? [[id, country] as const] : []
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const maxTokens = createMemo(() => Math.max(0, ...data().map((country) => country.tokens)) || 1)
|
const maxTokens = createMemo(() => Math.max(0, ...props.data.map((country) => country.tokens)) || 1)
|
||||||
const topCountries = createMemo(() => data().slice(0, 15))
|
const topCountries = createMemo(() => props.data.slice(0, 15))
|
||||||
const active = createMemo(() => data().find((country) => country.country === activeCountry()) ?? data()[0])
|
const active = createMemo(() => props.data.find((country) => country.country === activeCountry()) ?? props.data[0])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -1363,7 +1101,7 @@ function GeoBreakdownSection(props: { data: StatsHomeData["country"] }) {
|
|||||||
<SectionBridge label={i18n.t("nav.marketShare").toUpperCase()} href="#market-share" />
|
<SectionBridge label={i18n.t("nav.marketShare").toUpperCase()} href="#market-share" />
|
||||||
<SectionTitle id="geo-breakdown" title={i18n.t("home.geoTitle")} description={i18n.t("home.geoDescription")} />
|
<SectionTitle id="geo-breakdown" title={i18n.t("home.geoTitle")} description={i18n.t("home.geoDescription")} />
|
||||||
<Show
|
<Show
|
||||||
when={data().length > 0}
|
when={props.data.length > 0}
|
||||||
fallback={<EmptyState title={i18n.t("home.noGeoTitle")} description={i18n.t("home.noGeoDescription")} />}
|
fallback={<EmptyState title={i18n.t("home.noGeoTitle")} description={i18n.t("home.noGeoDescription")} />}
|
||||||
>
|
>
|
||||||
<div data-component="geo-breakdown">
|
<div data-component="geo-breakdown">
|
||||||
@@ -1453,31 +1191,30 @@ function GeoWorldMap(props: {
|
|||||||
</For>
|
</For>
|
||||||
</g>
|
</g>
|
||||||
<g data-slot="geo-country-markers">
|
<g data-slot="geo-country-markers">
|
||||||
<For each={worldCountryPaths}>
|
<For each={worldCountryMarkers}>
|
||||||
{(country) => {
|
{(country) => {
|
||||||
const entry = () => props.countryById.get(country.id)
|
const entry = () => props.countryById.get(country.id)
|
||||||
return (
|
return (
|
||||||
<Show when={country.marker && entry() ? country.marker : undefined}>
|
<Show when={entry()}>
|
||||||
{(marker) => (
|
<circle
|
||||||
<circle
|
cx={country.marker.x}
|
||||||
cx={marker().x}
|
cy={country.marker.y}
|
||||||
cy={marker().y}
|
data-country-id={country.id}
|
||||||
r={entry()?.country === props.activeCountry ? 3.4 : 2.4}
|
r={entry()?.country === props.activeCountry ? 3.4 : 2.4}
|
||||||
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
data-active={entry()?.country === props.activeCountry ? "true" : undefined}
|
||||||
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
style={{ "--geo-country-opacity": String(countryOpacity(entry())) } as JSX.CSSProperties}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
onPointerEnter={() => {
|
onPointerEnter={() => {
|
||||||
const item = entry()
|
const item = entry()
|
||||||
if (!item) return
|
if (!item) return
|
||||||
props.onActiveCountryChange(item.country)
|
props.onActiveCountryChange(item.country)
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const item = entry()
|
const item = entry()
|
||||||
if (!item) return
|
if (!item) return
|
||||||
props.onActiveCountryChange(item.country)
|
props.onActiveCountryChange(item.country)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -1507,7 +1244,6 @@ function GeoCountryList(props: {
|
|||||||
type="button"
|
type="button"
|
||||||
data-active={props.activeCountry === country.country ? "true" : undefined}
|
data-active={props.activeCountry === country.country ? "true" : undefined}
|
||||||
style={{ "--geo-row-opacity": String(opacityScale()(country.tokens)) } as JSX.CSSProperties}
|
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)}
|
onClick={() => props.onActiveCountryChange(country.country)}
|
||||||
onPointerEnter={() => props.onActiveCountryChange(country.country)}
|
onPointerEnter={() => props.onActiveCountryChange(country.country)}
|
||||||
onFocus={() => props.onActiveCountryChange(country.country)}
|
onFocus={() => props.onActiveCountryChange(country.country)}
|
||||||
@@ -1531,14 +1267,6 @@ function countryNumericId(country: string) {
|
|||||||
return countryNumericIds.get(country.toUpperCase())?.padStart(3, "0")
|
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) {
|
function formatCountryName(country: string, locale: string, unknown: string) {
|
||||||
const code = country.toUpperCase()
|
const code = country.toUpperCase()
|
||||||
if (code === "ZZ") return unknown
|
if (code === "ZZ") return unknown
|
||||||
@@ -1636,12 +1364,10 @@ function marketDateParts(label: string) {
|
|||||||
return { start: start ?? label, end: end ?? start ?? label }
|
return { start: start ?? label, end: end ?? start ?? label }
|
||||||
}
|
}
|
||||||
|
|
||||||
function TokenCostSection(props: { data: StatsHomeData["tokenCost"]; catalog: ModelCatalog | null }) {
|
function TokenCostSection(props: { data: TokenCostEntry[] }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const [product, setProduct] = createSignal<TokenProduct>("Go")
|
|
||||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||||
const data = createMemo(() => priceTokenCostFromCatalog(props.data[product()], props.catalog))
|
const visible = createMemo(() => props.data.slice(0, 13))
|
||||||
const visible = createMemo(() => data().slice(0, 13))
|
|
||||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0)))
|
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0)))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1660,17 +1386,6 @@ function TokenCostSection(props: { data: StatsHomeData["tokenCost"]; catalog: Mo
|
|||||||
>
|
>
|
||||||
<TokenCostChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
<TokenCostChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||||
</Show>
|
</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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1723,12 +1438,10 @@ function TokenCostChart(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CacheRatioSection(props: { data: StatsHomeData["cacheRatio"] }) {
|
function CacheRatioSection(props: { data: CacheRatioEntry[] }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const [product, setProduct] = createSignal<TokenProduct>("Go")
|
|
||||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||||
const data = createMemo(() => props.data[product()])
|
const visible = createMemo(() => props.data.slice(0, 16))
|
||||||
const visible = createMemo(() => data().slice(0, 16))
|
|
||||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0)))
|
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0)))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1745,17 +1458,6 @@ function CacheRatioSection(props: { data: StatsHomeData["cacheRatio"] }) {
|
|||||||
>
|
>
|
||||||
<CacheRatioChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
<CacheRatioChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||||
</Show>
|
</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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1853,12 +1555,10 @@ function MetricBar(props: { value: number; max: number; active: boolean }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
|
function SessionCostSection(props: { data: SessionCostEntry[] }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const [product, setProduct] = createSignal<TokenProduct>("Go")
|
|
||||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||||
const data = createMemo(() => props.data[product()])
|
const visible = createMemo(() => props.data.slice(0, 16))
|
||||||
const visible = createMemo(() => data().slice(0, 16))
|
|
||||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0)))
|
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0)))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1877,17 +1577,6 @@ function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
|
|||||||
>
|
>
|
||||||
<SessionCostChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
<SessionCostChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||||
</Show>
|
</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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1949,11 +1638,6 @@ function SessionCostChart(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LiveIndicator() {
|
|
||||||
const i18n = useI18n()
|
|
||||||
return <span data-component="live-filter">{i18n.t("chart.live")}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTokenCount(value: number) {
|
function formatTokenCount(value: number) {
|
||||||
if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}M`
|
if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}M`
|
||||||
return `${Math.round(value / 1_000)}K`
|
return `${Math.round(value / 1_000)}K`
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default defineConfig({
|
|||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
minify: false,
|
minify: "esbuild",
|
||||||
|
cssMinify: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/stats-core",
|
"name": "@opencode-ai/stats-core",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/stats-server",
|
"name": "@opencode-ai/stats-server",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ function command(command: string, args: string[] = [], input?: string) {
|
|||||||
function writeOsc52(text: string) {
|
function writeOsc52(text: string) {
|
||||||
if (!process.stdout.isTTY) return
|
if (!process.stdout.isTTY) return
|
||||||
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
|
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
|
||||||
process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence)
|
const passthrough = `\x1bPtmux;\x1b${sequence}\x1b\\`
|
||||||
|
process.stdout.write(process.env.TMUX ? sequence + passthrough : process.env.STY ? passthrough : sequence)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function read() {
|
export async function read() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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",
|
"name": "opencode",
|
||||||
"displayName": "opencode",
|
"displayName": "opencode",
|
||||||
"description": "opencode for VS Code",
|
"description": "opencode for VS Code",
|
||||||
"version": "1.18.13",
|
"version": "1.18.14",
|
||||||
"publisher": "sst-dev",
|
"publisher": "sst-dev",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
Reference in New Issue
Block a user