Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
504a599473 | ||
|
|
750b9f80a5 | ||
|
|
dfdd009750 | ||
|
|
c1ada302f9 | ||
|
|
51e4c9fc4c | ||
|
|
43e272e6c4 | ||
|
|
2f9f189f39 | ||
|
|
f3c70f4ea8 | ||
|
|
5d4441cd2b | ||
|
|
bf5f34ace7 | ||
|
|
9589657d21 | ||
|
|
37baed99c1 | ||
|
|
a3ba740de4 | ||
|
|
dc96664578 | ||
|
|
4dafc532a8 | ||
|
|
984fe4b769 | ||
|
|
48f50cf55e | ||
|
|
ba13f8da08 | ||
|
|
1a8b494055 | ||
|
|
4f02d7d424 | ||
|
|
4cebd69bf0 | ||
|
|
dc6e54503c | ||
|
|
f18847d739 | ||
|
|
2a0b67d84f | ||
|
|
89eac737a5 | ||
|
|
c68607fb2b | ||
|
|
e944ff0286 | ||
|
|
ee7612a31c | ||
|
|
582ed7c363 | ||
|
|
dce287a42d | ||
|
|
19974daa67 | ||
|
|
dcf865a889 | ||
|
|
3b20935959 | ||
|
|
30f4c2cf4c | ||
|
|
3541fdcb20 | ||
|
|
15de97c10f | ||
|
|
ee3fd3f7be | ||
|
|
dc87659791 | ||
|
|
149f5eaa2e | ||
|
|
42e0b47a7d | ||
|
|
2d5df3ad76 | ||
|
|
f202fa0d89 | ||
|
|
0abffdb8f8 | ||
|
|
e533d48b51 | ||
|
|
439372704d | ||
|
|
d7277fd305 | ||
|
|
5ae73637d3 | ||
|
|
bf0cbf2bfa | ||
|
|
4b3a841dd9 | ||
|
|
aca32eaa1c | ||
|
|
3ae75d7031 | ||
|
|
4b5e447961 | ||
|
|
7a2b8eae76 | ||
|
|
d983b9485d | ||
|
|
14836de276 | ||
|
|
e265efec09 | ||
|
|
5ae00ba567 | ||
|
|
a0f032c9b9 | ||
|
|
e6132fc6a4 | ||
|
|
950b608c4d | ||
|
|
3210df7428 | ||
|
|
cdeb82e9ca | ||
|
|
a9cae7b335 | ||
|
|
972c0893dd | ||
|
|
e5d89ca567 | ||
|
|
4ae70d4b0d | ||
|
|
935cd7481b | ||
|
|
5553efea5e | ||
|
|
0ff73ed8a6 | ||
|
|
5e792d7ac5 | ||
|
|
4a77e94e3c | ||
|
|
4c563ea405 | ||
|
|
5875257462 | ||
|
|
9701891e94 | ||
|
|
a2ab37c1b6 | ||
|
|
4d6e2d8efc | ||
|
|
4407d5d96f | ||
|
|
244945c0e7 | ||
|
|
c652b2b4e8 | ||
|
|
aabeeb1431 | ||
|
|
0fbedc5e19 | ||
|
|
12782fff14 | ||
|
|
ca463a2346 | ||
|
|
7265cdf817 | ||
|
|
7baa751351 | ||
|
|
5b86fa9109 | ||
|
|
aa7e008fe1 | ||
|
|
792664071c | ||
|
|
a0541ba57a | ||
|
|
4994bf1b46 | ||
|
|
1e24514d61 | ||
|
|
4b1c6300a0 | ||
|
|
db3fb9d316 | ||
|
|
cd79676b42 | ||
|
|
09e7e0ab70 | ||
|
|
0e60f66604 | ||
|
|
fc8db6cdf9 | ||
|
|
5cc37c4ea0 | ||
|
|
46ad456718 | ||
|
|
832ffd2303 | ||
|
|
b261430880 | ||
|
|
545f345848 | ||
|
|
77ae0b527e | ||
|
|
c1278109c9 | ||
|
|
a7a88d01ef | ||
|
|
4e0ab6b634 | ||
|
|
d36485b7af | ||
|
|
1da24f6adb | ||
|
|
e29dd27632 | ||
|
|
37380e1f94 | ||
|
|
1309ca7a81 | ||
|
|
c1515316f5 | ||
|
|
b66e7b6fce | ||
|
|
eb398f1951 | ||
|
|
643c22d21f | ||
|
|
74acd08ead | ||
|
|
49ea5aa2ad | ||
|
|
ee1af0fe80 | ||
|
|
dfebf40471 | ||
|
|
6af6a1295f | ||
|
|
22821744ef | ||
|
|
872c9467b2 | ||
|
|
d8249f32a8 | ||
|
|
982954cc1b | ||
|
|
4caa458232 | ||
|
|
6fe8e3973c | ||
|
|
7816901713 | ||
|
|
71abca9571 | ||
|
|
7216a8c86d | ||
|
|
e3e16e58c5 | ||
|
|
a2951a2702 | ||
|
|
55453dc606 | ||
|
|
198d7f7e5f | ||
|
|
e3e9fd7aa8 | ||
|
|
3c56dbcf58 | ||
|
|
ee07ed2dc4 | ||
|
|
485e4520e7 | ||
|
|
fc115ea367 | ||
|
|
d03b79e61e | ||
|
|
0acae8211a | ||
|
|
0af4505756 | ||
|
|
a606e1d2ec | ||
|
|
0e65700183 | ||
|
|
e6301ca5d5 | ||
|
|
b562863fcc | ||
|
|
db85f01eff | ||
|
|
1a6fd018f6 | ||
|
|
fdb5bae3c6 | ||
|
|
a9624c0fff | ||
|
|
316d4c9197 | ||
|
|
5e886c35d5 | ||
|
|
5162268f9d | ||
|
|
0eb899a950 | ||
|
|
3241f6b8bb | ||
|
|
2c792f17e6 | ||
|
|
7d0c6860cd | ||
|
|
c70e393c81 | ||
|
|
20963c4186 | ||
|
|
0a778a2789 | ||
|
|
42c1e61bf4 | ||
|
|
795b845782 | ||
|
|
2e434a459a | ||
|
|
ae62bc8b1f | ||
|
|
187a5fe301 |
@@ -0,0 +1,71 @@
|
||||
#
|
||||
# This file is intentionally in the wrong dir, will move and add later....
|
||||
#
|
||||
|
||||
# name: publish-python-sdk
|
||||
|
||||
# on:
|
||||
# release:
|
||||
# types: [published]
|
||||
# workflow_dispatch:
|
||||
|
||||
# jobs:
|
||||
# publish:
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read
|
||||
# steps:
|
||||
# - name: Checkout repository
|
||||
# uses: actions/checkout@v4
|
||||
|
||||
# - name: Setup Bun
|
||||
# uses: oven-sh/setup-bun@v1
|
||||
# with:
|
||||
# bun-version: 1.2.21
|
||||
|
||||
# - name: Install dependencies (JS/Bun)
|
||||
# run: bun install
|
||||
|
||||
# - name: Install uv
|
||||
# shell: bash
|
||||
# run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# - name: Generate Python SDK from OpenAPI (CLI)
|
||||
# shell: bash
|
||||
# run: |
|
||||
# ~/.local/bin/uv run --project packages/sdk/python python packages/sdk/python/scripts/generate.py --source cli
|
||||
|
||||
# - name: Sync Python dependencies
|
||||
# shell: bash
|
||||
# run: |
|
||||
# ~/.local/bin/uv sync --dev --project packages/sdk/python
|
||||
|
||||
# - name: Set version from release tag
|
||||
# shell: bash
|
||||
# run: |
|
||||
# TAG="${GITHUB_REF_NAME:-}"
|
||||
# if [ -z "$TAG" ]; then
|
||||
# TAG="$(git describe --tags --abbrev=0 || echo 0.0.0)"
|
||||
# fi
|
||||
# echo "Using version: $TAG"
|
||||
# VERSION="$TAG" ~/.local/bin/uv run --project packages/sdk/python python - <<'PY'
|
||||
# import os, re, pathlib
|
||||
# root = pathlib.Path('packages/sdk/python')
|
||||
# pt = (root / 'pyproject.toml').read_text()
|
||||
# version = os.environ.get('VERSION','0.0.0').lstrip('v')
|
||||
# pt = re.sub(r'(?m)^(version\s*=\s*")[^"]+("\s*)$', f"\\1{version}\\2", pt)
|
||||
# (root / 'pyproject.toml').write_text(pt)
|
||||
# # Also update generator config override for consistency
|
||||
# cfgp = root / 'openapi-python-client.yaml'
|
||||
# if cfgp.exists():
|
||||
# cfg = cfgp.read_text()
|
||||
# cfg = re.sub(r'(?m)^(package_version_override:\s*)\S+$', f"\\1{version}", cfg)
|
||||
# cfgp.write_text(cfg)
|
||||
# PY
|
||||
|
||||
# - name: Build and publish to PyPI
|
||||
# env:
|
||||
# PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
|
||||
# shell: bash
|
||||
# run: |
|
||||
# ~/.local/bin/uv run --project packages/sdk/python python packages/sdk/python/scripts/publish.py
|
||||
@@ -53,13 +53,17 @@ jobs:
|
||||
- name: Install OpenCode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Setup npm auth
|
||||
run: |
|
||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
./script/publish.ts
|
||||
env:
|
||||
OPENCODE_BUMP: ${{ inputs.bump }}
|
||||
OPENCODE_CHANNEL: latest
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
|
||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
- opentui
|
||||
- v0
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ If you are unsure if a PR would be accepted, feel free to ask a maintainer or lo
|
||||
- [`help wanted`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
|
||||
- [`good first issue`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)
|
||||
- [`bug`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
|
||||
- [`perf`](https://github.com/sst/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22)
|
||||
|
||||
> [!NOTE]
|
||||
> PRs that ignore these guardrails will likely be closed.
|
||||
|
||||
@@ -118,3 +118,10 @@
|
||||
| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) |
|
||||
| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) |
|
||||
| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) |
|
||||
| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) |
|
||||
| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) |
|
||||
| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) |
|
||||
| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) |
|
||||
| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) |
|
||||
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
|
||||
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -64,7 +64,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -88,7 +88,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -109,12 +109,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/precision-diffs": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -149,7 +148,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "22.0.0",
|
||||
@@ -165,7 +164,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -216,6 +215,10 @@
|
||||
"@ai-sdk/google-vertex": "3.0.16",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||
"@parcel/watcher-win32-x64": "2.5.1",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -229,7 +232,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -249,7 +252,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.81.0",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -260,7 +263,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -273,20 +276,27 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@pierre/precision-diffs": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"marked": "16.2.0",
|
||||
"marked-shiki": "1.2.1",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "3.9.2",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"virtua": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
@@ -296,7 +306,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -343,7 +353,7 @@
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
|
||||
"@pierre/precision-diffs": "0.4.1",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
@@ -937,7 +947,7 @@
|
||||
|
||||
"@petamoriken/float16": ["@petamoriken/float16@3.9.2", "", {}, "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog=="],
|
||||
|
||||
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.0.2-alpha.1-1", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" } }, "sha512-T43cwB7gMnbM+tp9p73NptUm4uUOfmrP5ihMOAHWQPpzBa/oeTjqZlmEmSQLpT8WKKnWG0lbKZPtlw7l0gW0Vw=="],
|
||||
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.4.1", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-AoozHakINGyNJFgbYc/1PlDK0yunrAxbtXEMBe9fdu8RLkNjVtYRTLw7EF2mM/YuVoVRjj2HT/2VJ4a2rMyDOA=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
@@ -1075,7 +1085,7 @@
|
||||
|
||||
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
|
||||
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
|
||||
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-kUTRVKPsB/28H5Ko6qEsyudBiWEDLst+Sfi+hwr59E0GLHV0h8RfgbQU7fdN5Lt9A8R1ulRiZyTvAizkROjwDA=="],
|
||||
|
||||
@@ -3513,6 +3523,8 @@
|
||||
|
||||
"@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="],
|
||||
|
||||
"@pierre/precision-diffs/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
|
||||
|
||||
"@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/transformers@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/types": "3.13.0" } }, "sha512-833lcuVzcRiG+fXvgslWsM2f4gHpjEgui1ipIknSizRuTgMkNZupiXE5/TVJ6eSYfhNBFhBZKkReKWO2GgYmqA=="],
|
||||
|
||||
"@pierre/precision-diffs/shiki": ["shiki@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="],
|
||||
@@ -3525,10 +3537,6 @@
|
||||
|
||||
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@shikijs/core/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
|
||||
|
||||
"@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
|
||||
|
||||
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||
|
||||
"@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
|
||||
@@ -3793,8 +3801,6 @@
|
||||
|
||||
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
|
||||
"shiki/@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
|
||||
|
||||
"sitemap/sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
|
||||
|
||||
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
@@ -3949,6 +3955,8 @@
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
|
||||
@@ -4083,6 +4091,8 @@
|
||||
|
||||
"@opencode-ai/web/shiki/@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="],
|
||||
|
||||
"@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
|
||||
|
||||
"@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
|
||||
|
||||
"@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
|
||||
@@ -4345,6 +4355,8 @@
|
||||
|
||||
"@actions/github/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
|
||||
|
||||
"@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
|
||||
|
||||
"@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
|
||||
|
||||
"@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
|
||||
|
||||
@@ -104,7 +104,7 @@ To test locally:
|
||||
- `MODEL`: The model used by opencode. Same as the `MODEL` defined in the GitHub workflow.
|
||||
- `ANTHROPIC_API_KEY`: Your model provider API key. Same as the keys defined in the GitHub workflow.
|
||||
- `GITHUB_RUN_ID`: Dummy value to emulate GitHub action environment.
|
||||
- `MOCK_TOKEN`: A GitHub persontal access token. This token is used to verify you have `admin` or `write` access to the test repo. Generate a token [here](https://github.com/settings/personal-access-tokens).
|
||||
- `MOCK_TOKEN`: A GitHub personal access token. This token is used to verify you have `admin` or `write` access to the test repo. Generate a token [here](https://github.com/settings/personal-access-tokens).
|
||||
- `MOCK_EVENT`: Mock GitHub event payload (see templates below).
|
||||
- `/path/to/opencode`: Path to your cloned opencode repo. `bun /path/to/opencode/github/index.ts` runs your local version of `opencode`.
|
||||
|
||||
@@ -118,7 +118,7 @@ Replace:
|
||||
|
||||
- `"owner":"sst"` with repo owner
|
||||
- `"repo":"hello-world"` with repo name
|
||||
- `"actor":"fwang"` with the GitHub username of commentor
|
||||
- `"actor":"fwang"` with the GitHub username of commenter
|
||||
- `"number":4` with the GitHub issue id
|
||||
- `"body":"hey opencode, summarize thread"` with comment body
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
|
||||
"@pierre/precision-diffs": "0.4.1",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
@@ -53,8 +53,8 @@
|
||||
"turbo": "2.5.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*"
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -63,7 +63,7 @@
|
||||
"license": "MIT",
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"printWidth": 120
|
||||
"printWidth": 100
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
|
||||
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||
"start": "vinxi start",
|
||||
"version": "0.15.17"
|
||||
"version": "0.15.30"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg width="240" height="300" viewBox="0 0 240 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1401_86283)">
|
||||
<mask id="mask0_1401_86283" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1401_86283)">
|
||||
<path d="M180 240H60V120H180V240Z" fill="#4B4646"/>
|
||||
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#F1ECEC"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1401_86283">
|
||||
<rect width="240" height="300" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 593 B |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg width="240" height="300" viewBox="0 0 240 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1401_86274)">
|
||||
<mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||
<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1401_86274)">
|
||||
<path d="M180 240H60V120H180V240Z" fill="#CFCECD"/>
|
||||
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1401_86274">
|
||||
<rect width="240" height="300" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 593 B |
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,30 @@
|
||||
<svg width="641" height="115" viewBox="0 0 641 115" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1401_86292)">
|
||||
<mask id="mask0_1401_86292" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="641" height="115">
|
||||
<path d="M640.714 0H0V115H640.714V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1401_86292)">
|
||||
<path d="M49.2868 82.1433H16.4297V49.2861H49.2868V82.1433Z" fill="#4B4646"/>
|
||||
<path d="M49.2857 32.8573H16.4286V82.143H49.2857V32.8573ZM65.7143 98.5716H0V16.4287H65.7143V98.5716Z" fill="#B7B1B1"/>
|
||||
<path d="M131.427 82.1433H98.5703V49.2861H131.427V82.1433Z" fill="#4B4646"/>
|
||||
<path d="M98.5692 82.143H131.426V32.8573H98.5692V82.143ZM147.855 98.5716H98.5692V115H82.1406V16.4287H147.855V98.5716Z" fill="#B7B1B1"/>
|
||||
<path d="M229.997 65.7139V82.1424H180.711V65.7139H229.997Z" fill="#4B4646"/>
|
||||
<path d="M230.003 65.7144H180.718V82.143H230.003V98.5716H164.289V16.4287H230.003V65.7144ZM180.718 49.2859H213.575V32.8573H180.718V49.2859Z" fill="#B7B1B1"/>
|
||||
<path d="M295.717 98.5718H262.859V49.2861H295.717V98.5718Z" fill="#4B4646"/>
|
||||
<path d="M295.715 32.8573H262.858V98.5716H246.43V16.4287H295.715V32.8573ZM312.144 98.5716H295.715V32.8573H312.144V98.5716Z" fill="#B7B1B1"/>
|
||||
<path d="M394.286 82.1433H345V49.2861H394.286V82.1433Z" fill="#4B4646"/>
|
||||
<path d="M394.285 32.8573H344.999V82.143H394.285V98.5716H328.57V16.4287H394.285V32.8573Z" fill="#F1ECEC"/>
|
||||
<path d="M459.998 82.1433H427.141V49.2861H459.998V82.1433Z" fill="#4B4646"/>
|
||||
<path d="M459.997 32.8573H427.14V82.143H459.997V32.8573ZM476.425 98.5716H410.711V16.4287H476.425V98.5716Z" fill="#F1ECEC"/>
|
||||
<path d="M542.146 82.1433H509.289V49.2861H542.146V82.1433Z" fill="#4B4646"/>
|
||||
<path d="M542.145 32.8571H509.288V82.1429H542.145V32.8571ZM558.574 98.5714H492.859V16.4286H542.145V0H558.574V98.5714Z" fill="#F1ECEC"/>
|
||||
<path d="M640.715 65.7139V82.1424H591.43V65.7139H640.715Z" fill="#4B4646"/>
|
||||
<path d="M591.429 32.8573V49.2859H624.286V32.8573H591.429ZM640.714 65.7144H591.429V82.143H640.714V98.5716H575V16.4287H640.714V65.7144Z" fill="#F1ECEC"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1401_86292">
|
||||
<rect width="640.714" height="115" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,30 @@
|
||||
<svg width="640" height="115" viewBox="0 0 640 115" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1401_86330)">
|
||||
<mask id="mask0_1401_86330" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="640" height="115">
|
||||
<path d="M640 0H0V115H640V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1401_86330)">
|
||||
<path d="M49.2346 82.1433H16.4141V49.2861H49.2346V82.1433Z" fill="#CFCECD"/>
|
||||
<path d="M49.2308 32.8573H16.4103V82.143H49.2308V32.8573ZM65.641 98.5716H0V16.4287H65.641V98.5716Z" fill="#656363"/>
|
||||
<path d="M131.281 82.1433H98.4609V49.2861H131.281V82.1433Z" fill="#CFCECD"/>
|
||||
<path d="M98.4649 82.143H131.285V32.8573H98.4649V82.143ZM147.696 98.5716H98.4649V115H82.0547V16.4287H147.696V98.5716Z" fill="#656363"/>
|
||||
<path d="M229.746 65.7139V82.1424H180.516V65.7139H229.746Z" fill="#CFCECD"/>
|
||||
<path d="M229.743 65.7144H180.512V82.143H229.743V98.5716H164.102V16.4287H229.743V65.7144ZM180.512 49.2859H213.332V32.8573H180.512V49.2859Z" fill="#656363"/>
|
||||
<path d="M295.383 98.5718H262.562V49.2861H295.383V98.5718Z" fill="#CFCECD"/>
|
||||
<path d="M295.387 32.8573H262.567V98.5716H246.156V16.4287H295.387V32.8573ZM311.797 98.5716H295.387V32.8573H311.797V98.5716Z" fill="#656363"/>
|
||||
<path d="M393.848 82.1433H344.617V49.2861H393.848V82.1433Z" fill="#CFCECD"/>
|
||||
<path d="M393.844 32.8573H344.613V82.143H393.844V98.5716H328.203V16.4287H393.844V32.8573Z" fill="#211E1E"/>
|
||||
<path d="M459.485 82.1433H426.664V49.2861H459.485V82.1433Z" fill="#CFCECD"/>
|
||||
<path d="M459.489 32.8573H426.668V82.143H459.489V32.8573ZM475.899 98.5716H410.258V16.4287H475.899V98.5716Z" fill="#211E1E"/>
|
||||
<path d="M541.539 82.1433H508.719V49.2861H541.539V82.1433Z" fill="#CFCECD"/>
|
||||
<path d="M541.535 32.8571H508.715V82.1428H541.535V32.8571ZM557.946 98.5714H492.305V16.4286H541.535V0H557.946V98.5714Z" fill="#211E1E"/>
|
||||
<path d="M639.996 65.7139V82.1424H590.766V65.7139H639.996Z" fill="#CFCECD"/>
|
||||
<path d="M590.77 32.8573V49.2859H623.59V32.8573H590.77ZM640 65.7144H590.77V82.143H640V98.5716H574.359V16.4287H640V65.7144Z" fill="#211E1E"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1401_86330">
|
||||
<rect width="640" height="115" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg width="641" height="115" viewBox="0 0 641 115" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1401_86315)">
|
||||
<mask id="mask0_1401_86315" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="641" height="115">
|
||||
<path d="M640.714 0H0V115H640.714V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1401_86315)">
|
||||
<path d="M49.2857 32.8573H16.4286V82.143H49.2857V32.8573ZM65.7143 98.5716H0V16.4287H65.7143V98.5716Z" fill="white"/>
|
||||
<path d="M98.5692 82.143H131.426V32.8573H98.5692V82.143ZM147.855 98.5716H98.5692V115H82.1406V16.4287H147.855V98.5716Z" fill="white"/>
|
||||
<path d="M230.003 65.7144H180.718V82.143H230.003V98.5716H164.289V16.4287H230.003V65.7144ZM180.718 49.2859H213.575V32.8573H180.718V49.2859Z" fill="white"/>
|
||||
<path d="M295.715 32.8573H262.858V98.5716H246.43V16.4287H295.715V32.8573ZM312.144 98.5716H295.715V32.8573H312.144V98.5716Z" fill="white"/>
|
||||
<path d="M394.285 32.8573H344.999V82.143H394.285V98.5716H328.57V16.4287H394.285V32.8573Z" fill="white"/>
|
||||
<path d="M459.997 32.8573H427.14V82.143H459.997V32.8573ZM476.425 98.5716H410.711V16.4287H476.425V98.5716Z" fill="white"/>
|
||||
<path d="M542.145 32.8571H509.288V82.1429H542.145V32.8571ZM558.574 98.5714H492.859V16.4286H542.145V0H558.574V98.5714Z" fill="white"/>
|
||||
<path d="M591.429 32.8573V49.2859H624.286V32.8573H591.429ZM640.714 65.7144H591.429V82.143H640.714V98.5716H575V16.4287H640.714V65.7144Z" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1401_86315">
|
||||
<rect width="640.714" height="115" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,22 @@
|
||||
<svg width="640" height="115" viewBox="0 0 640 115" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1401_86353)">
|
||||
<mask id="mask0_1401_86353" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="640" height="115">
|
||||
<path d="M640 0H0V115H640V0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1401_86353)">
|
||||
<path d="M49.2308 32.8573H16.4103V82.143H49.2308V32.8573ZM65.641 98.5716H0V16.4287H65.641V98.5716Z" fill="black"/>
|
||||
<path d="M98.4649 82.143H131.285V32.8573H98.4649V82.143ZM147.696 98.5716H98.4649V115H82.0547V16.4287H147.696V98.5716Z" fill="black"/>
|
||||
<path d="M229.743 65.7144H180.512V82.143H229.743V98.5716H164.102V16.4287H229.743V65.7144ZM180.512 49.2859H213.332V32.8573H180.512V49.2859Z" fill="black"/>
|
||||
<path d="M295.387 32.8573H262.567V98.5716H246.156V16.4287H295.387V32.8573ZM311.797 98.5716H295.387V32.8573H311.797V98.5716Z" fill="black"/>
|
||||
<path d="M393.844 32.8573H344.613V82.143H393.844V98.5716H328.203V16.4287H393.844V32.8573Z" fill="black"/>
|
||||
<path d="M459.489 32.8573H426.668V82.143H459.489V32.8573ZM475.899 98.5716H410.258V16.4287H475.899V98.5716Z" fill="black"/>
|
||||
<path d="M541.535 32.8571H508.715V82.1428H541.535V32.8571ZM557.946 98.5714H492.305V16.4286H541.535V0H557.946V98.5714Z" fill="black"/>
|
||||
<path d="M590.77 32.8573V49.2859H623.59V32.8573H590.77ZM640 65.7144H590.77V82.143H640V98.5716H574.359V16.4287H640V65.7144Z" fill="black"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1401_86353">
|
||||
<rect width="640" height="115" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 302 B |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg width="22" height="26" viewBox="0 0 22 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 7H7V19H15V7ZM19 23H3V3H19V23Z" fill="url(#paint0_linear_1311_94922)" stroke="#F1ECEC"/>
|
||||
<path d="M3 0V26M19 0V26M15 0V26M7 0V26M0 3H22M0 7H22M0 19H22M0 23H22" stroke="#4B4646" stroke-opacity="0.4"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_1311_94922" x1="11" y1="3" x2="11" y2="23" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1B1818"/>
|
||||
<stop offset="1" stop-color="#2D2828"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 526 B |
@@ -0,0 +1,10 @@
|
||||
<svg width="22" height="26" viewBox="0 0 22 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 7H7V19H15V7ZM19 23H3V3H19V23Z" fill="url(#paint0_linear_1311_94913)" stroke="#8E8B8B"/>
|
||||
<path d="M3 0V26M19 0V26M15 0V26M7 0V26M0 3H22M0 7H22M0 19H22M0 23H22" stroke="#110000" stroke-opacity="0.121569"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_1311_94913" x1="11" y1="3" x2="11" y2="23" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F9F8F8"/>
|
||||
<stop offset="1" stop-color="#E9E8E8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 531 B |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg width="22" height="26" viewBox="0 0 22 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1311_94916)">
|
||||
<path d="M15 19H7V11H15V19Z" fill="#4B4646"/>
|
||||
<path d="M15 7H7V19H15V7ZM19 23H3V3H19V23Z" fill="#F1ECEC"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1311_94916">
|
||||
<rect width="16" height="20" fill="white" transform="translate(3 3)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,11 @@
|
||||
<svg width="22" height="26" viewBox="0 0 22 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1311_94907)">
|
||||
<path d="M15 19H7V11H15V19Z" fill="#BCBBBB"/>
|
||||
<path d="M15 7H7V19H15V7ZM19 23H3V3H19V23Z" fill="#211E1E"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1311_94907">
|
||||
<rect width="16" height="20" fill="white" transform="translate(3 3)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,11 @@
|
||||
<svg width="32" height="40" viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1311_94973)">
|
||||
<path d="M24 32H8V16H24V32Z" fill="#4B4646"/>
|
||||
<path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#F1ECEC"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1311_94973">
|
||||
<rect width="32" height="40" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 358 B |
@@ -0,0 +1,11 @@
|
||||
<svg width="32" height="40" viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1311_94969)">
|
||||
<path d="M24 32H8V16H24V32Z" fill="#BCBBBB"/>
|
||||
<path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#211E1E"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1311_94969">
|
||||
<rect width="32" height="40" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 358 B |
@@ -0,0 +1,25 @@
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1311_95032)">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/>
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/>
|
||||
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/>
|
||||
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/>
|
||||
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/>
|
||||
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/>
|
||||
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/>
|
||||
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/>
|
||||
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1311_95032">
|
||||
<rect width="234" height="42" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,25 @@
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1311_95049)">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#CFCECD"/>
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#656363"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#CFCECD"/>
|
||||
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#656363"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#CFCECD"/>
|
||||
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#656363"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#CFCECD"/>
|
||||
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#656363"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#CFCECD"/>
|
||||
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#211E1E"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#CFCECD"/>
|
||||
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#211E1E"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#CFCECD"/>
|
||||
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#211E1E"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#CFCECD"/>
|
||||
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#211E1E"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1311_95049">
|
||||
<rect width="234" height="42" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="22" height="26" viewBox="0 0 22 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.33203 7.99967V6.33301H10.9987M17.6654 7.99967V6.33301H10.9987M10.9987 6.33301V19.6663M10.9987 19.6663H9.33203M10.9987 19.6663H12.6654" stroke="#F1ECEC" stroke-width="2" stroke-linecap="square"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 310 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="22" height="26" viewBox="0 0 22 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.33203 7.99967V6.33301H10.9987M17.6654 7.99967V6.33301H10.9987M10.9987 6.33301V19.6663M10.9987 19.6663H9.33203M10.9987 19.6663H12.6654" stroke="black" stroke-width="2" stroke-linecap="square"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
@@ -0,0 +1,63 @@
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
min-width: 160px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-background);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(19, 16, 16, 0.08),
|
||||
0 6px 8px -4px rgba(19, 16, 16, 0.12),
|
||||
0 4px 3px -2px rgba(19, 16, 16, 0.12),
|
||||
0 1px 2px -1px rgba(19, 16, 16, 0.12);
|
||||
padding: 6px;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
box-shadow: 0 0 0 1px rgba(247, 237, 237, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 8px 16px 8px 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: left;
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
[data-slot="copy dark"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
[data-slot="copy light"] {
|
||||
display: none;
|
||||
}
|
||||
[data-slot="copy dark"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-weak-hover);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 22px;
|
||||
height: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: var(--space-1) 0;
|
||||
}
|
||||
@@ -1,11 +1,40 @@
|
||||
import logoLight from "../asset/logo-ornate-light.svg"
|
||||
import logoDark from "../asset/logo-ornate-dark.svg"
|
||||
import { A, createAsync } from "@solidjs/router"
|
||||
import copyLogoLight from "../asset/lander/logo-light.svg"
|
||||
import copyLogoDark from "../asset/lander/logo-dark.svg"
|
||||
import copyWordmarkLight from "../asset/lander/wordmark-light.svg"
|
||||
import copyWordmarkDark from "../asset/lander/wordmark-dark.svg"
|
||||
import copyBrandAssetsLight from "../asset/lander/brand-assets-light.svg"
|
||||
import copyBrandAssetsDark from "../asset/lander/brand-assets-dark.svg"
|
||||
|
||||
// SVG files for copying (separate from button icons)
|
||||
// Replace these with your actual SVG files for copying
|
||||
import copyLogoSvgLight from "../asset/lander/opencode-logo-light.svg"
|
||||
import copyLogoSvgDark from "../asset/lander/opencode-logo-dark.svg"
|
||||
import copyWordmarkSvgLight from "../asset/lander/opencode-wordmark-light.svg"
|
||||
import copyWordmarkSvgDark from "../asset/lander/opencode-wordmark-dark.svg"
|
||||
import { A, createAsync, useNavigate } from "@solidjs/router"
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { github } from "~/lib/github"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import "./header-context-menu.css"
|
||||
|
||||
const isDarkMode = () => window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
|
||||
const fetchSvgContent = async (svgPath: string): Promise<string> => {
|
||||
try {
|
||||
const response = await fetch(svgPath)
|
||||
const svgText = await response.text()
|
||||
return svgText
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch SVG content:", err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function Header(props: { zen?: boolean }) {
|
||||
const navigate = useNavigate()
|
||||
const githubData = createAsync(() => github())
|
||||
const starCount = createMemo(() =>
|
||||
githubData()?.stars
|
||||
@@ -13,19 +42,109 @@ export function Header(props: { zen?: boolean }) {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
}).format(githubData()?.stars!)
|
||||
: "25K",
|
||||
: "29K",
|
||||
)
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
mobileMenuOpen: false,
|
||||
contextMenuOpen: false,
|
||||
contextMenuPosition: { x: 0, y: 0 },
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const handleClickOutside = () => {
|
||||
setStore("contextMenuOpen", false)
|
||||
}
|
||||
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
setStore("contextMenuOpen", false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setStore("contextMenuOpen", false)
|
||||
}
|
||||
}
|
||||
|
||||
if (store.contextMenuOpen) {
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
document.addEventListener("contextmenu", handleContextMenu)
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("click", handleClickOutside)
|
||||
document.removeEventListener("contextmenu", handleContextMenu)
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const handleLogoContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
const logoElement = (event.currentTarget as HTMLElement).querySelector("a")
|
||||
if (logoElement) {
|
||||
const rect = logoElement.getBoundingClientRect()
|
||||
setStore("contextMenuPosition", {
|
||||
x: rect.left - 16,
|
||||
y: rect.bottom + 8,
|
||||
})
|
||||
}
|
||||
setStore("contextMenuOpen", true)
|
||||
}
|
||||
|
||||
const copyWordmarkToClipboard = async () => {
|
||||
try {
|
||||
const isDark = isDarkMode()
|
||||
const wordmarkSvgPath = isDark ? copyWordmarkSvgDark : copyWordmarkSvgLight
|
||||
const wordmarkSvg = await fetchSvgContent(wordmarkSvgPath)
|
||||
await navigator.clipboard.writeText(wordmarkSvg)
|
||||
} catch (err) {
|
||||
console.error("Failed to copy wordmark to clipboard:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const copyLogoToClipboard = async () => {
|
||||
try {
|
||||
const isDark = isDarkMode()
|
||||
const logoSvgPath = isDark ? copyLogoSvgDark : copyLogoSvgLight
|
||||
const logoSvg = await fetchSvgContent(logoSvgPath)
|
||||
await navigator.clipboard.writeText(logoSvg)
|
||||
} catch (err) {
|
||||
console.error("Failed to copy logo to clipboard:", err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section data-component="top">
|
||||
<A href="/">
|
||||
<img data-slot="logo light" src={logoLight} alt="opencode logo light" />
|
||||
<img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
|
||||
</A>
|
||||
<div onContextMenu={handleLogoContextMenu}>
|
||||
<A href="/">
|
||||
<img data-slot="logo light" src={logoLight} alt="opencode logo light" />
|
||||
<img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
|
||||
</A>
|
||||
</div>
|
||||
|
||||
<Show when={store.contextMenuOpen}>
|
||||
<div
|
||||
class="context-menu"
|
||||
style={`left: ${store.contextMenuPosition.x}px; top: ${store.contextMenuPosition.y}px;`}
|
||||
>
|
||||
<button class="context-menu-item" onClick={copyLogoToClipboard}>
|
||||
<img data-slot="copy light" src={copyLogoLight} alt="Logo" />
|
||||
<img data-slot="copy dark" src={copyLogoDark} alt="Logo" />
|
||||
Copy logo as SVG
|
||||
</button>
|
||||
<button class="context-menu-item" onClick={copyWordmarkToClipboard}>
|
||||
<img data-slot="copy light" src={copyWordmarkLight} alt="Wordmark" />
|
||||
<img data-slot="copy dark" src={copyWordmarkDark} alt="Wordmark" />
|
||||
Copy wordmark as SVG
|
||||
</button>
|
||||
<button class="context-menu-item" onClick={() => navigate("/brand")}>
|
||||
<img data-slot="copy light" src={copyBrandAssetsLight} alt="Brand Assets" />
|
||||
<img data-slot="copy dark" src={copyBrandAssetsDark} alt="Brand Assets" />
|
||||
Brand assets
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<nav data-component="nav-desktop">
|
||||
<ul>
|
||||
<li>
|
||||
@@ -36,6 +155,9 @@ export function Header(props: { zen?: boolean }) {
|
||||
<li>
|
||||
<a href="/docs">Docs</a>
|
||||
</li>
|
||||
<li>
|
||||
<A href="/enterprise">Enterprise</A>
|
||||
</li>
|
||||
<li>
|
||||
<Switch>
|
||||
<Match when={props.zen}>
|
||||
@@ -107,6 +229,9 @@ export function Header(props: { zen?: boolean }) {
|
||||
<li>
|
||||
<a href="/docs">Docs</a>
|
||||
</li>
|
||||
<li>
|
||||
<A href="/enterprise">Enterprise</A>
|
||||
</li>
|
||||
<li>
|
||||
<Switch>
|
||||
<Match when={props.zen}>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { A } from "@solidjs/router"
|
||||
|
||||
export function Legal() {
|
||||
return (
|
||||
<div data-component="legal">
|
||||
<span>
|
||||
©{new Date().getFullYear()} <a href="https://anoma.ly">Anomaly</a>
|
||||
</span>
|
||||
<span>
|
||||
<A href="/brand">Brand</A>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { AWS } from "@opencode-ai/console-core/aws.js"
|
||||
|
||||
interface EnterpriseFormData {
|
||||
name: string
|
||||
role: string
|
||||
email: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export async function POST(event: APIEvent) {
|
||||
try {
|
||||
const body = (await event.request.json()) as EnterpriseFormData
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name || !body.role || !body.email || !body.message) {
|
||||
return Response.json({ error: "All fields are required" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(body.email)) {
|
||||
return Response.json({ error: "Invalid email format" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Create email content
|
||||
const emailContent = `
|
||||
${body.message}<br><br>
|
||||
--<br>
|
||||
${body.name}<br>
|
||||
${body.role}<br>
|
||||
${body.email}`.trim()
|
||||
|
||||
// Send email using AWS SES
|
||||
await AWS.sendEmail({
|
||||
to: "contact@anoma.ly",
|
||||
subject: `Enterprise Inquiry from ${body.name}`,
|
||||
body: emailContent,
|
||||
})
|
||||
|
||||
return Response.json({ success: true, message: "Form submitted successfully" }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Error processing enterprise form:", error)
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
::selection {
|
||||
background: var(--color-background-interactive);
|
||||
color: var(--color-text-strong);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: var(--color-background-interactive);
|
||||
color: var(--color-text-inverted);
|
||||
}
|
||||
}
|
||||
|
||||
[data-page="enterprise"] {
|
||||
--color-background: hsl(0, 20%, 99%);
|
||||
--color-background-weak: hsl(0, 8%, 97%);
|
||||
--color-background-weak-hover: hsl(0, 8%, 94%);
|
||||
--color-background-strong: hsl(0, 5%, 12%);
|
||||
--color-background-strong-hover: hsl(0, 5%, 18%);
|
||||
--color-background-interactive: hsl(62, 84%, 88%);
|
||||
--color-background-interactive-weaker: hsl(64, 74%, 95%);
|
||||
|
||||
--color-text: hsl(0, 1%, 39%);
|
||||
--color-text-weak: hsl(0, 1%, 60%);
|
||||
--color-text-weaker: hsl(30, 2%, 81%);
|
||||
--color-text-strong: hsl(0, 5%, 12%);
|
||||
--color-text-inverted: hsl(0, 20%, 99%);
|
||||
--color-text-success: hsl(119, 100%, 35%);
|
||||
|
||||
--color-border: hsl(30, 2%, 81%);
|
||||
--color-border-weak: hsl(0, 1%, 85%);
|
||||
|
||||
--color-icon: hsl(0, 1%, 55%);
|
||||
--color-success: hsl(142, 76%, 36%);
|
||||
|
||||
background: var(--color-background);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text);
|
||||
padding-bottom: 5rem;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--color-background: hsl(0, 9%, 7%);
|
||||
--color-background-weak: hsl(0, 6%, 10%);
|
||||
--color-background-weak-hover: hsl(0, 6%, 15%);
|
||||
--color-background-strong: hsl(0, 15%, 94%);
|
||||
--color-background-strong-hover: hsl(0, 15%, 97%);
|
||||
--color-background-interactive: hsl(62, 100%, 90%);
|
||||
--color-background-interactive-weaker: hsl(60, 20%, 8%);
|
||||
|
||||
--color-text: hsl(0, 4%, 71%);
|
||||
--color-text-weak: hsl(0, 2%, 49%);
|
||||
--color-text-weaker: hsl(0, 3%, 28%);
|
||||
--color-text-strong: hsl(0, 15%, 94%);
|
||||
--color-text-inverted: hsl(0, 9%, 7%);
|
||||
--color-text-success: hsl(119, 60%, 72%);
|
||||
|
||||
--color-border: hsl(0, 3%, 28%);
|
||||
--color-border-weak: hsl(0, 4%, 23%);
|
||||
|
||||
--color-icon: hsl(10, 3%, 43%);
|
||||
--color-success: hsl(142, 76%, 46%);
|
||||
}
|
||||
|
||||
/* Header and Footer styles - copied from index.css */
|
||||
[data-component="top"] {
|
||||
padding: 24px 5rem;
|
||||
height: 80px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--color-background);
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
z-index: 10;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
padding: 24px 1.5rem;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 34px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
[data-component="nav-desktop"] {
|
||||
ul {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 48px;
|
||||
li {
|
||||
display: inline-block;
|
||||
a {
|
||||
text-decoration: none;
|
||||
span {
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="nav-mobile"] {
|
||||
button > svg {
|
||||
color: var(--color-icon);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="nav-mobile-toggle"] {
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
cursor: pointer;
|
||||
margin-right: -8px;
|
||||
}
|
||||
|
||||
[data-component="nav-mobile-toggle"]:hover {
|
||||
background: var(--color-background-weak);
|
||||
}
|
||||
|
||||
[data-component="nav-mobile"] {
|
||||
display: none;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
display: block;
|
||||
|
||||
[data-component="nav-mobile-icon"] {
|
||||
cursor: pointer;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-component="nav-mobile-menu-list"] {
|
||||
position: fixed;
|
||||
background: var(--color-background);
|
||||
top: 80px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 20px 0;
|
||||
|
||||
li {
|
||||
a {
|
||||
text-decoration: none;
|
||||
padding: 20px;
|
||||
display: block;
|
||||
|
||||
span {
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: var(--color-background-weak);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="logo dark"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
[data-slot="logo light"] {
|
||||
display: none;
|
||||
}
|
||||
[data-slot="logo dark"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="footer"] {
|
||||
border-top: 1px solid var(--color-border-weak);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@media (max-width: 65rem) {
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
}
|
||||
|
||||
[data-slot="cell"] {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
padding: 2rem 0;
|
||||
width: 100%;
|
||||
display: block;
|
||||
|
||||
span {
|
||||
color: var(--color-text-weak);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: var(--color-background-weak);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="cell"] + [data-slot="cell"] {
|
||||
border-left: 1px solid var(--color-border-weak);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: third column on its own row */
|
||||
@media (max-width: 25rem) {
|
||||
flex-wrap: wrap;
|
||||
|
||||
[data-slot="cell"] {
|
||||
flex: 1 0 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border-weak);
|
||||
}
|
||||
|
||||
[data-slot="cell"]:nth-child(1) {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="container"] {
|
||||
max-width: 67.5rem;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--color-border-weak);
|
||||
border-top: none;
|
||||
|
||||
@media (max-width: 65rem) {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="content"] {
|
||||
}
|
||||
|
||||
[data-component="brand-content"] {
|
||||
padding: 4rem 5rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-strong);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-strong);
|
||||
margin: 2rem 0 1rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.6;
|
||||
margin-bottom: 2.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
[data-component="download-button"] {
|
||||
padding: 8px 12px 8px 20px;
|
||||
background: var(--color-background-strong);
|
||||
color: var(--color-text-inverted);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-background-strong-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="brand-grid"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-top: 4rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
[data-component="brand-grid"] img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border-weak);
|
||||
}
|
||||
|
||||
[data-component="brand-grid"] > div {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-component="actions"] {
|
||||
position: absolute;
|
||||
background: rgba(4, 0, 0, 0.08);
|
||||
border-radius: 4px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
background: none;
|
||||
margin-top: 1rem;
|
||||
justify-content: start;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="brand-grid"] > div:hover [data-component="actions"] {
|
||||
opacity: 1;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="actions"] button {
|
||||
padding: 6px 12px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(19, 16, 16, 0.08),
|
||||
0 6px 8px -4px rgba(19, 16, 16, 0.12),
|
||||
0 4px 3px -2px rgba(19, 16, 16, 0.12),
|
||||
0 1px 2px -1px rgba(19, 16, 16, 0.12);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(19, 16, 16, 0.16)
|
||||
}
|
||||
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 0 0 1px rgba(19, 16, 16, 0.08), 0 6px 8px -8px rgba(19, 16, 16, 0.50);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="faq"] {
|
||||
border-top: 1px solid var(--color-border-weak);
|
||||
padding: 4rem 5rem;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
[data-slot="section-title"] {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-strong);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
margin-bottom: 24px;
|
||||
line-height: 200%;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
line-height: 180%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="faq-question"] {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
[data-slot="faq-icon-plus"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-weak);
|
||||
margin-top: 2px;
|
||||
|
||||
[data-closed] & {
|
||||
display: block;
|
||||
}
|
||||
[data-expanded] & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
[data-slot="faq-icon-minus"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-weak);
|
||||
margin-top: 2px;
|
||||
|
||||
[data-closed] & {
|
||||
display: none;
|
||||
}
|
||||
[data-expanded] & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
[data-slot="faq-question-text"] {
|
||||
flex-grow: 1;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="faq-answer"] {
|
||||
margin-left: 40px;
|
||||
margin-bottom: 32px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="legal"] {
|
||||
color: var(--color-text-weak);
|
||||
text-align: center;
|
||||
padding: 2rem 5rem;
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
justify-content: center;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-weak);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-strong);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
|
||||
&:hover {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import "./index.css"
|
||||
import { Title, Meta } from "@solidjs/meta"
|
||||
import { Header } from "~/component/header"
|
||||
import { Footer } from "~/component/footer"
|
||||
import { Legal } from "~/component/legal"
|
||||
import previewLogoLight from "../../asset/brand/preview-opencode-logo-light.png"
|
||||
import previewLogoDark from "../../asset/brand/preview-opencode-logo-dark.png"
|
||||
import previewWordmarkLight from "../../asset/brand/preview-opencode-wordmark-light.png"
|
||||
import previewWordmarkDark from "../../asset/brand/preview-opencode-wordmark-dark.png"
|
||||
import previewWordmarkSimpleLight from "../../asset/brand/preview-opencode-wordmark-simple-light.png"
|
||||
import previewWordmarkSimpleDark from "../../asset/brand/preview-opencode-wordmark-simple-dark.png"
|
||||
import logoLightPng from "../../asset/brand/opencode-logo-light.png"
|
||||
import logoDarkPng from "../../asset/brand/opencode-logo-dark.png"
|
||||
import wordmarkLightPng from "../../asset/brand/opencode-wordmark-light.png"
|
||||
import wordmarkDarkPng from "../../asset/brand/opencode-wordmark-dark.png"
|
||||
import wordmarkSimpleLightPng from "../../asset/brand/opencode-wordmark-simple-light.png"
|
||||
import wordmarkSimpleDarkPng from "../../asset/brand/opencode-wordmark-simple-dark.png"
|
||||
import logoLightSvg from "../../asset/brand/opencode-logo-light.svg"
|
||||
import logoDarkSvg from "../../asset/brand/opencode-logo-dark.svg"
|
||||
import wordmarkLightSvg from "../../asset/brand/opencode-wordmark-light.svg"
|
||||
import wordmarkDarkSvg from "../../asset/brand/opencode-wordmark-dark.svg"
|
||||
import wordmarkSimpleLightSvg from "../../asset/brand/opencode-wordmark-simple-light.svg"
|
||||
import wordmarkSimpleDarkSvg from "../../asset/brand/opencode-wordmark-simple-dark.svg"
|
||||
const brandAssets = "/opencode-brand-assets.zip"
|
||||
|
||||
export default function Brand() {
|
||||
const downloadFile = async (url: string, filename: string) => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
const blob = await response.blob()
|
||||
const blobUrl = window.URL.createObjectURL(blob)
|
||||
|
||||
const link = document.createElement("a")
|
||||
link.href = blobUrl
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
window.URL.revokeObjectURL(blobUrl)
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.target = "_blank"
|
||||
link.rel = "noopener noreferrer"
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main data-page="enterprise">
|
||||
<Title>OpenCode | Brand</Title>
|
||||
<Meta name="description" content="OpenCode brand guidelines" />
|
||||
<div data-component="container">
|
||||
<Header />
|
||||
|
||||
<div data-component="content">
|
||||
<section data-component="brand-content">
|
||||
<h2>Brand guidelines</h2>
|
||||
<p>Resources and assets to help you work with the OpenCode brand.</p>
|
||||
<button
|
||||
data-component="download-button"
|
||||
onClick={() => downloadFile(brandAssets, "opencode-brand-assets.zip")}
|
||||
>
|
||||
Download all assets
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div data-component="brand-grid">
|
||||
<div>
|
||||
<img src={previewLogoLight} alt="OpenCode brand guidelines" />
|
||||
<div data-component="actions">
|
||||
<button onClick={() => downloadFile(logoLightPng, "opencode-logo-light.png")}>
|
||||
PNG
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => downloadFile(logoLightSvg, "opencode-logo-light.svg")}>
|
||||
SVG
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<img src={previewLogoDark} alt="OpenCode brand guidelines" />
|
||||
<div data-component="actions">
|
||||
<button onClick={() => downloadFile(logoDarkPng, "opencode-logo-dark.png")}>
|
||||
PNG
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => downloadFile(logoDarkSvg, "opencode-logo-dark.svg")}>
|
||||
SVG
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<img src={previewWordmarkLight} alt="OpenCode brand guidelines" />
|
||||
<div data-component="actions">
|
||||
<button
|
||||
onClick={() => downloadFile(wordmarkLightPng, "opencode-wordmark-light.png")}
|
||||
>
|
||||
PNG
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadFile(wordmarkLightSvg, "opencode-wordmark-light.svg")}
|
||||
>
|
||||
SVG
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<img src={previewWordmarkDark} alt="OpenCode brand guidelines" />
|
||||
<div data-component="actions">
|
||||
<button
|
||||
onClick={() => downloadFile(wordmarkDarkPng, "opencode-wordmark-dark.png")}
|
||||
>
|
||||
PNG
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadFile(wordmarkDarkSvg, "opencode-wordmark-dark.svg")}
|
||||
>
|
||||
SVG
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<img src={previewWordmarkSimpleLight} alt="OpenCode brand guidelines" />
|
||||
<div data-component="actions">
|
||||
<button
|
||||
onClick={() =>
|
||||
downloadFile(wordmarkSimpleLightPng, "opencode-wordmark-simple-light.png")
|
||||
}
|
||||
>
|
||||
PNG
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
downloadFile(wordmarkSimpleLightSvg, "opencode-wordmark-simple-light.svg")
|
||||
}
|
||||
>
|
||||
SVG
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<img src={previewWordmarkSimpleDark} alt="OpenCode brand guidelines" />
|
||||
<div data-component="actions">
|
||||
<button
|
||||
onClick={() =>
|
||||
downloadFile(wordmarkSimpleDarkPng, "opencode-wordmark-simple-dark.png")
|
||||
}
|
||||
>
|
||||
PNG
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
downloadFile(wordmarkSimpleDarkSvg, "opencode-wordmark-simple-dark.svg")
|
||||
}
|
||||
>
|
||||
SVG
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
<Legal />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
::selection {
|
||||
background: var(--color-background-interactive);
|
||||
color: var(--color-text-strong);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: var(--color-background-interactive);
|
||||
color: var(--color-text-inverted);
|
||||
}
|
||||
}
|
||||
|
||||
[data-page="enterprise"] {
|
||||
--color-background: hsl(0, 20%, 99%);
|
||||
--color-background-weak: hsl(0, 8%, 97%);
|
||||
--color-background-weak-hover: hsl(0, 8%, 94%);
|
||||
--color-background-strong: hsl(0, 5%, 12%);
|
||||
--color-background-strong-hover: hsl(0, 5%, 18%);
|
||||
--color-background-interactive: hsl(62, 84%, 88%);
|
||||
--color-background-interactive-weaker: hsl(64, 74%, 95%);
|
||||
|
||||
--color-text: hsl(0, 1%, 39%);
|
||||
--color-text-weak: hsl(0, 1%, 60%);
|
||||
--color-text-weaker: hsl(30, 2%, 81%);
|
||||
--color-text-strong: hsl(0, 5%, 12%);
|
||||
--color-text-inverted: hsl(0, 20%, 99%);
|
||||
--color-text-success: hsl(119, 100%, 35%);
|
||||
|
||||
--color-border: hsl(30, 2%, 81%);
|
||||
--color-border-weak: hsl(0, 1%, 85%);
|
||||
|
||||
--color-icon: hsl(0, 1%, 55%);
|
||||
--color-success: hsl(142, 76%, 36%);
|
||||
|
||||
background: var(--color-background);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text);
|
||||
padding-bottom: 5rem;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--color-background: hsl(0, 9%, 7%);
|
||||
--color-background-weak: hsl(0, 6%, 10%);
|
||||
--color-background-weak-hover: hsl(0, 6%, 15%);
|
||||
--color-background-strong: hsl(0, 15%, 94%);
|
||||
--color-background-strong-hover: hsl(0, 15%, 97%);
|
||||
--color-background-interactive: hsl(62, 100%, 90%);
|
||||
--color-background-interactive-weaker: hsl(60, 20%, 8%);
|
||||
|
||||
--color-text: hsl(0, 4%, 71%);
|
||||
--color-text-weak: hsl(0, 2%, 49%);
|
||||
--color-text-weaker: hsl(0, 3%, 28%);
|
||||
--color-text-strong: hsl(0, 15%, 94%);
|
||||
--color-text-inverted: hsl(0, 9%, 7%);
|
||||
--color-text-success: hsl(119, 60%, 72%);
|
||||
|
||||
--color-border: hsl(0, 3%, 28%);
|
||||
--color-border-weak: hsl(0, 4%, 23%);
|
||||
|
||||
--color-icon: hsl(10, 3%, 43%);
|
||||
--color-success: hsl(142, 76%, 46%);
|
||||
}
|
||||
|
||||
/* Header and Footer styles - copied from index.css */
|
||||
[data-component="top"] {
|
||||
padding: 24px 5rem;
|
||||
height: 80px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--color-background);
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
z-index: 10;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
padding: 24px 1.5rem;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 34px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
[data-component="nav-desktop"] {
|
||||
ul {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 48px;
|
||||
li {
|
||||
display: inline-block;
|
||||
a {
|
||||
text-decoration: none;
|
||||
span {
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="nav-mobile"] {
|
||||
button > svg {
|
||||
color: var(--color-icon);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="nav-mobile-toggle"] {
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
cursor: pointer;
|
||||
margin-right: -8px;
|
||||
}
|
||||
|
||||
[data-component="nav-mobile-toggle"]:hover {
|
||||
background: var(--color-background-weak);
|
||||
}
|
||||
|
||||
[data-component="nav-mobile"] {
|
||||
display: none;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
display: block;
|
||||
|
||||
[data-component="nav-mobile-icon"] {
|
||||
cursor: pointer;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-component="nav-mobile-menu-list"] {
|
||||
position: fixed;
|
||||
background: var(--color-background);
|
||||
top: 80px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 20px 0;
|
||||
|
||||
li {
|
||||
a {
|
||||
text-decoration: none;
|
||||
padding: 20px;
|
||||
display: block;
|
||||
|
||||
span {
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: var(--color-background-weak);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="logo dark"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
[data-slot="logo light"] {
|
||||
display: none;
|
||||
}
|
||||
[data-slot="logo dark"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="footer"] {
|
||||
border-top: 1px solid var(--color-border-weak);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@media (max-width: 65rem) {
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
}
|
||||
|
||||
[data-slot="cell"] {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
padding: 2rem 0;
|
||||
width: 100%;
|
||||
display: block;
|
||||
|
||||
span {
|
||||
color: var(--color-text-weak);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: var(--color-background-weak);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="cell"] + [data-slot="cell"] {
|
||||
border-left: 1px solid var(--color-border-weak);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: third column on its own row */
|
||||
@media (max-width: 25rem) {
|
||||
flex-wrap: wrap;
|
||||
|
||||
[data-slot="cell"] {
|
||||
flex: 1 0 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border-weak);
|
||||
}
|
||||
|
||||
[data-slot="cell"]:nth-child(1) {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="container"] {
|
||||
max-width: 67.5rem;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--color-border-weak);
|
||||
border-top: none;
|
||||
|
||||
@media (max-width: 65rem) {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="content"] {
|
||||
}
|
||||
|
||||
[data-component="enterprise-content"] {
|
||||
padding: 4rem 0;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="enterprise-columns"] {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4rem;
|
||||
padding: 4rem 5rem;
|
||||
|
||||
@media (max-width: 80rem) {
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3rem;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="enterprise-column-1"] {
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-strong);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-strong);
|
||||
margin: 2rem 0 1rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
[data-component="testimonial"] {
|
||||
margin-top: 4rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-strong);
|
||||
|
||||
[data-component="quotation"] {
|
||||
svg {
|
||||
margin-bottom: 1rem;
|
||||
opacity: 20%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="testimonial-logo"] {
|
||||
svg {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="enterprise-column-2"] {
|
||||
[data-component="enterprise-form"] {
|
||||
padding: 0;
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-strong);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
[data-component="form-group"] {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-weak);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
transition: background-color 5000000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
input:-webkit-autofill {
|
||||
-webkit-text-fill-color: var(--color-text-strong) !important;
|
||||
}
|
||||
|
||||
input:-moz-autofill {
|
||||
-moz-text-fill-color: var(--color-text-strong) !important;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-border-weak);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-weak);
|
||||
color: var(--color-text-strong);
|
||||
font-family: inherit;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: var(--color-background-interactive-weaker);
|
||||
outline: none;
|
||||
border: none;
|
||||
color: var(--color-text-strong);
|
||||
border: 1px solid var(--color-background-strong);
|
||||
box-shadow: 0 0 0 3px var(--color-background-interactive);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--color-background-interactive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="submit-button"] {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: var(--color-background-strong);
|
||||
color: var(--color-text-inverted);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-background-strong-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="success-message"] {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem 0;
|
||||
color: var(--color-text-success);
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="faq"] {
|
||||
border-top: 1px solid var(--color-border-weak);
|
||||
padding: 4rem 5rem;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
[data-slot="section-title"] {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-strong);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
margin-bottom: 24px;
|
||||
line-height: 200%;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
line-height: 180%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="faq-question"] {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
[data-slot="faq-icon-plus"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-weak);
|
||||
margin-top: 2px;
|
||||
|
||||
[data-closed] & {
|
||||
display: block;
|
||||
}
|
||||
[data-expanded] & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
[data-slot="faq-icon-minus"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-weak);
|
||||
margin-top: 2px;
|
||||
|
||||
[data-closed] & {
|
||||
display: none;
|
||||
}
|
||||
[data-expanded] & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
[data-slot="faq-question-text"] {
|
||||
flex-grow: 1;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="faq-answer"] {
|
||||
margin-left: 40px;
|
||||
margin-bottom: 32px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="legal"] {
|
||||
color: var(--color-text-weak);
|
||||
text-align: center;
|
||||
padding: 2rem 5rem;
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
justify-content: center;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-weak);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-strong);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
|
||||
&:hover {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import "./index.css"
|
||||
import { Title, Meta } from "@solidjs/meta"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { Header } from "~/component/header"
|
||||
import { Footer } from "~/component/footer"
|
||||
import { Legal } from "~/component/legal"
|
||||
import { Faq } from "~/component/faq"
|
||||
|
||||
export default function Enterprise() {
|
||||
const [formData, setFormData] = createSignal({
|
||||
name: "",
|
||||
role: "",
|
||||
email: "",
|
||||
message: "",
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
||||
const [showSuccess, setShowSuccess] = createSignal(false)
|
||||
|
||||
const handleInputChange = (field: string) => (e: Event) => {
|
||||
const target = e.target as HTMLInputElement | HTMLTextAreaElement
|
||||
setFormData((prev) => ({ ...prev, [field]: target.value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/enterprise", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(formData()),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setShowSuccess(true)
|
||||
setFormData({
|
||||
name: "",
|
||||
role: "",
|
||||
email: "",
|
||||
message: "",
|
||||
})
|
||||
setTimeout(() => setShowSuccess(false), 5000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to submit form:", error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main data-page="enterprise">
|
||||
<Title>OpenCode | Enterprise solutions for your organisation</Title>
|
||||
<Meta name="description" content="Contact OpenCode for enterprise solutions" />
|
||||
<div data-component="container">
|
||||
<Header />
|
||||
|
||||
<div data-component="content">
|
||||
<section data-component="enterprise-content">
|
||||
<div data-component="enterprise-columns">
|
||||
<div data-component="enterprise-column-1">
|
||||
<h2>Your code is yours</h2>
|
||||
<p>
|
||||
OpenCode operates securely inside your organization with no data or context stored
|
||||
and no licensing restrictions or ownership claims. Start a trial with your team,
|
||||
then deploy it across your organization by integrating it with your SSO and
|
||||
internal AI gateway.
|
||||
</p>
|
||||
<p>Let us know and how we can help.</p>
|
||||
|
||||
<Show when={false}>
|
||||
<div data-component="testimonial">
|
||||
<div data-component="quotation">
|
||||
<svg
|
||||
width="20"
|
||||
height="17"
|
||||
viewBox="0 0 20 17"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.4118 0L16.5882 9.20833H20V17H12.2353V10.0938L16 0H19.4118ZM7.17647 0L4.35294 9.20833H7.76471V17H0V10.0938L3.76471 0H7.17647Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
Thanks to OpenCode, we found a way to create software to track all our assets —
|
||||
even the imaginary ones.
|
||||
<div data-component="testimonial-logo">
|
||||
<svg
|
||||
width="80"
|
||||
height="79"
|
||||
viewBox="0 0 80 79"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0 39.3087L10.0579 29.251L15.6862 34.7868L13.7488 36.7248L10.3345 33.2186L8.48897 35.0639L11.8111 38.4781L9.96557 40.4156L6.55181 37.0018L4.06028 39.4928L7.56674 42.9991L5.62884 44.845L0 39.3087Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M17.7182 36.8164L20.2094 39.4003L16.6108 46.9666L22.2393 41.3374L24.3615 43.46L14.2118 53.5179L11.9047 51.1187L15.4112 43.3677L9.78254 49.0888L7.66016 46.9666L17.7182 36.8164Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M42.8139 61.915L45.3055 64.4064L41.6145 71.9731L47.243 66.3441L49.3652 68.4663L39.3077 78.5244L36.9088 76.1252L40.5072 68.374L34.7866 74.0953L32.6641 71.9731L42.8139 61.915Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M16.4258 55.7324L26.4833 45.582L28.6061 47.7042C31.0049 50.1034 32.3892 51.9497 30.1746 54.1642C28.7902 55.548 27.6831 56.0094 26.1145 54.9016L26.0222 54.994C27.2218 56.1941 26.9448 57.1162 25.4688 58.5931L23.9 60.1615C23.4383 60.6232 22.8847 61.2693 22.7927 62.0067L20.6705 59.8845C20.7625 59.146 21.3161 58.5008 21.778 58.1316L23.5307 56.3788C24.269 55.6403 23.715 54.2555 23.254 53.8872L22.8847 53.4256L18.548 57.7623L16.4258 55.7324ZM24.3611 51.9495C25.4689 53.0563 26.4833 53.3332 27.4984 52.3178C28.5134 51.3957 28.2367 50.3802 27.1295 49.1812L24.3611 51.9495Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M33.4952 66.9899C31.096 69.3891 28.8815 68.4659 27.4047 66.9899C26.021 65.6062 25.0978 63.3907 27.4972 60.9003L31.8336 56.6548C34.2333 54.2556 36.4478 55.0864 37.9241 56.5635C39.308 58.0396 40.2311 60.2541 37.8315 62.6531L33.4952 66.9899ZM29.0659 63.5752C28.6048 64.0369 28.6048 64.7753 29.1583 65.3292C29.6196 65.8821 30.4502 65.7897 30.8194 65.4215L36.2633 59.9769C36.7246 59.6076 36.7246 58.7779 36.171 58.3164C35.7097 57.7626 34.8791 57.7626 34.5101 58.2241L29.0659 63.5752Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M78.5267 39.308L68.2845 29.0654L47.5231 49.735L49.6453 51.8572L68.2845 33.2179L74.3746 39.308L47.2461 66.3435L49.3683 68.4657L78.5267 39.308Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M49.6443 51.8577L43.3695 45.4902L64.0386 24.8215L53.7969 14.4873L33.0352 35.2482L35.1574 37.3705L53.7969 18.7315L59.7947 24.8215L39.1251 45.4902L47.5221 53.9799L49.6443 51.8577Z"
|
||||
fill="#2D9C5C"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M35.1564 37.3706L28.7896 31.0038L49.5515 10.3347L39.3088 0L10.0586 29.2507L12.1804 31.2804L39.3088 4.24476L45.3066 10.3347L24.6377 31.0038L33.0342 39.4008L35.1564 37.3706Z"
|
||||
fill="#E92A35"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M77.2332 52.4105C76.0336 52.4105 75.111 51.4884 75.111 50.196C75.111 48.9046 76.0336 47.9814 77.2332 47.9814C78.3405 47.9814 79.263 48.9046 79.263 50.196C79.263 51.4884 78.3405 52.4105 77.2332 52.4105ZM77.2332 52.9643C78.7098 52.9643 80.0015 51.6729 80.0015 50.196C80.0015 48.6276 78.7096 47.4287 77.2332 47.4287C75.6644 47.4287 74.4648 48.6278 74.4648 50.196C74.4647 51.6731 75.6643 52.9643 77.2332 52.9643ZM76.1259 51.7653H76.6797V50.3804H77.0485L77.8788 51.7653H78.4332L77.6023 50.3804C78.1558 50.2881 78.4332 50.0122 78.4332 49.5507C78.4332 48.9046 78.0633 48.6276 77.3253 48.6276H76.1257V51.7653H76.1259ZM76.6797 49.0892H77.2332C77.5102 49.0892 77.8788 49.0892 77.8788 49.4586C77.8788 49.9202 77.6023 49.9202 77.2332 49.9202H76.6797V49.0892Z"
|
||||
fill="#0083C6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div data-component="enterprise-column-2">
|
||||
<div data-component="enterprise-form">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div data-component="form-group">
|
||||
<label for="name">Full name</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
required
|
||||
value={formData().name}
|
||||
onInput={handleInputChange("name")}
|
||||
placeholder="Jeff Bezos"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-component="form-group">
|
||||
<label for="role">Role</label>
|
||||
<input
|
||||
id="role"
|
||||
type="text"
|
||||
required
|
||||
value={formData().role}
|
||||
onInput={handleInputChange("role")}
|
||||
placeholder="Executive Chairman"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-component="form-group">
|
||||
<label for="email">Company email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData().email}
|
||||
onInput={handleInputChange("email")}
|
||||
placeholder="jeff@amazon.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-component="form-group">
|
||||
<label for="message">What problem are you trying to solve?</label>
|
||||
<textarea
|
||||
id="message"
|
||||
required
|
||||
rows={5}
|
||||
value={formData().message}
|
||||
onInput={handleInputChange("message")}
|
||||
placeholder="We need help with..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isSubmitting()} data-component="submit-button">
|
||||
{isSubmitting() ? "Sending..." : "Send"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{showSuccess() && (
|
||||
<div data-component="success-message">
|
||||
Message sent, we'll be in touch soon.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section data-component="faq">
|
||||
<div data-slot="section-title">
|
||||
<h3>FAQ</h3>
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<Faq question="What is OpenCode Enterprise?">
|
||||
OpenCode Enterprise is for organizations that want to ensure that their code and
|
||||
data never leaves their infrastructure. It can do this by using a centralized
|
||||
config that integrates with your SSO and internal AI gateway.
|
||||
</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<Faq question="How do I get started with OpenCode Enterprise?">
|
||||
Simply start with an internal trial with your team. OpenCode by default does not
|
||||
store your code or context data, making it easy to get started. Then contact us to
|
||||
discuss pricing and implementation options.
|
||||
</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<Faq question="How does enterprise pricing work?">
|
||||
We offer per-seat enterprise pricing. If you have your own LLM gateway, we do not
|
||||
charge for tokens used. For further details, contact us for a custom quote based
|
||||
on your organization's needs.
|
||||
</Faq>
|
||||
</li>
|
||||
<li>
|
||||
<Faq question="Is my data secure with OpenCode Enterprise?">
|
||||
Yes. OpenCode does not store your code or context data. All processing happens
|
||||
locally or through direct API calls to your AI provider. With central config and
|
||||
SSO integration, your data remains secure within your organization's
|
||||
infrastructure.
|
||||
</Faq>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
<Legal />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -56,7 +56,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@supports (background: -webkit-named-image(i)) {
|
||||
[data-page="opencode"] {
|
||||
border-top: 1px solid var(--color-border-weak);
|
||||
@@ -118,7 +117,6 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
[data-component="growth-stat"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -149,14 +147,12 @@ body {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
[data-component="stat-illustration"] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
|
||||
svg {
|
||||
margin: 0;
|
||||
@@ -164,10 +160,9 @@ body {
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
@@ -184,7 +179,6 @@ body {
|
||||
-moz-text-fill-color: var(--color-text-strong) !important;
|
||||
}
|
||||
|
||||
|
||||
[data-component="container"] {
|
||||
max-width: 67.5rem;
|
||||
margin: 0 auto;
|
||||
@@ -328,10 +322,10 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 100%;
|
||||
padding: calc(var(--vertical-padding)*2) var(--padding);
|
||||
padding: calc(var(--vertical-padding) * 2) var(--padding);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
padding: var(--vertical-padding) var(--padding)
|
||||
padding: var(--vertical-padding) var(--padding);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -352,7 +346,6 @@ body {
|
||||
@media (max-width: 550px) {
|
||||
width: calc(100vw - 48px);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
[data-component="tabs"] {
|
||||
@@ -368,7 +361,7 @@ body {
|
||||
padding: 0 20px;
|
||||
|
||||
@media (max-width: 60rem) {
|
||||
gap: 20px;
|
||||
gap: 32px;
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -443,7 +436,6 @@ body {
|
||||
@media (max-width: 35rem) {
|
||||
width: calc(100% - 40px) !important;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
[data-slot="highlight"] {
|
||||
@@ -496,7 +488,6 @@ body {
|
||||
@media (max-width: 60rem) {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
p {
|
||||
@@ -579,7 +570,6 @@ body {
|
||||
margin-bottom: 24px;
|
||||
max-width: 100%;
|
||||
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -827,16 +817,12 @@ body {
|
||||
outline: none;
|
||||
border: none;
|
||||
color: var(--color-text-strong);
|
||||
|
||||
border: 1px solid var(--color-background-strong); /* Tailwind blue-600 as example */
|
||||
|
||||
/* Tailwind-style ring */
|
||||
border: 1px solid var(--color-background-strong);
|
||||
box-shadow: 0 0 0 3px var(--color-background-interactive);
|
||||
/* mimics "ring-2 ring-blue-600/50" */
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--color-background-interactive)
|
||||
border: 1px solid var(--color-background-interactive);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -896,7 +882,6 @@ body {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
|
||||
[data-slot="faq-icon-plus"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-weak);
|
||||
@@ -1025,7 +1010,6 @@ body {
|
||||
}
|
||||
|
||||
[data-component="copy-status"] {
|
||||
|
||||
[data-slot="copy"] {
|
||||
display: block;
|
||||
width: var(--space-4);
|
||||
@@ -1058,12 +1042,10 @@ body {
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
}
|
||||
|
||||
|
||||
[data-slot="cell"] {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
padding: 2rem 0;
|
||||
@@ -1076,7 +1058,6 @@ body {
|
||||
@media (max-width: 40rem) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1115,10 +1096,18 @@ body {
|
||||
[data-component="legal"] {
|
||||
color: var(--color-text-weak);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
justify-content: center;
|
||||
|
||||
a {
|
||||
color: var(--color-text-weak);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,8 +219,8 @@ export default function Home() {
|
||||
<div>
|
||||
<span>[*]</span>
|
||||
<p>
|
||||
With over <strong>26,000</strong> GitHub stars, <strong>188</strong> contributors, and almost{" "}
|
||||
<strong>3,000</strong> commits, OpenCode is used and trusted by over <strong>200,000</strong>{" "}
|
||||
With over <strong>29,000</strong> GitHub stars, <strong>230</strong> contributors, and almost{" "}
|
||||
<strong>3,500</strong> commits, OpenCode is used and trusted by over <strong>250,000</strong>{" "}
|
||||
developers every month.
|
||||
</p>
|
||||
</div>
|
||||
@@ -274,7 +274,7 @@ export default function Home() {
|
||||
</svg>
|
||||
</div>
|
||||
<span>
|
||||
<figure>Fig 1.</figure> <strong>26K</strong> GitHub Stars
|
||||
<figure>Fig 1.</figure> <strong>29K</strong> GitHub Stars
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -577,7 +577,7 @@ export default function Home() {
|
||||
</svg>
|
||||
</div>
|
||||
<span>
|
||||
<figure>Fig 2.</figure> <strong>188</strong> Contributors
|
||||
<figure>Fig 2.</figure> <strong>230</strong> Contributors
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -619,7 +619,7 @@ export default function Home() {
|
||||
</svg>
|
||||
</div>
|
||||
<span>
|
||||
<figure>Fig 3.</figure> <strong>200K</strong> Monthly Devs
|
||||
<figure>Fig 3.</figure> <strong>250K</strong> Monthly Devs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function formatDateForTable(date: Date) {
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
}
|
||||
return date.toLocaleDateString("en-GB", options).replace(",", ",")
|
||||
return date.toLocaleDateString(undefined, options).replace(",", ",")
|
||||
}
|
||||
|
||||
export function formatDateUTC(date: Date) {
|
||||
|
||||
@@ -60,7 +60,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[data-page="zen"] {
|
||||
background: var(--color-background);
|
||||
--padding: 5rem;
|
||||
@@ -112,9 +111,6 @@ body {
|
||||
-moz-text-fill-color: var(--color-text-strong) !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
[data-component="container"] {
|
||||
max-width: 67.5rem;
|
||||
margin: 0 auto;
|
||||
@@ -179,8 +175,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
[data-component="nav-mobile-toggle"] {
|
||||
border: none;
|
||||
background: none;
|
||||
@@ -258,14 +252,14 @@ body {
|
||||
[data-component="hero"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: calc(var(--vertical-padding)*2) var(--padding);
|
||||
padding: calc(var(--vertical-padding) * 2) var(--padding);
|
||||
|
||||
[data-slot="zen logo dark"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
padding: var(--vertical-padding) var(--padding)
|
||||
padding: var(--vertical-padding) var(--padding);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -295,8 +289,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
p {
|
||||
color: var(--color-text);
|
||||
margin-bottom: 24px;
|
||||
@@ -778,12 +770,10 @@ body {
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
}
|
||||
|
||||
|
||||
[data-slot="cell"] {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
padding: 2rem 0;
|
||||
@@ -796,7 +786,6 @@ body {
|
||||
@media (max-width: 40rem) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,10 +824,18 @@ body {
|
||||
[data-component="legal"] {
|
||||
color: var(--color-text-weak);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
justify-content: center;
|
||||
|
||||
a {
|
||||
color: var(--color-text-weak);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export class AuthError extends Error {}
|
||||
export class CreditsError extends Error {}
|
||||
export class MonthlyLimitError extends Error {}
|
||||
export class UserLimitError extends Error {}
|
||||
export class ModelError extends Error {}
|
||||
@@ -0,0 +1 @@
|
||||
export type Format = "anthropic" | "openai" | "oa-compat"
|
||||
@@ -1,67 +1,45 @@
|
||||
import { z } from "zod"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import path from "node:path"
|
||||
import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||
import { BillingTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { Billing } from "../../../../core/src/billing"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
|
||||
import { logger } from "./logger"
|
||||
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError } from "./error"
|
||||
import {
|
||||
createBodyConverter,
|
||||
createStreamPartConverter,
|
||||
createResponseConverter,
|
||||
} from "./provider/provider"
|
||||
import { Format } from "./format"
|
||||
import { anthropicHelper } from "./provider/anthropic"
|
||||
import { openaiHelper } from "./provider/openai"
|
||||
import { oaCompatHelper } from "./provider/openai-compatible"
|
||||
|
||||
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
||||
type Model = ZenData["models"][string]
|
||||
|
||||
export async function handler(
|
||||
input: APIEvent,
|
||||
opts: {
|
||||
modifyBody?: (body: any) => any
|
||||
setAuthHeader: (headers: Headers, apiKey: string) => void
|
||||
format: Format
|
||||
parseApiKey: (headers: Headers) => string | undefined
|
||||
onStreamPart: (chunk: string) => void
|
||||
getStreamUsage: () => any
|
||||
normalizeUsage: (body: any) => {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWrite5mTokens?: number
|
||||
cacheWrite1hTokens?: number
|
||||
}
|
||||
},
|
||||
) {
|
||||
class AuthError extends Error {}
|
||||
class CreditsError extends Error {}
|
||||
class MonthlyLimitError extends Error {}
|
||||
class UserLimitError extends Error {}
|
||||
class ModelError extends Error {}
|
||||
|
||||
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
||||
type Model = ZenData["models"][string]
|
||||
|
||||
const FREE_WORKSPACES = [
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
||||
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
|
||||
]
|
||||
|
||||
const logger = {
|
||||
metric: (values: Record<string, any>) => {
|
||||
console.log(`_metric:${JSON.stringify(values)}`)
|
||||
},
|
||||
log: console.log,
|
||||
debug: (message: string) => {
|
||||
if (Resource.App.stage === "production") return
|
||||
console.debug(message)
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input.request.url)
|
||||
const body = await input.request.json()
|
||||
logger.debug(JSON.stringify(body))
|
||||
logger.metric({
|
||||
is_tream: !!body.stream,
|
||||
session: input.request.headers.get("x-opencode-session"),
|
||||
@@ -69,7 +47,11 @@ export async function handler(
|
||||
})
|
||||
const zenData = ZenData.list()
|
||||
const modelInfo = validateModel(zenData, body.model)
|
||||
const providerInfo = selectProvider(zenData, modelInfo)
|
||||
const providerInfo = selectProvider(
|
||||
zenData,
|
||||
modelInfo,
|
||||
input.request.headers.get("x-real-ip") ?? "",
|
||||
)
|
||||
const authInfo = await authenticate(modelInfo, providerInfo)
|
||||
validateBilling(modelInfo, authInfo)
|
||||
validateModelSettings(authInfo)
|
||||
@@ -78,22 +60,28 @@ export async function handler(
|
||||
|
||||
// Request to model provider
|
||||
const startTimestamp = Date.now()
|
||||
const res = await fetch(path.posix.join(providerInfo.api, url.pathname.replace(/^\/zen\/v1/, "") + url.search), {
|
||||
const reqUrl = providerInfo.modifyUrl(providerInfo.api)
|
||||
const reqBody = JSON.stringify(
|
||||
providerInfo.modifyBody({
|
||||
...createBodyConverter(opts.format, providerInfo.format)(body),
|
||||
model: providerInfo.model,
|
||||
}),
|
||||
)
|
||||
logger.debug("REQUEST URL: " + reqUrl)
|
||||
logger.debug("REQUEST: " + reqBody)
|
||||
const res = await fetch(reqUrl, {
|
||||
method: "POST",
|
||||
headers: (() => {
|
||||
const headers = input.request.headers
|
||||
headers.delete("host")
|
||||
headers.delete("content-length")
|
||||
opts.setAuthHeader(headers, providerInfo.apiKey)
|
||||
providerInfo.modifyHeaders(headers, body, providerInfo.apiKey)
|
||||
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
|
||||
headers.set(k, headers.get(v)!)
|
||||
})
|
||||
return headers
|
||||
})(),
|
||||
body: JSON.stringify({
|
||||
...(opts.modifyBody?.(body) ?? body),
|
||||
model: providerInfo.model,
|
||||
}),
|
||||
body: reqBody,
|
||||
})
|
||||
|
||||
// Scrub response headers
|
||||
@@ -104,14 +92,19 @@ export async function handler(
|
||||
resHeaders.set(k, v)
|
||||
}
|
||||
}
|
||||
logger.debug("STATUS: " + res.status + " " + res.statusText)
|
||||
if (res.status === 400 || res.status === 503) {
|
||||
logger.debug("RESPONSE: " + (await res.text()))
|
||||
}
|
||||
|
||||
// Handle non-streaming response
|
||||
if (!body.stream) {
|
||||
const responseConverter = createResponseConverter(providerInfo.format, opts.format)
|
||||
const json = await res.json()
|
||||
const body = JSON.stringify(json)
|
||||
const body = JSON.stringify(responseConverter(json))
|
||||
logger.metric({ response_length: body.length })
|
||||
logger.debug(body)
|
||||
await trackUsage(authInfo, modelInfo, providerInfo.id, json.usage)
|
||||
logger.debug("RESPONSE: " + body)
|
||||
await trackUsage(authInfo, modelInfo, providerInfo, json.usage)
|
||||
await reload(authInfo)
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
@@ -121,10 +114,13 @@ export async function handler(
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
const streamConverter = createStreamPartConverter(providerInfo.format, opts.format)
|
||||
const usageParser = providerInfo.createUsageParser()
|
||||
const stream = new ReadableStream({
|
||||
start(c) {
|
||||
const reader = res.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const encoder = new TextEncoder()
|
||||
let buffer = ""
|
||||
let responseLength = 0
|
||||
|
||||
@@ -136,9 +132,9 @@ export async function handler(
|
||||
response_length: responseLength,
|
||||
"timestamp.last_byte": Date.now(),
|
||||
})
|
||||
const usage = opts.getStreamUsage()
|
||||
const usage = usageParser.retrieve()
|
||||
if (usage) {
|
||||
await trackUsage(authInfo, modelInfo, providerInfo.id, usage)
|
||||
await trackUsage(authInfo, modelInfo, providerInfo, usage)
|
||||
await reload(authInfo)
|
||||
}
|
||||
c.close()
|
||||
@@ -158,12 +154,21 @@ export async function handler(
|
||||
const parts = buffer.split("\n\n")
|
||||
buffer = parts.pop() ?? ""
|
||||
|
||||
for (const part of parts) {
|
||||
logger.debug(part)
|
||||
opts.onStreamPart(part.trim())
|
||||
for (let part of parts) {
|
||||
logger.debug("PART: " + part)
|
||||
|
||||
part = part.trim()
|
||||
usageParser.parse(part)
|
||||
|
||||
if (providerInfo.format !== opts.format) {
|
||||
part = streamConverter(part)
|
||||
c.enqueue(encoder.encode(part + "\n\n"))
|
||||
}
|
||||
}
|
||||
|
||||
c.enqueue(value)
|
||||
if (providerInfo.format === opts.format) {
|
||||
c.enqueue(value)
|
||||
}
|
||||
|
||||
return pump()
|
||||
}) || Promise.resolve()
|
||||
@@ -225,17 +230,33 @@ export async function handler(
|
||||
return { id: modelId, ...modelData }
|
||||
}
|
||||
|
||||
function selectProvider(zenData: ZenData, model: Awaited<ReturnType<typeof validateModel>>) {
|
||||
function selectProvider(
|
||||
zenData: ZenData,
|
||||
model: Awaited<ReturnType<typeof validateModel>>,
|
||||
ip: string,
|
||||
) {
|
||||
const providers = model.providers
|
||||
.filter((provider) => !provider.disabled)
|
||||
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
|
||||
const provider = providers[Math.floor(Math.random() * providers.length)]
|
||||
|
||||
// Use last character of IP address to select a provider
|
||||
const lastChar = ip.charCodeAt(ip.length - 1) || 0
|
||||
const index = lastChar % providers.length
|
||||
const provider = providers[index]
|
||||
|
||||
if (!(provider.id in zenData.providers)) {
|
||||
throw new ModelError(`Provider ${provider.id} not supported`)
|
||||
}
|
||||
|
||||
return { ...provider, ...zenData.providers[provider.id] }
|
||||
return {
|
||||
...provider,
|
||||
...zenData.providers[provider.id],
|
||||
...(provider.id === "anthropic"
|
||||
? anthropicHelper
|
||||
: provider.id === "openai"
|
||||
? openaiHelper
|
||||
: oaCompatHelper),
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
@@ -274,11 +295,20 @@ export async function handler(
|
||||
.from(KeyTable)
|
||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
|
||||
.innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
|
||||
.innerJoin(UserTable, and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)))
|
||||
.leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, model.id)))
|
||||
.innerJoin(
|
||||
UserTable,
|
||||
and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)),
|
||||
)
|
||||
.leftJoin(
|
||||
ModelTable,
|
||||
and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, model.id)),
|
||||
)
|
||||
.leftJoin(
|
||||
ProviderTable,
|
||||
and(eq(ProviderTable.workspaceID, KeyTable.workspaceID), eq(ProviderTable.provider, providerInfo.id)),
|
||||
and(
|
||||
eq(ProviderTable.workspaceID, KeyTable.workspaceID),
|
||||
eq(ProviderTable.provider, providerInfo.id),
|
||||
),
|
||||
)
|
||||
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
||||
.then((rows) => rows[0]),
|
||||
@@ -302,12 +332,20 @@ export async function handler(
|
||||
}
|
||||
|
||||
function validateBilling(model: Model, authInfo: Awaited<ReturnType<typeof authenticate>>) {
|
||||
if (!authInfo || authInfo.isFree) return
|
||||
if (!authInfo) return
|
||||
if (authInfo.provider?.credentials) return
|
||||
if (authInfo.isFree) return
|
||||
if (model.allowAnonymous) return
|
||||
|
||||
const billing = authInfo.billing
|
||||
if (!billing.paymentMethodID) throw new CreditsError("No payment method")
|
||||
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
|
||||
if (!billing.paymentMethodID)
|
||||
throw new CreditsError(
|
||||
`No payment method. Add a payment method here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
||||
)
|
||||
if (billing.balance <= 0)
|
||||
throw new CreditsError(
|
||||
`Insufficient balance. Manage your billing here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
||||
)
|
||||
|
||||
const now = new Date()
|
||||
const currentYear = now.getUTCFullYear()
|
||||
@@ -322,7 +360,7 @@ export async function handler(
|
||||
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
|
||||
if (currentYear === dateYear && currentMonth === dateMonth)
|
||||
throw new MonthlyLimitError(
|
||||
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}.`,
|
||||
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -335,7 +373,9 @@ export async function handler(
|
||||
const dateYear = authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear()
|
||||
const dateMonth = authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
|
||||
if (currentYear === dateYear && currentMonth === dateMonth)
|
||||
throw new UserLimitError(`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}.`)
|
||||
throw new UserLimitError(
|
||||
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,15 +396,22 @@ export async function handler(
|
||||
async function trackUsage(
|
||||
authInfo: Awaited<ReturnType<typeof authenticate>>,
|
||||
modelInfo: ReturnType<typeof validateModel>,
|
||||
providerId: string,
|
||||
providerInfo: Awaited<ReturnType<typeof selectProvider>>,
|
||||
usage: any,
|
||||
) {
|
||||
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
||||
opts.normalizeUsage(usage)
|
||||
const {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens,
|
||||
cacheWrite1hTokens,
|
||||
} = providerInfo.normalizeUsage(usage)
|
||||
|
||||
const modelCost =
|
||||
modelInfo.cost200K &&
|
||||
inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) > 200_000
|
||||
inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) >
|
||||
200_000
|
||||
? modelInfo.cost200K
|
||||
: modelInfo.cost
|
||||
|
||||
@@ -415,13 +462,14 @@ export async function handler(
|
||||
|
||||
if (!authInfo) return
|
||||
|
||||
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
|
||||
const cost =
|
||||
authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx.insert(UsageTable).values({
|
||||
workspaceID: authInfo.workspaceID,
|
||||
id: Identifier.create("usage"),
|
||||
model: modelInfo.id,
|
||||
provider: providerId,
|
||||
provider: providerInfo.id,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
@@ -455,7 +503,9 @@ export async function handler(
|
||||
`,
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)))
|
||||
.where(
|
||||
and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)),
|
||||
)
|
||||
})
|
||||
|
||||
await Database.use((tx) =>
|
||||
@@ -482,7 +532,10 @@ export async function handler(
|
||||
eq(BillingTable.workspaceID, authInfo.workspaceID),
|
||||
eq(BillingTable.reload, true),
|
||||
lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
|
||||
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
|
||||
or(
|
||||
isNull(BillingTable.timeReloadLockedTill),
|
||||
lt(BillingTable.timeReloadLockedTill, sql`now()`),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
export const logger = {
|
||||
metric: (values: Record<string, any>) => {
|
||||
console.log(`_metric:${JSON.stringify(values)}`)
|
||||
},
|
||||
log: console.log,
|
||||
debug: (message: string) => {
|
||||
if (Resource.App.stage === "production") return
|
||||
console.debug(message)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
import { ProviderHelper, CommonRequest, CommonResponse, CommonChunk } from "./provider"
|
||||
|
||||
type Usage = {
|
||||
cache_creation?: {
|
||||
ephemeral_5m_input_tokens?: number
|
||||
ephemeral_1h_input_tokens?: number
|
||||
}
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
server_tool_use?: {
|
||||
web_search_requests?: number
|
||||
}
|
||||
}
|
||||
|
||||
export const anthropicHelper = {
|
||||
format: "anthropic",
|
||||
modifyUrl: (providerApi: string) => providerApi + "/messages",
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||
headers.set("x-api-key", apiKey)
|
||||
headers.set("anthropic-version", headers.get("anthropic-version") ?? "2023-06-01")
|
||||
if (body.model.startsWith("claude-sonnet-")) {
|
||||
headers.set("anthropic-beta", "context-1m-2025-08-07")
|
||||
}
|
||||
},
|
||||
modifyBody: (body: Record<string, any>) => {
|
||||
return {
|
||||
...body,
|
||||
service_tier: "standard_only",
|
||||
}
|
||||
},
|
||||
createUsageParser: () => {
|
||||
let usage: Usage
|
||||
|
||||
return {
|
||||
parse: (chunk: string) => {
|
||||
const data = chunk.split("\n")[1]
|
||||
if (!data.startsWith("data: ")) return
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(data.slice(6))
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
const usageUpdate = json.usage ?? json.message?.usage
|
||||
if (!usageUpdate) return
|
||||
usage = {
|
||||
...usage,
|
||||
...usageUpdate,
|
||||
cache_creation: {
|
||||
...usage?.cache_creation,
|
||||
...usageUpdate.cache_creation,
|
||||
},
|
||||
server_tool_use: {
|
||||
...usage?.server_tool_use,
|
||||
...usageUpdate.server_tool_use,
|
||||
},
|
||||
}
|
||||
},
|
||||
retrieve: () => usage,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => ({
|
||||
inputTokens: usage.input_tokens ?? 0,
|
||||
outputTokens: usage.output_tokens ?? 0,
|
||||
reasoningTokens: undefined,
|
||||
cacheReadTokens: usage.cache_read_input_tokens ?? undefined,
|
||||
cacheWrite5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens ?? undefined,
|
||||
cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? undefined,
|
||||
}),
|
||||
} satisfies ProviderHelper
|
||||
|
||||
export function fromAnthropicRequest(body: any): CommonRequest {
|
||||
if (!body || typeof body !== "object") return body
|
||||
|
||||
const msgs: any[] = []
|
||||
|
||||
const sys = Array.isArray(body.system) ? body.system : undefined
|
||||
if (sys && sys.length > 0) {
|
||||
for (const s of sys) {
|
||||
if (!s) continue
|
||||
if ((s as any).type !== "text") continue
|
||||
if (typeof (s as any).text !== "string") continue
|
||||
if ((s as any).text.length === 0) continue
|
||||
msgs.push({ role: "system", content: (s as any).text })
|
||||
}
|
||||
}
|
||||
|
||||
const toImg = (src: any) => {
|
||||
if (!src || typeof src !== "object") return undefined
|
||||
if ((src as any).type === "url" && typeof (src as any).url === "string")
|
||||
return { type: "image_url", image_url: { url: (src as any).url } }
|
||||
if (
|
||||
(src as any).type === "base64" &&
|
||||
typeof (src as any).media_type === "string" &&
|
||||
typeof (src as any).data === "string"
|
||||
)
|
||||
return { type: "image_url", image_url: { url: `data:${(src as any).media_type};base64,${(src as any).data}` } }
|
||||
return undefined
|
||||
}
|
||||
|
||||
const inMsgs = Array.isArray(body.messages) ? body.messages : []
|
||||
for (const m of inMsgs) {
|
||||
if (!m || !(m as any).role) continue
|
||||
|
||||
if ((m as any).role === "user") {
|
||||
const partsIn = Array.isArray((m as any).content) ? (m as any).content : []
|
||||
const partsOut: any[] = []
|
||||
for (const p of partsIn) {
|
||||
if (!p || !(p as any).type) continue
|
||||
if ((p as any).type === "text" && typeof (p as any).text === "string")
|
||||
partsOut.push({ type: "text", text: (p as any).text })
|
||||
if ((p as any).type === "image") {
|
||||
const ip = toImg((p as any).source)
|
||||
if (ip) partsOut.push(ip)
|
||||
}
|
||||
if ((p as any).type === "tool_result") {
|
||||
const id = (p as any).tool_use_id
|
||||
const content =
|
||||
typeof (p as any).content === "string" ? (p as any).content : JSON.stringify((p as any).content)
|
||||
msgs.push({ role: "tool", tool_call_id: id, content })
|
||||
}
|
||||
}
|
||||
if (partsOut.length > 0) {
|
||||
if (partsOut.length === 1 && partsOut[0].type === "text") msgs.push({ role: "user", content: partsOut[0].text })
|
||||
else msgs.push({ role: "user", content: partsOut })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "assistant") {
|
||||
const partsIn = Array.isArray((m as any).content) ? (m as any).content : []
|
||||
const texts: string[] = []
|
||||
const tcs: any[] = []
|
||||
for (const p of partsIn) {
|
||||
if (!p || !(p as any).type) continue
|
||||
if ((p as any).type === "text" && typeof (p as any).text === "string") texts.push((p as any).text)
|
||||
if ((p as any).type === "tool_use") {
|
||||
const name = (p as any).name
|
||||
const id = (p as any).id
|
||||
const inp = (p as any).input
|
||||
const input = (() => {
|
||||
if (typeof inp === "string") return inp
|
||||
try {
|
||||
return JSON.stringify(inp ?? {})
|
||||
} catch {
|
||||
return String(inp ?? "")
|
||||
}
|
||||
})()
|
||||
tcs.push({ id, type: "function", function: { name, arguments: input } })
|
||||
}
|
||||
}
|
||||
const out: any = { role: "assistant", content: texts.join("") }
|
||||
if (tcs.length > 0) out.tool_calls = tcs
|
||||
msgs.push(out)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const tools = Array.isArray(body.tools)
|
||||
? body.tools
|
||||
.filter((t: any) => t && typeof t === "object" && "input_schema" in t)
|
||||
.map((t: any) => ({
|
||||
type: "function",
|
||||
function: { name: (t as any).name, description: (t as any).description, parameters: (t as any).input_schema },
|
||||
}))
|
||||
: undefined
|
||||
|
||||
const tcin = body.tool_choice
|
||||
const tc = (() => {
|
||||
if (!tcin) return undefined
|
||||
if ((tcin as any).type === "auto") return "auto"
|
||||
if ((tcin as any).type === "any") return "required"
|
||||
if ((tcin as any).type === "tool" && typeof (tcin as any).name === "string")
|
||||
return { type: "function" as const, function: { name: (tcin as any).name } }
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const stop = (() => {
|
||||
const v = body.stop_sequences
|
||||
if (!v) return undefined
|
||||
if (Array.isArray(v)) return v.length === 1 ? v[0] : v
|
||||
if (typeof v === "string") return v
|
||||
return undefined
|
||||
})()
|
||||
|
||||
return {
|
||||
model: body.model,
|
||||
max_tokens: body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
stop,
|
||||
messages: msgs,
|
||||
stream: !!body.stream,
|
||||
tools,
|
||||
tool_choice: tc,
|
||||
}
|
||||
}
|
||||
|
||||
export function toAnthropicRequest(body: CommonRequest) {
|
||||
if (!body || typeof body !== "object") return body
|
||||
|
||||
const sysIn = Array.isArray(body.messages) ? body.messages.filter((m: any) => m && m.role === "system") : []
|
||||
let ccCount = 0
|
||||
const cc = () => {
|
||||
ccCount++
|
||||
return ccCount <= 4 ? { cache_control: { type: "ephemeral" } } : {}
|
||||
}
|
||||
const system = sysIn
|
||||
.filter((m: any) => typeof m.content === "string" && m.content.length > 0)
|
||||
.map((m: any) => ({ type: "text", text: m.content, ...cc() }))
|
||||
|
||||
const msgsIn = Array.isArray(body.messages) ? body.messages : []
|
||||
const msgsOut: any[] = []
|
||||
|
||||
const toSrc = (p: any) => {
|
||||
if (!p || typeof p !== "object") return undefined
|
||||
if ((p as any).type === "image_url" && (p as any).image_url) {
|
||||
const u = (p as any).image_url.url ?? (p as any).image_url
|
||||
if (typeof u === "string" && u.startsWith("data:")) {
|
||||
const m = u.match(/^data:([^;]+);base64,(.*)$/)
|
||||
if (m) return { type: "base64", media_type: m[1], data: m[2] }
|
||||
}
|
||||
if (typeof u === "string") return { type: "url", url: u }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const m of msgsIn) {
|
||||
if (!m || !(m as any).role) continue
|
||||
|
||||
if ((m as any).role === "user") {
|
||||
if (typeof (m as any).content === "string") {
|
||||
msgsOut.push({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: (m as any).content, ...cc() }],
|
||||
})
|
||||
} else if (Array.isArray((m as any).content)) {
|
||||
const parts: any[] = []
|
||||
for (const p of (m as any).content) {
|
||||
if (!p || !(p as any).type) continue
|
||||
if ((p as any).type === "text" && typeof (p as any).text === "string")
|
||||
parts.push({ type: "text", text: (p as any).text, ...cc() })
|
||||
if ((p as any).type === "image_url") {
|
||||
const s = toSrc(p)
|
||||
if (s) parts.push({ type: "image", source: s, ...cc() })
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) msgsOut.push({ role: "user", content: parts })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "assistant") {
|
||||
const out: any = { role: "assistant", content: [] as any[] }
|
||||
if (typeof (m as any).content === "string" && (m as any).content.length > 0) {
|
||||
;(out.content as any[]).push({ type: "text", text: (m as any).content, ...cc() })
|
||||
}
|
||||
if (Array.isArray((m as any).tool_calls)) {
|
||||
for (const tc of (m as any).tool_calls) {
|
||||
if ((tc as any).type === "function" && (tc as any).function) {
|
||||
let input: any
|
||||
const a = (tc as any).function.arguments
|
||||
if (typeof a === "string") {
|
||||
try {
|
||||
input = JSON.parse(a)
|
||||
} catch {
|
||||
input = a
|
||||
}
|
||||
} else input = a
|
||||
const id = (tc as any).id || `toolu_${Math.random().toString(36).slice(2)}`
|
||||
;(out.content as any[]).push({
|
||||
type: "tool_use",
|
||||
id,
|
||||
name: (tc as any).function.name,
|
||||
input,
|
||||
...cc(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((out.content as any[]).length > 0) msgsOut.push(out)
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "tool") {
|
||||
msgsOut.push({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: (m as any).tool_call_id,
|
||||
content: (m as any).content,
|
||||
...cc(),
|
||||
},
|
||||
],
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const tools = Array.isArray(body.tools)
|
||||
? body.tools
|
||||
.filter((t: any) => t && typeof t === "object" && (t as any).type === "function")
|
||||
.map((t: any) => ({
|
||||
name: (t as any).function.name,
|
||||
description: (t as any).function.description,
|
||||
input_schema: (t as any).function.parameters,
|
||||
...cc(),
|
||||
}))
|
||||
: undefined
|
||||
|
||||
const tcIn = body.tool_choice
|
||||
const tool_choice = (() => {
|
||||
if (!tcIn) return undefined
|
||||
if (tcIn === "auto") return { type: "auto" }
|
||||
if (tcIn === "required") return { type: "any" }
|
||||
if ((tcIn as any).type === "function" && (tcIn as any).function?.name)
|
||||
return { type: "tool", name: (tcIn as any).function.name }
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const stop_sequences = (() => {
|
||||
const v = body.stop
|
||||
if (!v) return undefined
|
||||
if (Array.isArray(v)) return v
|
||||
if (typeof v === "string") return [v]
|
||||
return undefined
|
||||
})()
|
||||
|
||||
return {
|
||||
max_tokens: body.max_tokens ?? 32_000,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
system: system.length > 0 ? system : undefined,
|
||||
messages: msgsOut,
|
||||
stream: !!body.stream,
|
||||
tools,
|
||||
tool_choice,
|
||||
stop_sequences,
|
||||
}
|
||||
}
|
||||
|
||||
export function fromAnthropicResponse(resp: any): CommonResponse {
|
||||
if (!resp || typeof resp !== "object") return resp
|
||||
|
||||
if (Array.isArray((resp as any).choices)) return resp
|
||||
|
||||
const isAnthropic = typeof (resp as any).type === "string" && (resp as any).type === "message"
|
||||
if (!isAnthropic) return resp
|
||||
|
||||
const idIn = (resp as any).id
|
||||
const id =
|
||||
typeof idIn === "string" ? idIn.replace(/^msg_/, "chatcmpl_") : `chatcmpl_${Math.random().toString(36).slice(2)}`
|
||||
const model = (resp as any).model
|
||||
|
||||
const blocks: any[] = Array.isArray((resp as any).content) ? (resp as any).content : []
|
||||
const text = blocks
|
||||
.filter((b) => b && b.type === "text" && typeof (b as any).text === "string")
|
||||
.map((b: any) => b.text)
|
||||
.join("")
|
||||
const tcs = blocks
|
||||
.filter((b) => b && b.type === "tool_use")
|
||||
.map((b: any) => {
|
||||
const name = (b as any).name
|
||||
const args = (() => {
|
||||
const inp = (b as any).input
|
||||
if (typeof inp === "string") return inp
|
||||
try {
|
||||
return JSON.stringify(inp ?? {})
|
||||
} catch {
|
||||
return String(inp ?? "")
|
||||
}
|
||||
})()
|
||||
const tid =
|
||||
typeof (b as any).id === "string" && (b as any).id.length > 0
|
||||
? (b as any).id
|
||||
: `toolu_${Math.random().toString(36).slice(2)}`
|
||||
return { id: tid, type: "function" as const, function: { name, arguments: args } }
|
||||
})
|
||||
|
||||
const finish = (r: string | null) => {
|
||||
if (r === "end_turn") return "stop"
|
||||
if (r === "tool_use") return "tool_calls"
|
||||
if (r === "max_tokens") return "length"
|
||||
if (r === "content_filter") return "content_filter"
|
||||
return null
|
||||
}
|
||||
|
||||
const u = (resp as any).usage
|
||||
const usage = (() => {
|
||||
if (!u) return undefined as any
|
||||
const pt = typeof (u as any).input_tokens === "number" ? (u as any).input_tokens : undefined
|
||||
const ct = typeof (u as any).output_tokens === "number" ? (u as any).output_tokens : undefined
|
||||
const total = pt != null && ct != null ? pt + ct : undefined
|
||||
const cached =
|
||||
typeof (u as any).cache_read_input_tokens === "number" ? (u as any).cache_read_input_tokens : undefined
|
||||
const details = cached != null ? { cached_tokens: cached } : undefined
|
||||
return {
|
||||
prompt_tokens: pt,
|
||||
completion_tokens: ct,
|
||||
total_tokens: total,
|
||||
...(details ? { prompt_tokens_details: details } : {}),
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
...(text && text.length > 0 ? { content: text } : {}),
|
||||
...(tcs.length > 0 ? { tool_calls: tcs } : {}),
|
||||
},
|
||||
finish_reason: finish((resp as any).stop_reason ?? null),
|
||||
},
|
||||
],
|
||||
...(usage ? { usage } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function toAnthropicResponse(resp: CommonResponse) {
|
||||
if (!resp || typeof resp !== "object") return resp
|
||||
|
||||
if (!Array.isArray((resp as any).choices)) return resp
|
||||
|
||||
const choice = (resp as any).choices[0]
|
||||
if (!choice) return resp
|
||||
|
||||
const message = choice.message
|
||||
if (!message) return resp
|
||||
|
||||
const content: any[] = []
|
||||
|
||||
if (typeof message.content === "string" && message.content.length > 0)
|
||||
content.push({ type: "text", text: message.content })
|
||||
|
||||
if (Array.isArray(message.tool_calls)) {
|
||||
for (const tc of message.tool_calls) {
|
||||
if ((tc as any).type === "function" && (tc as any).function) {
|
||||
let input: any
|
||||
try {
|
||||
input = JSON.parse((tc as any).function.arguments)
|
||||
} catch {
|
||||
input = (tc as any).function.arguments
|
||||
}
|
||||
content.push({ type: "tool_use", id: (tc as any).id, name: (tc as any).function.name, input })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stop_reason = (() => {
|
||||
const r = choice.finish_reason
|
||||
if (r === "stop") return "end_turn"
|
||||
if (r === "tool_calls") return "tool_use"
|
||||
if (r === "length") return "max_tokens"
|
||||
if (r === "content_filter") return "content_filter"
|
||||
return null
|
||||
})()
|
||||
|
||||
const usage = (() => {
|
||||
const u = (resp as any).usage
|
||||
if (!u) return undefined
|
||||
return {
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
cache_read_input_tokens: u.prompt_tokens_details?.cached_tokens,
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id: (resp as any).id,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: content.length > 0 ? content : [{ type: "text", text: "" }],
|
||||
model: (resp as any).model,
|
||||
stop_reason,
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
export function fromAnthropicChunk(chunk: string): CommonChunk | string {
|
||||
// Anthropic sends two lines per part: "event: <type>\n" + "data: <json>"
|
||||
const lines = chunk.split("\n")
|
||||
const dataLine = lines.find((l) => l.startsWith("data: "))
|
||||
if (!dataLine) return chunk
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(dataLine.slice(6))
|
||||
} catch {
|
||||
return chunk
|
||||
}
|
||||
|
||||
const out: CommonChunk = {
|
||||
id: json.id ?? json.message?.id ?? "",
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: json.model ?? json.message?.model ?? "",
|
||||
choices: [],
|
||||
}
|
||||
|
||||
if (json.type === "content_block_start") {
|
||||
const cb = json.content_block
|
||||
if (cb?.type === "text") {
|
||||
out.choices.push({ index: json.index ?? 0, delta: { role: "assistant", content: "" }, finish_reason: null })
|
||||
} else if (cb?.type === "tool_use") {
|
||||
out.choices.push({
|
||||
index: json.index ?? 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{ index: json.index ?? 0, id: cb.id, type: "function", function: { name: cb.name, arguments: "" } },
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (json.type === "content_block_delta") {
|
||||
const d = json.delta
|
||||
if (d?.type === "text_delta") {
|
||||
out.choices.push({ index: json.index ?? 0, delta: { content: d.text }, finish_reason: null })
|
||||
} else if (d?.type === "input_json_delta") {
|
||||
out.choices.push({
|
||||
index: json.index ?? 0,
|
||||
delta: { tool_calls: [{ index: json.index ?? 0, function: { arguments: d.partial_json } }] },
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (json.type === "message_delta") {
|
||||
const d = json.delta
|
||||
const finish_reason = (() => {
|
||||
const r = d?.stop_reason
|
||||
if (r === "end_turn") return "stop"
|
||||
if (r === "tool_use") return "tool_calls"
|
||||
if (r === "max_tokens") return "length"
|
||||
if (r === "content_filter") return "content_filter"
|
||||
return null
|
||||
})()
|
||||
|
||||
out.choices.push({ index: 0, delta: {}, finish_reason })
|
||||
}
|
||||
|
||||
if (json.usage) {
|
||||
const u = json.usage
|
||||
out.usage = {
|
||||
prompt_tokens: u.input_tokens,
|
||||
completion_tokens: u.output_tokens,
|
||||
total_tokens: (u.input_tokens || 0) + (u.output_tokens || 0),
|
||||
...(u.cache_read_input_tokens ? { prompt_tokens_details: { cached_tokens: u.cache_read_input_tokens } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function toAnthropicChunk(chunk: CommonChunk): string {
|
||||
if (!chunk.choices || !Array.isArray(chunk.choices) || chunk.choices.length === 0) {
|
||||
return JSON.stringify({})
|
||||
}
|
||||
|
||||
const choice = chunk.choices[0]
|
||||
const delta = choice.delta
|
||||
if (!delta) return JSON.stringify({})
|
||||
|
||||
const result: any = {}
|
||||
|
||||
if (delta.content) {
|
||||
result.type = "content_block_delta"
|
||||
result.index = 0
|
||||
result.delta = { type: "text_delta", text: delta.content }
|
||||
}
|
||||
|
||||
if (delta.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
if (tc.function?.name) {
|
||||
result.type = "content_block_start"
|
||||
result.index = tc.index ?? 0
|
||||
result.content_block = { type: "tool_use", id: tc.id, name: tc.function.name, input: {} }
|
||||
} else if (tc.function?.arguments) {
|
||||
result.type = "content_block_delta"
|
||||
result.index = tc.index ?? 0
|
||||
result.delta = { type: "input_json_delta", partial_json: tc.function.arguments }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (choice.finish_reason) {
|
||||
const stop_reason = (() => {
|
||||
const r = choice.finish_reason
|
||||
if (r === "stop") return "end_turn"
|
||||
if (r === "tool_calls") return "tool_use"
|
||||
if (r === "length") return "max_tokens"
|
||||
if (r === "content_filter") return "content_filter"
|
||||
return null
|
||||
})()
|
||||
result.type = "message_delta"
|
||||
result.delta = { stop_reason, stop_sequence: null }
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
const u = chunk.usage
|
||||
result.usage = {
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
cache_read_input_tokens: u.prompt_tokens_details?.cached_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(result)
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { ProviderHelper, CommonRequest, CommonResponse, CommonChunk } from "./provider"
|
||||
|
||||
type Usage = {
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
total_tokens?: number
|
||||
// used by moonshot
|
||||
cached_tokens?: number
|
||||
// used by xai
|
||||
prompt_tokens_details?: {
|
||||
text_tokens?: number
|
||||
audio_tokens?: number
|
||||
image_tokens?: number
|
||||
cached_tokens?: number
|
||||
}
|
||||
completion_tokens_details?: {
|
||||
reasoning_tokens?: number
|
||||
audio_tokens?: number
|
||||
accepted_prediction_tokens?: number
|
||||
rejected_prediction_tokens?: number
|
||||
}
|
||||
}
|
||||
|
||||
export const oaCompatHelper = {
|
||||
format: "oa-compat",
|
||||
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||
headers.set("authorization", `Bearer ${apiKey}`)
|
||||
},
|
||||
modifyBody: (body: Record<string, any>) => {
|
||||
return {
|
||||
...body,
|
||||
...(body.stream ? { stream_options: { include_usage: true } } : {}),
|
||||
}
|
||||
},
|
||||
createUsageParser: () => {
|
||||
let usage: Usage
|
||||
|
||||
return {
|
||||
parse: (chunk: string) => {
|
||||
if (!chunk.startsWith("data: ")) return
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(chunk.slice(6)) as { usage?: Usage }
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!json.usage) return
|
||||
usage = json.usage
|
||||
},
|
||||
retrieve: () => usage,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
const inputTokens = usage.prompt_tokens ?? 0
|
||||
const outputTokens = usage.completion_tokens ?? 0
|
||||
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
return {
|
||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens: undefined,
|
||||
cacheWrite1hTokens: undefined,
|
||||
}
|
||||
},
|
||||
} satisfies ProviderHelper
|
||||
|
||||
export function fromOaCompatibleRequest(body: any): CommonRequest {
|
||||
if (!body || typeof body !== "object") return body
|
||||
|
||||
const msgsIn = Array.isArray(body.messages) ? body.messages : []
|
||||
const msgsOut: any[] = []
|
||||
|
||||
for (const m of msgsIn) {
|
||||
if (!m || !m.role) continue
|
||||
|
||||
if (m.role === "system") {
|
||||
if (typeof m.content === "string" && m.content.length > 0) msgsOut.push({ role: "system", content: m.content })
|
||||
continue
|
||||
}
|
||||
|
||||
if (m.role === "user") {
|
||||
if (typeof m.content === "string") {
|
||||
msgsOut.push({ role: "user", content: m.content })
|
||||
} else if (Array.isArray(m.content)) {
|
||||
const parts: any[] = []
|
||||
for (const p of m.content) {
|
||||
if (!p || !p.type) continue
|
||||
if (p.type === "text" && typeof p.text === "string") parts.push({ type: "text", text: p.text })
|
||||
if (p.type === "image_url") parts.push({ type: "image_url", image_url: p.image_url })
|
||||
}
|
||||
if (parts.length === 1 && parts[0].type === "text") msgsOut.push({ role: "user", content: parts[0].text })
|
||||
else if (parts.length > 0) msgsOut.push({ role: "user", content: parts })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (m.role === "assistant") {
|
||||
const out: any = { role: "assistant" }
|
||||
if (typeof m.content === "string") out.content = m.content
|
||||
if (Array.isArray(m.tool_calls)) out.tool_calls = m.tool_calls
|
||||
msgsOut.push(out)
|
||||
continue
|
||||
}
|
||||
|
||||
if (m.role === "tool") {
|
||||
msgsOut.push({ role: "tool", tool_call_id: m.tool_call_id, content: m.content })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
model: body.model,
|
||||
max_tokens: body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
stop: body.stop,
|
||||
messages: msgsOut,
|
||||
stream: !!body.stream,
|
||||
tools: Array.isArray(body.tools) ? body.tools : undefined,
|
||||
tool_choice: body.tool_choice,
|
||||
}
|
||||
}
|
||||
|
||||
export function toOaCompatibleRequest(body: CommonRequest) {
|
||||
if (!body || typeof body !== "object") return body
|
||||
|
||||
const msgsIn = Array.isArray(body.messages) ? body.messages : []
|
||||
const msgsOut: any[] = []
|
||||
|
||||
const toImg = (p: any) => {
|
||||
if (!p || typeof p !== "object") return undefined
|
||||
if (p.type === "image_url" && p.image_url) return { type: "image_url", image_url: p.image_url }
|
||||
const s = (p as any).source
|
||||
if (!s || typeof s !== "object") return undefined
|
||||
if (s.type === "url" && typeof s.url === "string") return { type: "image_url", image_url: { url: s.url } }
|
||||
if (s.type === "base64" && typeof s.media_type === "string" && typeof s.data === "string")
|
||||
return { type: "image_url", image_url: { url: `data:${s.media_type};base64,${s.data}` } }
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const m of msgsIn) {
|
||||
if (!m || !m.role) continue
|
||||
|
||||
if (m.role === "system") {
|
||||
if (typeof m.content === "string" && m.content.length > 0) msgsOut.push({ role: "system", content: m.content })
|
||||
continue
|
||||
}
|
||||
|
||||
if (m.role === "user") {
|
||||
if (typeof m.content === "string") {
|
||||
msgsOut.push({ role: "user", content: m.content })
|
||||
continue
|
||||
}
|
||||
if (Array.isArray(m.content)) {
|
||||
const parts: any[] = []
|
||||
for (const p of m.content) {
|
||||
if (!p || !p.type) continue
|
||||
if (p.type === "text" && typeof p.text === "string") parts.push({ type: "text", text: p.text })
|
||||
const ip = toImg(p)
|
||||
if (ip) parts.push(ip)
|
||||
}
|
||||
if (parts.length === 1 && parts[0].type === "text") msgsOut.push({ role: "user", content: parts[0].text })
|
||||
else if (parts.length > 0) msgsOut.push({ role: "user", content: parts })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (m.role === "assistant") {
|
||||
const out: any = { role: "assistant" }
|
||||
if (typeof m.content === "string") out.content = m.content
|
||||
if (Array.isArray(m.tool_calls)) out.tool_calls = m.tool_calls
|
||||
msgsOut.push(out)
|
||||
continue
|
||||
}
|
||||
|
||||
if (m.role === "tool") {
|
||||
msgsOut.push({ role: "tool", tool_call_id: m.tool_call_id, content: m.content })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const tools = Array.isArray(body.tools)
|
||||
? body.tools.map((tool: any) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
},
|
||||
}))
|
||||
: undefined
|
||||
|
||||
return {
|
||||
model: body.model,
|
||||
max_tokens: body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
stop: body.stop,
|
||||
messages: msgsOut,
|
||||
stream: !!body.stream,
|
||||
tools,
|
||||
tool_choice: body.tool_choice,
|
||||
response_format: (body as any).response_format,
|
||||
}
|
||||
}
|
||||
|
||||
export function fromOaCompatibleResponse(resp: any): CommonResponse {
|
||||
if (!resp || typeof resp !== "object") return resp
|
||||
|
||||
if (!Array.isArray((resp as any).choices)) return resp
|
||||
|
||||
const choice = (resp as any).choices[0]
|
||||
if (!choice) return resp
|
||||
|
||||
const message = choice.message
|
||||
if (!message) return resp
|
||||
|
||||
const content: any[] = []
|
||||
|
||||
if (typeof message.content === "string" && message.content.length > 0) {
|
||||
content.push({ type: "text", text: message.content })
|
||||
}
|
||||
|
||||
if (Array.isArray(message.tool_calls)) {
|
||||
for (const toolCall of message.tool_calls) {
|
||||
if (toolCall.type === "function" && toolCall.function) {
|
||||
let input
|
||||
try {
|
||||
input = JSON.parse(toolCall.function.arguments)
|
||||
} catch {
|
||||
input = toolCall.function.arguments
|
||||
}
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
id: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
input,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stopReason = (() => {
|
||||
const reason = choice.finish_reason
|
||||
if (reason === "stop") return "stop"
|
||||
if (reason === "tool_calls") return "tool_calls"
|
||||
if (reason === "length") return "length"
|
||||
if (reason === "content_filter") return "content_filter"
|
||||
return null
|
||||
})()
|
||||
|
||||
const usage = (() => {
|
||||
const u = (resp as any).usage
|
||||
if (!u) return undefined
|
||||
return {
|
||||
prompt_tokens: u.prompt_tokens,
|
||||
completion_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
...(u.prompt_tokens_details?.cached_tokens
|
||||
? { prompt_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } }
|
||||
: {}),
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id: (resp as any).id,
|
||||
object: "chat.completion" as const,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: (resp as any).model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant" as const,
|
||||
...(content.length > 0 && content.some((c) => c.type === "text")
|
||||
? {
|
||||
content: content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join(""),
|
||||
}
|
||||
: {}),
|
||||
...(content.length > 0 && content.some((c) => c.type === "tool_use")
|
||||
? {
|
||||
tool_calls: content
|
||||
.filter((c) => c.type === "tool_use")
|
||||
.map((c: any) => ({
|
||||
id: c.id,
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: c.name,
|
||||
arguments: typeof c.input === "string" ? c.input : JSON.stringify(c.input),
|
||||
},
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
finish_reason: stopReason,
|
||||
},
|
||||
],
|
||||
...(usage ? { usage } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function toOaCompatibleResponse(resp: CommonResponse) {
|
||||
if (!resp || typeof resp !== "object") return resp
|
||||
|
||||
if (Array.isArray((resp as any).choices)) return resp
|
||||
|
||||
const isAnthropic = typeof (resp as any).type === "string" && (resp as any).type === "message"
|
||||
if (!isAnthropic) return resp
|
||||
|
||||
const idIn = (resp as any).id
|
||||
const id =
|
||||
typeof idIn === "string" ? idIn.replace(/^msg_/, "chatcmpl_") : `chatcmpl_${Math.random().toString(36).slice(2)}`
|
||||
const model = (resp as any).model
|
||||
|
||||
const blocks: any[] = Array.isArray((resp as any).content) ? (resp as any).content : []
|
||||
const text = blocks
|
||||
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
||||
.map((b) => b.text)
|
||||
.join("")
|
||||
const tcs = blocks
|
||||
.filter((b) => b && b.type === "tool_use")
|
||||
.map((b) => {
|
||||
const name = (b as any).name
|
||||
const args = (() => {
|
||||
const inp = (b as any).input
|
||||
if (typeof inp === "string") return inp
|
||||
try {
|
||||
return JSON.stringify(inp ?? {})
|
||||
} catch {
|
||||
return String(inp ?? "")
|
||||
}
|
||||
})()
|
||||
const tid =
|
||||
typeof (b as any).id === "string" && (b as any).id.length > 0
|
||||
? (b as any).id
|
||||
: `toolu_${Math.random().toString(36).slice(2)}`
|
||||
return { id: tid, type: "function" as const, function: { name, arguments: args } }
|
||||
})
|
||||
|
||||
const finish = (r: string | null) => {
|
||||
if (r === "end_turn") return "stop"
|
||||
if (r === "tool_use") return "tool_calls"
|
||||
if (r === "max_tokens") return "length"
|
||||
if (r === "content_filter") return "content_filter"
|
||||
return null
|
||||
}
|
||||
|
||||
const u = (resp as any).usage
|
||||
const usage = (() => {
|
||||
if (!u) return undefined as any
|
||||
const pt = typeof u.input_tokens === "number" ? u.input_tokens : undefined
|
||||
const ct = typeof u.output_tokens === "number" ? u.output_tokens : undefined
|
||||
const total = pt != null && ct != null ? pt + ct : undefined
|
||||
const cached = typeof u.cache_read_input_tokens === "number" ? u.cache_read_input_tokens : undefined
|
||||
const details = cached != null ? { cached_tokens: cached } : undefined
|
||||
return {
|
||||
prompt_tokens: pt,
|
||||
completion_tokens: ct,
|
||||
total_tokens: total,
|
||||
...(details ? { prompt_tokens_details: details } : {}),
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
...(text && text.length > 0 ? { content: text } : {}),
|
||||
...(tcs.length > 0 ? { tool_calls: tcs } : {}),
|
||||
},
|
||||
finish_reason: finish((resp as any).stop_reason ?? null),
|
||||
},
|
||||
],
|
||||
...(usage ? { usage } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function fromOaCompatibleChunk(chunk: string): CommonChunk | string {
|
||||
if (!chunk.startsWith("data: ")) return chunk
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(chunk.slice(6))
|
||||
} catch {
|
||||
return chunk
|
||||
}
|
||||
|
||||
if (!json.choices || !Array.isArray(json.choices) || json.choices.length === 0) {
|
||||
return chunk
|
||||
}
|
||||
|
||||
const choice = json.choices[0]
|
||||
const delta = choice.delta
|
||||
|
||||
if (!delta) return chunk
|
||||
|
||||
const result: CommonChunk = {
|
||||
id: json.id ?? "",
|
||||
object: "chat.completion.chunk",
|
||||
created: json.created ?? Math.floor(Date.now() / 1000),
|
||||
model: json.model ?? "",
|
||||
choices: [],
|
||||
}
|
||||
|
||||
if (delta.content) {
|
||||
result.choices.push({
|
||||
index: choice.index ?? 0,
|
||||
delta: { content: delta.content },
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
|
||||
if (delta.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
result.choices.push({
|
||||
index: choice.index ?? 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: toolCall.index ?? 0,
|
||||
id: toolCall.id,
|
||||
type: toolCall.type ?? "function",
|
||||
function: toolCall.function,
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (choice.finish_reason) {
|
||||
result.choices.push({
|
||||
index: choice.index ?? 0,
|
||||
delta: {},
|
||||
finish_reason: choice.finish_reason,
|
||||
})
|
||||
}
|
||||
|
||||
if (json.usage) {
|
||||
const usage = json.usage
|
||||
result.usage = {
|
||||
prompt_tokens: usage.prompt_tokens,
|
||||
completion_tokens: usage.completion_tokens,
|
||||
total_tokens: usage.total_tokens,
|
||||
...(usage.prompt_tokens_details?.cached_tokens
|
||||
? { prompt_tokens_details: { cached_tokens: usage.prompt_tokens_details.cached_tokens } }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function toOaCompatibleChunk(chunk: CommonChunk): string {
|
||||
const result: any = {
|
||||
id: chunk.id,
|
||||
object: "chat.completion.chunk",
|
||||
created: chunk.created,
|
||||
model: chunk.model,
|
||||
choices: [],
|
||||
}
|
||||
|
||||
if (!chunk.choices || chunk.choices.length === 0) {
|
||||
return `data: ${JSON.stringify(result)}`
|
||||
}
|
||||
|
||||
const choice = chunk.choices[0]
|
||||
const delta = choice.delta
|
||||
|
||||
if (delta?.role) {
|
||||
result.choices.push({
|
||||
index: choice.index,
|
||||
delta: { role: delta.role },
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
|
||||
if (delta?.content) {
|
||||
result.choices.push({
|
||||
index: choice.index,
|
||||
delta: { content: delta.content },
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
|
||||
if (delta?.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
result.choices.push({
|
||||
index: choice.index,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: tc.index,
|
||||
id: tc.id,
|
||||
type: tc.type,
|
||||
function: tc.function,
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (choice.finish_reason) {
|
||||
result.choices.push({
|
||||
index: choice.index,
|
||||
delta: {},
|
||||
finish_reason: choice.finish_reason,
|
||||
})
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
result.usage = {
|
||||
prompt_tokens: chunk.usage.prompt_tokens,
|
||||
completion_tokens: chunk.usage.completion_tokens,
|
||||
total_tokens: chunk.usage.total_tokens,
|
||||
...(chunk.usage.prompt_tokens_details?.cached_tokens
|
||||
? {
|
||||
prompt_tokens_details: { cached_tokens: chunk.usage.prompt_tokens_details.cached_tokens },
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
return `data: ${JSON.stringify(result)}`
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
import { ProviderHelper, CommonRequest, CommonResponse, CommonChunk } from "./provider"
|
||||
|
||||
type Usage = {
|
||||
input_tokens?: number
|
||||
input_tokens_details?: {
|
||||
cached_tokens?: number
|
||||
}
|
||||
output_tokens?: number
|
||||
output_tokens_details?: {
|
||||
reasoning_tokens?: number
|
||||
}
|
||||
total_tokens?: number
|
||||
}
|
||||
|
||||
export const openaiHelper = {
|
||||
format: "openai",
|
||||
modifyUrl: (providerApi: string) => providerApi + "/responses",
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||
headers.set("authorization", `Bearer ${apiKey}`)
|
||||
},
|
||||
modifyBody: (body: Record<string, any>) => {
|
||||
return body
|
||||
},
|
||||
createUsageParser: () => {
|
||||
let usage: Usage
|
||||
|
||||
return {
|
||||
parse: (chunk: string) => {
|
||||
const [event, data] = chunk.split("\n")
|
||||
if (event !== "event: response.completed") return
|
||||
if (!data.startsWith("data: ")) return
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } }
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!json.response?.usage) return
|
||||
usage = json.response.usage
|
||||
},
|
||||
retrieve: () => usage,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
const inputTokens = usage.input_tokens ?? 0
|
||||
const outputTokens = usage.output_tokens ?? 0
|
||||
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined
|
||||
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
|
||||
return {
|
||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||
outputTokens: outputTokens - (reasoningTokens ?? 0),
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens: undefined,
|
||||
cacheWrite1hTokens: undefined,
|
||||
}
|
||||
},
|
||||
} satisfies ProviderHelper
|
||||
|
||||
export function fromOpenaiRequest(body: any): CommonRequest {
|
||||
if (!body || typeof body !== "object") return body
|
||||
|
||||
const toImg = (p: any) => {
|
||||
if (!p || typeof p !== "object") return undefined
|
||||
if ((p as any).type === "image_url" && (p as any).image_url)
|
||||
return { type: "image_url", image_url: (p as any).image_url }
|
||||
if ((p as any).type === "input_image" && (p as any).image_url)
|
||||
return { type: "image_url", image_url: (p as any).image_url }
|
||||
const s = (p as any).source
|
||||
if (!s || typeof s !== "object") return undefined
|
||||
if ((s as any).type === "url" && typeof (s as any).url === "string")
|
||||
return { type: "image_url", image_url: { url: (s as any).url } }
|
||||
if (
|
||||
(s as any).type === "base64" &&
|
||||
typeof (s as any).media_type === "string" &&
|
||||
typeof (s as any).data === "string"
|
||||
)
|
||||
return { type: "image_url", image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` } }
|
||||
return undefined
|
||||
}
|
||||
|
||||
const msgs: any[] = []
|
||||
|
||||
const inMsgs = Array.isArray(body.input) ? body.input : Array.isArray(body.messages) ? body.messages : []
|
||||
|
||||
for (const m of inMsgs) {
|
||||
if (!m) continue
|
||||
|
||||
// Responses API items without role:
|
||||
if (!(m as any).role && (m as any).type) {
|
||||
if ((m as any).type === "function_call") {
|
||||
const name = (m as any).name
|
||||
const a = (m as any).arguments
|
||||
const args = typeof a === "string" ? a : JSON.stringify(a ?? {})
|
||||
msgs.push({
|
||||
role: "assistant",
|
||||
tool_calls: [{ id: (m as any).id, type: "function", function: { name, arguments: args } }],
|
||||
})
|
||||
}
|
||||
if ((m as any).type === "function_call_output") {
|
||||
const id = (m as any).call_id
|
||||
const out = (m as any).output
|
||||
const content = typeof out === "string" ? out : JSON.stringify(out)
|
||||
msgs.push({ role: "tool", tool_call_id: id, content })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "system" || (m as any).role === "developer") {
|
||||
const c = (m as any).content
|
||||
if (typeof c === "string" && c.length > 0) msgs.push({ role: "system", content: c })
|
||||
if (Array.isArray(c)) {
|
||||
const t = c.find((p: any) => p && typeof p.text === "string")
|
||||
if (t && typeof t.text === "string" && t.text.length > 0) msgs.push({ role: "system", content: t.text })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "user") {
|
||||
const c = (m as any).content
|
||||
if (typeof c === "string") {
|
||||
msgs.push({ role: "user", content: c })
|
||||
} else if (Array.isArray(c)) {
|
||||
const parts: any[] = []
|
||||
for (const p of c) {
|
||||
if (!p || !(p as any).type) continue
|
||||
if (((p as any).type === "text" || (p as any).type === "input_text") && typeof (p as any).text === "string")
|
||||
parts.push({ type: "text", text: (p as any).text })
|
||||
const ip = toImg(p)
|
||||
if (ip) parts.push(ip)
|
||||
if ((p as any).type === "tool_result") {
|
||||
const id = (p as any).tool_call_id
|
||||
const content =
|
||||
typeof (p as any).content === "string" ? (p as any).content : JSON.stringify((p as any).content)
|
||||
msgs.push({ role: "tool", tool_call_id: id, content })
|
||||
}
|
||||
}
|
||||
if (parts.length === 1 && parts[0].type === "text") msgs.push({ role: "user", content: parts[0].text })
|
||||
else if (parts.length > 0) msgs.push({ role: "user", content: parts })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "assistant") {
|
||||
const c = (m as any).content
|
||||
const out: any = { role: "assistant" }
|
||||
if (typeof c === "string" && c.length > 0) out.content = c
|
||||
if (Array.isArray((m as any).tool_calls)) out.tool_calls = (m as any).tool_calls
|
||||
msgs.push(out)
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "tool") {
|
||||
msgs.push({ role: "tool", tool_call_id: (m as any).tool_call_id, content: (m as any).content })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const tcIn = body.tool_choice
|
||||
const tc = (() => {
|
||||
if (!tcIn) return undefined
|
||||
if (tcIn === "auto") return "auto"
|
||||
if (tcIn === "required") return "required"
|
||||
if ((tcIn as any).type === "function" && (tcIn as any).function?.name)
|
||||
return { type: "function" as const, function: { name: (tcIn as any).function.name } }
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const stop = (() => {
|
||||
const v = body.stop_sequences ?? body.stop
|
||||
if (!v) return undefined
|
||||
if (Array.isArray(v)) return v.length === 1 ? v[0] : v
|
||||
if (typeof v === "string") return v
|
||||
return undefined
|
||||
})()
|
||||
|
||||
return {
|
||||
model: body.model,
|
||||
max_tokens: body.max_output_tokens ?? body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
stop,
|
||||
messages: msgs,
|
||||
stream: !!body.stream,
|
||||
tools: Array.isArray(body.tools) ? body.tools : undefined,
|
||||
tool_choice: tc,
|
||||
}
|
||||
}
|
||||
|
||||
export function toOpenaiRequest(body: CommonRequest) {
|
||||
if (!body || typeof body !== "object") return body
|
||||
|
||||
const msgsIn = Array.isArray(body.messages) ? body.messages : []
|
||||
const input: any[] = []
|
||||
|
||||
const toPart = (p: any) => {
|
||||
if (!p || typeof p !== "object") return undefined
|
||||
if ((p as any).type === "text" && typeof (p as any).text === "string")
|
||||
return { type: "input_text", text: (p as any).text }
|
||||
if ((p as any).type === "image_url" && (p as any).image_url)
|
||||
return { type: "input_image", image_url: (p as any).image_url }
|
||||
const s = (p as any).source
|
||||
if (!s || typeof s !== "object") return undefined
|
||||
if ((s as any).type === "url" && typeof (s as any).url === "string")
|
||||
return { type: "input_image", image_url: { url: (s as any).url } }
|
||||
if (
|
||||
(s as any).type === "base64" &&
|
||||
typeof (s as any).media_type === "string" &&
|
||||
typeof (s as any).data === "string"
|
||||
)
|
||||
return { type: "input_image", image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` } }
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const m of msgsIn) {
|
||||
if (!m || !(m as any).role) continue
|
||||
|
||||
if ((m as any).role === "system") {
|
||||
const c = (m as any).content
|
||||
if (typeof c === "string") input.push({ role: "system", content: c })
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "user") {
|
||||
const c = (m as any).content
|
||||
if (typeof c === "string") {
|
||||
input.push({ role: "user", content: [{ type: "input_text", text: c }] })
|
||||
} else if (Array.isArray(c)) {
|
||||
const parts: any[] = []
|
||||
for (const p of c) {
|
||||
const op = toPart(p)
|
||||
if (op) parts.push(op)
|
||||
}
|
||||
if (parts.length > 0) input.push({ role: "user", content: parts })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "assistant") {
|
||||
const c = (m as any).content
|
||||
if (typeof c === "string" && c.length > 0) {
|
||||
input.push({ role: "assistant", content: [{ type: "output_text", text: c }] })
|
||||
}
|
||||
if (Array.isArray((m as any).tool_calls)) {
|
||||
for (const tc of (m as any).tool_calls) {
|
||||
if ((tc as any).type === "function" && (tc as any).function) {
|
||||
const name = (tc as any).function.name
|
||||
const a = (tc as any).function.arguments
|
||||
const args = typeof a === "string" ? a : JSON.stringify(a)
|
||||
input.push({ type: "function_call", call_id: (tc as any).id, name, arguments: args })
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "tool") {
|
||||
const out = typeof (m as any).content === "string" ? (m as any).content : JSON.stringify((m as any).content)
|
||||
input.push({ type: "function_call_output", call_id: (m as any).tool_call_id, output: out })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const stop_sequences = (() => {
|
||||
const v = body.stop
|
||||
if (!v) return undefined
|
||||
if (Array.isArray(v)) return v
|
||||
if (typeof v === "string") return [v]
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const tcIn = body.tool_choice
|
||||
const tool_choice = (() => {
|
||||
if (!tcIn) return undefined
|
||||
if (tcIn === "auto") return "auto"
|
||||
if (tcIn === "required") return "required"
|
||||
if ((tcIn as any).type === "function" && (tcIn as any).function?.name)
|
||||
return { type: "function", function: { name: (tcIn as any).function.name } }
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const tools = (() => {
|
||||
if (!Array.isArray(body.tools)) return undefined
|
||||
return body.tools.map((tool: any) => {
|
||||
if (tool.type === "function") {
|
||||
return {
|
||||
type: "function",
|
||||
name: tool.function?.name,
|
||||
description: tool.function?.description,
|
||||
parameters: tool.function?.parameters,
|
||||
strict: tool.function?.strict,
|
||||
}
|
||||
}
|
||||
return tool
|
||||
})
|
||||
})()
|
||||
|
||||
return {
|
||||
model: body.model,
|
||||
input,
|
||||
max_output_tokens: body.max_tokens,
|
||||
top_p: body.top_p,
|
||||
stop_sequences,
|
||||
stream: !!body.stream,
|
||||
tools,
|
||||
tool_choice,
|
||||
include: Array.isArray((body as any).include) ? (body as any).include : undefined,
|
||||
truncation: (body as any).truncation,
|
||||
metadata: (body as any).metadata,
|
||||
store: (body as any).store,
|
||||
user: (body as any).user,
|
||||
text: { verbosity: body.model === "gpt-5-codex" ? "medium" : "low" },
|
||||
reasoning: { effort: "medium" },
|
||||
}
|
||||
}
|
||||
|
||||
export function fromOpenaiResponse(resp: any): CommonResponse {
|
||||
if (!resp || typeof resp !== "object") return resp
|
||||
if (Array.isArray((resp as any).choices)) return resp
|
||||
|
||||
const r = (resp as any).response ?? resp
|
||||
if (!r || typeof r !== "object") return resp
|
||||
|
||||
const idIn = (r as any).id
|
||||
const id =
|
||||
typeof idIn === "string" ? idIn.replace(/^resp_/, "chatcmpl_") : `chatcmpl_${Math.random().toString(36).slice(2)}`
|
||||
const model = (r as any).model ?? (resp as any).model
|
||||
|
||||
const out = Array.isArray((r as any).output) ? (r as any).output : []
|
||||
const text = out
|
||||
.filter((o: any) => o && o.type === "message" && Array.isArray((o as any).content))
|
||||
.flatMap((o: any) => (o as any).content)
|
||||
.filter((p: any) => p && p.type === "output_text" && typeof p.text === "string")
|
||||
.map((p: any) => p.text)
|
||||
.join("")
|
||||
|
||||
const tcs = out
|
||||
.filter((o: any) => o && o.type === "function_call")
|
||||
.map((o: any) => {
|
||||
const name = (o as any).name
|
||||
const a = (o as any).arguments
|
||||
const args = typeof a === "string" ? a : JSON.stringify(a ?? {})
|
||||
const tid =
|
||||
typeof (o as any).id === "string" && (o as any).id.length > 0
|
||||
? (o as any).id
|
||||
: `toolu_${Math.random().toString(36).slice(2)}`
|
||||
return { id: tid, type: "function" as const, function: { name, arguments: args } }
|
||||
})
|
||||
|
||||
const finish = (r: string | null) => {
|
||||
if (r === "stop") return "stop"
|
||||
if (r === "tool_call" || r === "tool_calls") return "tool_calls"
|
||||
if (r === "length" || r === "max_output_tokens") return "length"
|
||||
if (r === "content_filter") return "content_filter"
|
||||
return null
|
||||
}
|
||||
|
||||
const u = (r as any).usage ?? (resp as any).usage
|
||||
const usage = (() => {
|
||||
if (!u) return undefined as any
|
||||
const pt = typeof (u as any).input_tokens === "number" ? (u as any).input_tokens : undefined
|
||||
const ct = typeof (u as any).output_tokens === "number" ? (u as any).output_tokens : undefined
|
||||
const total = pt != null && ct != null ? pt + ct : undefined
|
||||
const cached = (u as any).input_tokens_details?.cached_tokens
|
||||
const details = typeof cached === "number" ? { cached_tokens: cached } : undefined
|
||||
return {
|
||||
prompt_tokens: pt,
|
||||
completion_tokens: ct,
|
||||
total_tokens: total,
|
||||
...(details ? { prompt_tokens_details: details } : {}),
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
...(text && text.length > 0 ? { content: text } : {}),
|
||||
...(tcs.length > 0 ? { tool_calls: tcs } : {}),
|
||||
},
|
||||
finish_reason: finish((r as any).stop_reason ?? null),
|
||||
},
|
||||
],
|
||||
...(usage ? { usage } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function toOpenaiResponse(resp: CommonResponse) {
|
||||
if (!resp || typeof resp !== "object") return resp
|
||||
if (!Array.isArray((resp as any).choices)) return resp
|
||||
|
||||
const choice = (resp as any).choices[0]
|
||||
if (!choice) return resp
|
||||
|
||||
const msg = choice.message
|
||||
if (!msg) return resp
|
||||
|
||||
const outputItems: any[] = []
|
||||
|
||||
if (typeof msg.content === "string" && msg.content.length > 0) {
|
||||
outputItems.push({
|
||||
id: `msg_${Math.random().toString(36).slice(2)}`,
|
||||
type: "message",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: msg.content, annotations: [], logprobs: [] }],
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(msg.tool_calls)) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
if ((tc as any).type === "function" && (tc as any).function) {
|
||||
outputItems.push({
|
||||
id: (tc as any).id,
|
||||
type: "function_call",
|
||||
name: (tc as any).function.name,
|
||||
call_id: (tc as any).id,
|
||||
arguments: (tc as any).function.arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stop_reason = (() => {
|
||||
const r = choice.finish_reason
|
||||
if (r === "stop") return "stop"
|
||||
if (r === "tool_calls") return "tool_call"
|
||||
if (r === "length") return "max_output_tokens"
|
||||
if (r === "content_filter") return "content_filter"
|
||||
return null
|
||||
})()
|
||||
|
||||
const usage = (() => {
|
||||
const u = (resp as any).usage
|
||||
if (!u) return undefined
|
||||
return {
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
...(u.prompt_tokens_details?.cached_tokens
|
||||
? { input_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } }
|
||||
: {}),
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id: (resp as any).id?.replace(/^chatcmpl_/, "resp_") ?? `resp_${Math.random().toString(36).slice(2)}`,
|
||||
object: "response",
|
||||
model: (resp as any).model,
|
||||
output: outputItems,
|
||||
stop_reason,
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
export function fromOpenaiChunk(chunk: string): CommonChunk | string {
|
||||
const lines = chunk.split("\n")
|
||||
const ev = lines[0]
|
||||
const dl = lines[1]
|
||||
if (!ev || !dl || !dl.startsWith("data: ")) return chunk
|
||||
|
||||
let json: any
|
||||
try {
|
||||
json = JSON.parse(dl.slice(6))
|
||||
} catch {
|
||||
return chunk
|
||||
}
|
||||
|
||||
const respObj = json.response ?? {}
|
||||
|
||||
const out: CommonChunk = {
|
||||
id: respObj.id ?? json.id ?? "",
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: respObj.model ?? json.model ?? "",
|
||||
choices: [],
|
||||
}
|
||||
|
||||
const e = ev.replace("event: ", "").trim()
|
||||
|
||||
if (e === "response.output_text.delta") {
|
||||
const d = (json as any).delta ?? (json as any).text ?? (json as any).output_text_delta
|
||||
if (typeof d === "string" && d.length > 0)
|
||||
out.choices.push({ index: 0, delta: { content: d }, finish_reason: null })
|
||||
}
|
||||
|
||||
if (e === "response.output_item.added" && (json as any).item?.type === "function_call") {
|
||||
const name = (json as any).item?.name
|
||||
const id = (json as any).item?.id
|
||||
if (typeof name === "string" && name.length > 0) {
|
||||
out.choices.push({
|
||||
index: 0,
|
||||
delta: { tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }] },
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (e === "response.function_call_arguments.delta") {
|
||||
const a = (json as any).delta ?? (json as any).arguments_delta
|
||||
if (typeof a === "string" && a.length > 0) {
|
||||
out.choices.push({
|
||||
index: 0,
|
||||
delta: { tool_calls: [{ index: 0, function: { arguments: a } }] },
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (e === "response.completed") {
|
||||
const fr = (() => {
|
||||
const sr = (respObj as any).stop_reason ?? (json as any).stop_reason
|
||||
if (sr === "stop") return "stop"
|
||||
if (sr === "tool_call" || sr === "tool_calls") return "tool_calls"
|
||||
if (sr === "length" || sr === "max_output_tokens") return "length"
|
||||
if (sr === "content_filter") return "content_filter"
|
||||
return null
|
||||
})()
|
||||
out.choices.push({ index: 0, delta: {}, finish_reason: fr })
|
||||
|
||||
const u = (respObj as any).usage ?? (json as any).response?.usage
|
||||
if (u) {
|
||||
out.usage = {
|
||||
prompt_tokens: u.input_tokens,
|
||||
completion_tokens: u.output_tokens,
|
||||
total_tokens: (u.input_tokens || 0) + (u.output_tokens || 0),
|
||||
...(u.input_tokens_details?.cached_tokens
|
||||
? { prompt_tokens_details: { cached_tokens: u.input_tokens_details.cached_tokens } }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function toOpenaiChunk(chunk: CommonChunk): string {
|
||||
if (!chunk.choices || !Array.isArray(chunk.choices) || chunk.choices.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const choice = chunk.choices[0]
|
||||
const d = choice.delta
|
||||
if (!d) return ""
|
||||
|
||||
const id = chunk.id
|
||||
const model = chunk.model
|
||||
|
||||
if (d.content) {
|
||||
const data = { id, type: "response.output_text.delta", delta: d.content, response: { id, model } }
|
||||
return `event: response.output_text.delta\ndata: ${JSON.stringify(data)}`
|
||||
}
|
||||
|
||||
if (d.tool_calls) {
|
||||
for (const tc of d.tool_calls) {
|
||||
if (tc.function?.name) {
|
||||
const data = {
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { id: tc.id, type: "function_call", name: tc.function.name, call_id: tc.id, arguments: "" },
|
||||
}
|
||||
return `event: response.output_item.added\ndata: ${JSON.stringify(data)}`
|
||||
}
|
||||
if (tc.function?.arguments) {
|
||||
const data = {
|
||||
type: "response.function_call_arguments.delta",
|
||||
output_index: 0,
|
||||
delta: tc.function.arguments,
|
||||
}
|
||||
return `event: response.function_call_arguments.delta\ndata: ${JSON.stringify(data)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (choice.finish_reason) {
|
||||
const u = chunk.usage
|
||||
const usage = u
|
||||
? {
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
...(u.prompt_tokens_details?.cached_tokens
|
||||
? { input_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } }
|
||||
: {}),
|
||||
}
|
||||
: undefined
|
||||
|
||||
const data: any = { id, type: "response.completed", response: { id, model, ...(usage ? { usage } : {}) } }
|
||||
return `event: response.completed\ndata: ${JSON.stringify(data)}`
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Format } from "../format"
|
||||
|
||||
import {
|
||||
fromAnthropicChunk,
|
||||
fromAnthropicRequest,
|
||||
fromAnthropicResponse,
|
||||
toAnthropicChunk,
|
||||
toAnthropicRequest,
|
||||
toAnthropicResponse,
|
||||
} from "./anthropic"
|
||||
import {
|
||||
fromOpenaiChunk,
|
||||
fromOpenaiRequest,
|
||||
fromOpenaiResponse,
|
||||
toOpenaiChunk,
|
||||
toOpenaiRequest,
|
||||
toOpenaiResponse,
|
||||
} from "./openai"
|
||||
import {
|
||||
fromOaCompatibleChunk,
|
||||
fromOaCompatibleRequest,
|
||||
fromOaCompatibleResponse,
|
||||
toOaCompatibleChunk,
|
||||
toOaCompatibleRequest,
|
||||
toOaCompatibleResponse,
|
||||
} from "./openai-compatible"
|
||||
|
||||
export type ProviderHelper = {
|
||||
format: Format
|
||||
modifyUrl: (providerApi: string) => string
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
|
||||
modifyBody: (body: Record<string, any>) => Record<string, any>
|
||||
createUsageParser: () => {
|
||||
parse: (chunk: string) => void
|
||||
retrieve: () => any
|
||||
}
|
||||
normalizeUsage: (usage: any) => {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWrite5mTokens?: number
|
||||
cacheWrite1hTokens?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommonMessage {
|
||||
role: "system" | "user" | "assistant" | "tool"
|
||||
content?: string | Array<CommonContentPart>
|
||||
tool_call_id?: string
|
||||
tool_calls?: CommonToolCall[]
|
||||
}
|
||||
|
||||
export interface CommonContentPart {
|
||||
type: "text" | "image_url"
|
||||
text?: string
|
||||
image_url?: { url: string }
|
||||
}
|
||||
|
||||
export interface CommonToolCall {
|
||||
id: string
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommonTool {
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
description?: string
|
||||
parameters?: Record<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommonUsage {
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
total_tokens?: number
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
cache_creation?: {
|
||||
ephemeral_5m_input_tokens?: number
|
||||
ephemeral_1h_input_tokens?: number
|
||||
}
|
||||
input_tokens_details?: {
|
||||
cached_tokens?: number
|
||||
}
|
||||
output_tokens_details?: {
|
||||
reasoning_tokens?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommonRequest {
|
||||
model: string
|
||||
max_tokens?: number
|
||||
temperature?: number
|
||||
top_p?: number
|
||||
stop?: string | string[]
|
||||
messages: CommonMessage[]
|
||||
stream?: boolean
|
||||
tools?: CommonTool[]
|
||||
tool_choice?: "auto" | "required" | { type: "function"; function: { name: string } }
|
||||
}
|
||||
|
||||
export interface CommonResponse {
|
||||
id: string
|
||||
object: "chat.completion"
|
||||
created: number
|
||||
model: string
|
||||
choices: Array<{
|
||||
index: number
|
||||
message: {
|
||||
role: "assistant"
|
||||
content?: string
|
||||
tool_calls?: CommonToolCall[]
|
||||
}
|
||||
finish_reason: "stop" | "tool_calls" | "length" | "content_filter" | null
|
||||
}>
|
||||
usage?: {
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
total_tokens?: number
|
||||
prompt_tokens_details?: { cached_tokens?: number }
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommonChunk {
|
||||
id: string
|
||||
object: "chat.completion.chunk"
|
||||
created: number
|
||||
model: string
|
||||
choices: Array<{
|
||||
index: number
|
||||
delta: {
|
||||
role?: "assistant"
|
||||
content?: string
|
||||
tool_calls?: Array<{
|
||||
index: number
|
||||
id?: string
|
||||
type?: "function"
|
||||
function?: {
|
||||
name?: string
|
||||
arguments?: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
finish_reason: "stop" | "tool_calls" | "length" | "content_filter" | null
|
||||
}>
|
||||
usage?: {
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
total_tokens?: number
|
||||
prompt_tokens_details?: { cached_tokens?: number }
|
||||
}
|
||||
}
|
||||
|
||||
export function createBodyConverter(from: Format, to: Format) {
|
||||
return (body: any): any => {
|
||||
if (from === to) return body
|
||||
|
||||
let raw: CommonRequest
|
||||
if (from === "anthropic") raw = fromAnthropicRequest(body)
|
||||
else if (from === "openai") raw = fromOpenaiRequest(body)
|
||||
else raw = fromOaCompatibleRequest(body)
|
||||
|
||||
if (to === "anthropic") return toAnthropicRequest(raw)
|
||||
if (to === "openai") return toOpenaiRequest(raw)
|
||||
if (to === "oa-compat") return toOaCompatibleRequest(raw)
|
||||
}
|
||||
}
|
||||
|
||||
export function createStreamPartConverter(from: Format, to: Format) {
|
||||
return (part: any): any => {
|
||||
if (from === to) return part
|
||||
|
||||
let raw: CommonChunk | string
|
||||
if (from === "anthropic") raw = fromAnthropicChunk(part)
|
||||
else if (from === "openai") raw = fromOpenaiChunk(part)
|
||||
else raw = fromOaCompatibleChunk(part)
|
||||
|
||||
// If result is a string (error case), pass it through
|
||||
if (typeof raw === "string") return raw
|
||||
|
||||
if (to === "anthropic") return toAnthropicChunk(raw)
|
||||
if (to === "openai") return toOpenaiChunk(raw)
|
||||
if (to === "oa-compat") return toOaCompatibleChunk(raw)
|
||||
}
|
||||
}
|
||||
|
||||
export function createResponseConverter(from: Format, to: Format) {
|
||||
return (response: any): any => {
|
||||
if (from === to) return response
|
||||
|
||||
let raw: CommonResponse
|
||||
if (from === "anthropic") raw = fromAnthropicResponse(response)
|
||||
else if (from === "openai") raw = fromOpenaiResponse(response)
|
||||
else raw = fromOaCompatibleResponse(response)
|
||||
|
||||
if (to === "anthropic") return toAnthropicResponse(raw)
|
||||
if (to === "openai") return toOpenaiResponse(raw)
|
||||
if (to === "oa-compat") return toOaCompatibleResponse(raw)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,9 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { handler } from "~/routes/zen/handler"
|
||||
|
||||
type Usage = {
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
total_tokens?: number
|
||||
// used by moonshot
|
||||
cached_tokens?: number
|
||||
// used by xai
|
||||
prompt_tokens_details?: {
|
||||
text_tokens?: number
|
||||
audio_tokens?: number
|
||||
image_tokens?: number
|
||||
cached_tokens?: number
|
||||
}
|
||||
completion_tokens_details?: {
|
||||
reasoning_tokens?: number
|
||||
audio_tokens?: number
|
||||
accepted_prediction_tokens?: number
|
||||
rejected_prediction_tokens?: number
|
||||
}
|
||||
}
|
||||
import { handler } from "~/routes/zen/util/handler"
|
||||
|
||||
export function POST(input: APIEvent) {
|
||||
let usage: Usage
|
||||
return handler(input, {
|
||||
modifyBody: (body: any) => ({
|
||||
...body,
|
||||
...(body.stream ? { stream_options: { include_usage: true } } : {}),
|
||||
}),
|
||||
setAuthHeader: (headers: Headers, apiKey: string) => {
|
||||
headers.set("authorization", `Bearer ${apiKey}`)
|
||||
},
|
||||
format: "oa-compat",
|
||||
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
|
||||
onStreamPart: (chunk: string) => {
|
||||
if (!chunk.startsWith("data: ")) return
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(chunk.slice(6)) as { usage?: Usage }
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!json.usage) return
|
||||
usage = json.usage
|
||||
},
|
||||
getStreamUsage: () => usage,
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
const inputTokens = usage.prompt_tokens ?? 0
|
||||
const outputTokens = usage.completion_tokens ?? 0
|
||||
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
return {
|
||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||
outputTokens: outputTokens - (reasoningTokens ?? 0),
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,64 +1,9 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { handler } from "~/routes/zen/handler"
|
||||
|
||||
type Usage = {
|
||||
cache_creation?: {
|
||||
ephemeral_5m_input_tokens?: number
|
||||
ephemeral_1h_input_tokens?: number
|
||||
}
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
server_tool_use?: {
|
||||
web_search_requests?: number
|
||||
}
|
||||
}
|
||||
import { handler } from "~/routes/zen/util/handler"
|
||||
|
||||
export function POST(input: APIEvent) {
|
||||
let usage: Usage
|
||||
return handler(input, {
|
||||
modifyBody: (body: any) => ({
|
||||
...body,
|
||||
service_tier: "standard_only",
|
||||
}),
|
||||
setAuthHeader: (headers: Headers, apiKey: string) => headers.set("x-api-key", apiKey),
|
||||
format: "anthropic",
|
||||
parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
|
||||
onStreamPart: (chunk: string) => {
|
||||
const data = chunk.split("\n")[1]
|
||||
if (!data.startsWith("data: ")) return
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(data.slice(6))
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
// ie. { type: "message_start"; message: { usage: Usage } }
|
||||
// ie. { type: "message_delta"; usage: Usage }
|
||||
const usageUpdate = json.usage ?? json.message?.usage
|
||||
if (!usageUpdate) return
|
||||
usage = {
|
||||
...usage,
|
||||
...usageUpdate,
|
||||
cache_creation: {
|
||||
...usage?.cache_creation,
|
||||
...usageUpdate.cache_creation,
|
||||
},
|
||||
server_tool_use: {
|
||||
...usage?.server_tool_use,
|
||||
...usageUpdate.server_tool_use,
|
||||
},
|
||||
}
|
||||
},
|
||||
getStreamUsage: () => usage,
|
||||
normalizeUsage: (usage: Usage) => ({
|
||||
inputTokens: usage.input_tokens ?? 0,
|
||||
outputTokens: usage.output_tokens ?? 0,
|
||||
cacheReadTokens: usage.cache_read_input_tokens ?? undefined,
|
||||
cacheWrite5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens ?? undefined,
|
||||
cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? undefined,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
|
||||
export async function OPTIONS(input: APIEvent) {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const zenData = ZenData.list()
|
||||
const disabledModels = await authenticate()
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
object: "list",
|
||||
data: Object.entries(zenData.models)
|
||||
.filter(([id]) => !disabledModels.includes(id))
|
||||
.map(([id, _model]) => ({
|
||||
id,
|
||||
object: "model",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: "opencode",
|
||||
})),
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async function authenticate() {
|
||||
const apiKey = input.request.headers.get("authorization")?.split(" ")[1]
|
||||
if (!apiKey) return []
|
||||
|
||||
const disabledModels = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
model: ModelTable.model,
|
||||
})
|
||||
.from(KeyTable)
|
||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
|
||||
.leftJoin(
|
||||
ModelTable,
|
||||
and(eq(ModelTable.workspaceID, KeyTable.workspaceID), isNull(ModelTable.timeDeleted)),
|
||||
)
|
||||
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
||||
.then((rows) => rows.map((row) => row.model)),
|
||||
)
|
||||
|
||||
return disabledModels
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,9 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { handler } from "~/routes/zen/handler"
|
||||
|
||||
type Usage = {
|
||||
input_tokens?: number
|
||||
input_tokens_details?: {
|
||||
cached_tokens?: number
|
||||
}
|
||||
output_tokens?: number
|
||||
output_tokens_details?: {
|
||||
reasoning_tokens?: number
|
||||
}
|
||||
total_tokens?: number
|
||||
}
|
||||
import { handler } from "~/routes/zen/util/handler"
|
||||
|
||||
export function POST(input: APIEvent) {
|
||||
let usage: Usage
|
||||
return handler(input, {
|
||||
setAuthHeader: (headers: Headers, apiKey: string) => {
|
||||
headers.set("authorization", `Bearer ${apiKey}`)
|
||||
},
|
||||
format: "openai",
|
||||
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
|
||||
onStreamPart: (chunk: string) => {
|
||||
const [event, data] = chunk.split("\n")
|
||||
if (event !== "event: response.completed") return
|
||||
if (!data.startsWith("data: ")) return
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } }
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!json.response?.usage) return
|
||||
usage = json.response.usage
|
||||
},
|
||||
getStreamUsage: () => usage,
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
const inputTokens = usage.input_tokens ?? 0
|
||||
const outputTokens = usage.output_tokens ?? 0
|
||||
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined
|
||||
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
|
||||
return {
|
||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||
outputTokens: outputTokens - (reasoningTokens ?? 0),
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Database, eq } from "../src/drizzle/index.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql"
|
||||
|
||||
// get input from command line
|
||||
const email = process.argv[2]
|
||||
if (!email) {
|
||||
console.error("Usage: bun lookup-user.ts <email>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const authData = await printTable("Auth", (tx) =>
|
||||
tx.select().from(AuthTable).where(eq(AuthTable.subject, email)),
|
||||
)
|
||||
if (authData.length === 0) {
|
||||
console.error("User not found")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await printTable("Auth", (tx) =>
|
||||
tx.select().from(AuthTable).where(eq(AuthTable.accountID, authData[0].accountID)),
|
||||
)
|
||||
|
||||
function printTable(
|
||||
title: string,
|
||||
callback: (tx: Database.TxOrDb) => Promise<any[]>,
|
||||
): Promise<any[]> {
|
||||
return Database.use(async (tx) => {
|
||||
const data = await callback(tx)
|
||||
console.log(`== ${title} ==`)
|
||||
console.table(data)
|
||||
return data
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { Database } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { AccountTable } from "@opencode-ai/console-core/schema/account.sql.js"
|
||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||
import { BillingTable, PaymentTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||
import { Database } from "../src/drizzle/index.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { AccountTable } from "../src/schema/account.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
import { BillingTable, PaymentTable, UsageTable } from "../src/schema/billing.sql.js"
|
||||
import { KeyTable } from "../src/schema/key.sql.js"
|
||||
|
||||
if (Resource.App.stage !== "frank") throw new Error("This script is only for frank")
|
||||
|
||||
for (const table of [AccountTable, BillingTable, KeyTable, PaymentTable, UsageTable, UserTable, WorkspaceTable]) {
|
||||
for (const table of [
|
||||
AccountTable,
|
||||
BillingTable,
|
||||
KeyTable,
|
||||
PaymentTable,
|
||||
UsageTable,
|
||||
UserTable,
|
||||
WorkspaceTable,
|
||||
]) {
|
||||
await Database.use((tx) => tx.delete(table))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.svg" />
|
||||
<title>OpenCode</title>
|
||||
</head>
|
||||
<body class="overscroll-none select-none text-12-regular">
|
||||
<body class="antialiased overscroll-none select-none text-12-regular">
|
||||
<!-- <script> -->
|
||||
<!-- ;(function () { -->
|
||||
<!-- const savedTheme = localStorage.getItem("theme") || "opencode" -->
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
@@ -16,8 +16,8 @@
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
"vite-plugin-solid": "catalog:"
|
||||
@@ -26,7 +26,6 @@
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/precision-diffs": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/active-element": "2.1.3",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "s
|
||||
import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js"
|
||||
import { useLocal, type TextSelection } from "@/context/local"
|
||||
import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils"
|
||||
import { useShiki } from "@/context/shiki"
|
||||
import { useShiki } from "@opencode-ai/ui"
|
||||
|
||||
type DefinedSelection = Exclude<TextSelection, undefined>
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useMarked } from "@/context/marked"
|
||||
import { createResource } from "solid-js"
|
||||
|
||||
function strip(text: string): string {
|
||||
const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/
|
||||
const match = text.match(wrappedRe)
|
||||
return match ? match[2] : text
|
||||
}
|
||||
export function Markdown(props: { text: string; class?: string }) {
|
||||
const marked = useMarked()
|
||||
const [html] = createResource(
|
||||
() => strip(props.text),
|
||||
async (markdown) => {
|
||||
return marked.parse(markdown)
|
||||
},
|
||||
)
|
||||
return (
|
||||
<div
|
||||
class={`min-w-0 max-w-full overflow-auto no-scrollbar text-14-regular text-text-base ${props.class ?? ""}`}
|
||||
innerHTML={html()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Component, createMemo } from "solid-js"
|
||||
|
||||
interface ProgressCircleProps {
|
||||
percentage: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}
|
||||
|
||||
export const ProgressCircle: Component<ProgressCircleProps> = (props) => {
|
||||
// --- Set default values for props ---
|
||||
const size = () => props.size || 16
|
||||
const strokeWidth = () => props.strokeWidth || 3
|
||||
|
||||
// --- Constants for SVG calculation ---
|
||||
const viewBoxSize = 16
|
||||
const center = viewBoxSize / 2
|
||||
const radius = () => center - strokeWidth() / 2
|
||||
const circumference = createMemo(() => 2 * Math.PI * radius())
|
||||
|
||||
// --- Reactive Calculation for the progress offset ---
|
||||
const offset = createMemo(() => {
|
||||
const clampedPercentage = Math.max(0, Math.min(100, props.percentage || 0))
|
||||
const progress = clampedPercentage / 100
|
||||
return circumference() * (1 - progress)
|
||||
})
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size()}
|
||||
height={size()}
|
||||
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
|
||||
fill="none"
|
||||
class="transform -rotate-90"
|
||||
>
|
||||
<circle cx={center} cy={center} r={radius()} class="stroke-border-weak-base" stroke-width={strokeWidth()} />
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius()}
|
||||
class="stroke-border-active"
|
||||
stroke-width={strokeWidth()}
|
||||
stroke-dasharray={circumference().toString()}
|
||||
stroke-dashoffset={offset()}
|
||||
style={{ transition: "stroke-dashoffset 0.35s cubic-bezier(0.65, 0, 0.35, 1)" }}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -10,13 +10,15 @@ import { DateTime } from "luxon"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
interface TextPart extends PartBase {
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
interface FileAttachmentPart extends PartBase {
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: TextSelection
|
||||
@@ -34,7 +36,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const local = useLocal()
|
||||
let editorRef!: HTMLDivElement
|
||||
|
||||
const defaultParts = [{ type: "text", content: "" } as const]
|
||||
const defaultParts = [{ type: "text", content: "", start: 0, end: 0 } as const]
|
||||
const [store, setStore] = createStore<{
|
||||
contentParts: ContentPart[]
|
||||
popoverIsOpen: boolean
|
||||
@@ -51,7 +53,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
event.stopPropagation()
|
||||
// @ts-expect-error
|
||||
const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? ""
|
||||
addPart({ type: "text", content: plainText })
|
||||
addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -69,16 +71,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
|
||||
const { flat, active, onInput, onKeyDown, refetch } = useFilteredList<string>({
|
||||
items: local.file.search,
|
||||
key: (x) => x,
|
||||
onSelect: (path) => {
|
||||
if (!path) return
|
||||
addPart({ type: "file", path, content: "@" + getFilename(path) })
|
||||
addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 })
|
||||
setStore("popoverIsOpen", false)
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
local.model.recent()
|
||||
refetch()
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => store.contentParts,
|
||||
@@ -117,17 +124,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const parseFromDOM = (): ContentPart[] => {
|
||||
const newParts: ContentPart[] = []
|
||||
let position = 0
|
||||
editorRef.childNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
if (node.textContent) newParts.push({ type: "text", content: node.textContent })
|
||||
if (node.textContent) {
|
||||
const content = node.textContent
|
||||
newParts.push({ type: "text", content, start: position, end: position + content.length })
|
||||
position += content.length
|
||||
}
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type) {
|
||||
switch ((node as HTMLElement).dataset.type) {
|
||||
case "file":
|
||||
const content = node.textContent!
|
||||
newParts.push({
|
||||
type: "file",
|
||||
path: (node as HTMLElement).dataset.path!,
|
||||
content: node.textContent!,
|
||||
content,
|
||||
start: position,
|
||||
end: position + content.length,
|
||||
})
|
||||
position += content.length
|
||||
break
|
||||
default:
|
||||
break
|
||||
@@ -163,17 +179,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const startIndex = atMatch ? atMatch.index! : cursorPosition
|
||||
const endIndex = atMatch ? cursorPosition : cursorPosition
|
||||
|
||||
const pushText = (acc: { parts: ContentPart[] }, value: string) => {
|
||||
const pushText = (acc: { parts: ContentPart[]; runningIndex: number }, value: string) => {
|
||||
if (!value) return
|
||||
const last = acc.parts[acc.parts.length - 1]
|
||||
if (last && last.type === "text") {
|
||||
acc.parts[acc.parts.length - 1] = {
|
||||
type: "text",
|
||||
content: last.content + value,
|
||||
start: last.start,
|
||||
end: last.end + value.length,
|
||||
}
|
||||
return
|
||||
}
|
||||
acc.parts.push({ type: "text", content: value })
|
||||
acc.parts.push({ type: "text", content: value, start: acc.runningIndex, end: acc.runningIndex + value.length })
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -183,20 +201,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
} = store.contentParts.reduce(
|
||||
(acc, item) => {
|
||||
if (acc.inserted) {
|
||||
acc.parts.push(item)
|
||||
acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
|
||||
acc.runningIndex += item.content.length
|
||||
return acc
|
||||
}
|
||||
|
||||
const nextIndex = acc.runningIndex + item.content.length
|
||||
if (nextIndex <= startIndex) {
|
||||
acc.parts.push(item)
|
||||
acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
|
||||
acc.runningIndex = nextIndex
|
||||
return acc
|
||||
}
|
||||
|
||||
if (item.type !== "text") {
|
||||
acc.parts.push(item)
|
||||
acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
|
||||
acc.runningIndex = nextIndex
|
||||
return acc
|
||||
}
|
||||
@@ -207,24 +225,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const tail = item.content.slice(tailLength)
|
||||
|
||||
pushText(acc, head)
|
||||
acc.runningIndex += head.length
|
||||
|
||||
if (part.type === "text") {
|
||||
pushText(acc, part.content)
|
||||
acc.runningIndex += part.content.length
|
||||
}
|
||||
if (part.type !== "text") {
|
||||
acc.parts.push({ ...part })
|
||||
acc.parts.push({ ...part, start: acc.runningIndex, end: acc.runningIndex + part.content.length })
|
||||
acc.runningIndex += part.content.length
|
||||
}
|
||||
|
||||
const needsGap = Boolean(atMatch)
|
||||
const rest = needsGap ? (tail ? (/^\s/.test(tail) ? tail : ` ${tail}`) : " ") : tail
|
||||
pushText(acc, rest)
|
||||
acc.runningIndex += rest.length
|
||||
|
||||
const baseCursor = startIndex + part.content.length
|
||||
const cursorAddition = needsGap && rest.length > 0 ? 1 : 0
|
||||
acc.cursorPositionAfter = baseCursor + cursorAddition
|
||||
|
||||
acc.inserted = true
|
||||
acc.runningIndex = nextIndex
|
||||
return acc
|
||||
},
|
||||
{
|
||||
@@ -237,9 +258,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
if (!inserted) {
|
||||
const baseParts = store.contentParts.filter((item) => !(item.type === "text" && item.content === ""))
|
||||
const appendedAcc = { parts: [...baseParts] as ContentPart[] }
|
||||
if (part.type === "text") pushText(appendedAcc, part.content)
|
||||
if (part.type !== "text") appendedAcc.parts.push({ ...part })
|
||||
const runningIndex = baseParts.reduce((sum, p) => sum + p.content.length, 0)
|
||||
const appendedAcc = { parts: [...baseParts] as ContentPart[], runningIndex }
|
||||
if (part.type === "text") {
|
||||
pushText(appendedAcc, part.content)
|
||||
}
|
||||
if (part.type !== "text") {
|
||||
appendedAcc.parts.push({
|
||||
...part,
|
||||
start: appendedAcc.runningIndex,
|
||||
end: appendedAcc.runningIndex + part.content.length,
|
||||
})
|
||||
}
|
||||
const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : defaultParts
|
||||
setStore("contentParts", next)
|
||||
setStore("popoverIsOpen", false)
|
||||
@@ -289,7 +319,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular">
|
||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(i)}/
|
||||
{getDirectory(i)}
|
||||
</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||
</div>
|
||||
@@ -344,16 +374,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
items={local.model.list()}
|
||||
current={local.model.current()}
|
||||
filterKeys={["provider.name", "name", "id"]}
|
||||
groupBy={(x) => x.provider.name}
|
||||
groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)}
|
||||
sortGroupsBy={(a, b) => {
|
||||
const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
|
||||
if (a.category === "Recent" && b.category !== "Recent") return -1
|
||||
if (b.category === "Recent" && a.category !== "Recent") return 1
|
||||
const aProvider = a.items[0].provider.id
|
||||
const bProvider = b.items[0].provider.id
|
||||
if (order.includes(aProvider) && !order.includes(bProvider)) return -1
|
||||
if (!order.includes(aProvider) && order.includes(bProvider)) return 1
|
||||
return order.indexOf(aProvider) - order.indexOf(bProvider)
|
||||
}}
|
||||
onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
|
||||
onSelect={(x) =>
|
||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true })
|
||||
}
|
||||
trigger={
|
||||
<Button as="div" variant="ghost">
|
||||
{local.model.current()?.name ?? "Select model"}
|
||||
@@ -368,9 +402,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0 " />
|
||||
<div class="flex gap-x-3 items-baseline flex-[1_0_0]">
|
||||
<span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
|
||||
<span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0">
|
||||
{DateTime.fromFormat(i.release_date, "yyyy-MM-dd").toFormat("LLL yyyy")}
|
||||
</span>
|
||||
<Show when={i.release_date}>
|
||||
<span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0">
|
||||
{DateTime.fromFormat(i.release_date, "yyyy-MM-dd").toFormat("LLL yyyy")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={!i.cost || i.cost?.input === 0}>
|
||||
|
||||
@@ -1,536 +0,0 @@
|
||||
import { Icon, Tooltip } from "@opencode-ai/ui"
|
||||
import { Collapsible } from "@/ui"
|
||||
import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk"
|
||||
import { DateTime } from "luxon"
|
||||
import {
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
splitProps,
|
||||
Switch,
|
||||
type ComponentProps,
|
||||
type ParentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { getFilename } from "@/utils"
|
||||
import { Markdown } from "./markdown"
|
||||
import { Code } from "./code"
|
||||
import { createElementSize } from "@solid-primitives/resize-observer"
|
||||
import { createScrollPosition } from "@solid-primitives/scroll"
|
||||
import { ProgressCircle } from "./progress-circle"
|
||||
import { pipe, sumBy } from "remeda"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLocal } from "@/context/local"
|
||||
|
||||
function Part(props: ParentProps & ComponentProps<"div">) {
|
||||
const [local, others] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
"h-6 flex items-center": true,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
{...others}
|
||||
>
|
||||
<p class="text-12-medium text-left">{local.children}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps & ComponentProps<typeof Collapsible>) {
|
||||
return (
|
||||
<Collapsible {...props}>
|
||||
<Collapsible.Trigger class="peer/collapsible">
|
||||
<Part>{props.title}</Part>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<p class="flex-auto min-w-0 text-pretty">
|
||||
<span class="text-12-medium text-text-weak break-words">{props.children}</span>
|
||||
</p>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
function ReadToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
const local = useLocal()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Reading file...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => {
|
||||
const path = state().input["filePath"] as string
|
||||
return (
|
||||
<Part onClick={() => local.file.open(path)}>
|
||||
<span class="">Read</span> {getFilename(path)}
|
||||
</Part>
|
||||
)
|
||||
}}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<div>
|
||||
<Part>
|
||||
<span class="">Read</span> {getFilename(state().input["filePath"] as string)}
|
||||
</Part>
|
||||
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function EditToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Preparing edit...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<>
|
||||
<span class="">Edit</span> {getFilename(state().input["filePath"] as string)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Code path={state().input["filePath"] as string} code={state().metadata["diff"] as string} />
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<>
|
||||
<span class="">Edit</span> {getFilename(state().input["filePath"] as string)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function WriteToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Preparing write...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<>
|
||||
<span class="">Write</span> {getFilename(state().input["filePath"] as string)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="p-2 bg-background-panel rounded-md border border-border-subtle"></div>
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<div>
|
||||
<Part>
|
||||
<span class="">Write</span> {getFilename(state().input["filePath"] as string)}
|
||||
</Part>
|
||||
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function BashToolPart(props: { part: ToolPart }) {
|
||||
const sync = useSync()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.state.status === "pending"}>
|
||||
<Part>Writing shell command...</Part>
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "completed" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
defaultOpen
|
||||
title={
|
||||
<>
|
||||
<span class="">Run command:</span> {state().input["command"]}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Markdown text={`\`\`\`command\n${state().input["command"]}\n${state().output}\`\`\``} />
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.part.state.status === "error" && props.part.state}>
|
||||
{(state) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<>
|
||||
<span class="">Shell</span> {state().input["command"]}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolPart(props: { part: ToolPart }) {
|
||||
// read
|
||||
// edit
|
||||
// write
|
||||
// bash
|
||||
// ls
|
||||
// glob
|
||||
// grep
|
||||
// todowrite
|
||||
// todoread
|
||||
// webfetch
|
||||
// websearch
|
||||
// patch
|
||||
// task
|
||||
return (
|
||||
<div class="min-w-0 flex-auto text-12-medium">
|
||||
<Switch
|
||||
fallback={
|
||||
<span>
|
||||
{props.part.type}:{props.part.tool}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Match when={props.part.tool === "read"}>
|
||||
<ReadToolPart part={props.part} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "edit"}>
|
||||
<EditToolPart part={props.part} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "write"}>
|
||||
<WriteToolPart part={props.part} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "bash"}>
|
||||
<BashToolPart part={props.part} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SessionTimeline(props: { session: string; class?: string }) {
|
||||
const sync = useSync()
|
||||
const [scrollElement, setScrollElement] = createSignal<HTMLElement | undefined>(undefined)
|
||||
const [root, setRoot] = createSignal<HTMLDivElement | undefined>(undefined)
|
||||
const [tail, setTail] = createSignal(true)
|
||||
const size = createElementSize(root)
|
||||
const scroll = createScrollPosition(scrollElement)
|
||||
|
||||
const valid = (part: Part) => {
|
||||
if (!part) return false
|
||||
switch (part.type) {
|
||||
case "step-start":
|
||||
case "step-finish":
|
||||
case "file":
|
||||
case "patch":
|
||||
return false
|
||||
case "text":
|
||||
return !part.synthetic && part.text.trim()
|
||||
case "reasoning":
|
||||
return part.text.trim()
|
||||
case "tool":
|
||||
switch (part.tool) {
|
||||
case "todoread":
|
||||
case "todowrite":
|
||||
case "list":
|
||||
case "grep":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const hasValidParts = (message: Message) => {
|
||||
return sync.data.part[message.id]?.filter(valid).length > 0
|
||||
}
|
||||
|
||||
const hasTextPart = (message: Message) => {
|
||||
return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
|
||||
}
|
||||
|
||||
const session = createMemo(() => sync.session.get(props.session))
|
||||
const messages = createMemo(() => sync.data.message[props.session] ?? [])
|
||||
const messagesWithValidParts = createMemo(() => sync.data.message[props.session]?.filter(hasValidParts) ?? [])
|
||||
const working = createMemo(() => {
|
||||
const last = messages()[messages().length - 1]
|
||||
if (!last) return false
|
||||
if (last.role === "user") return true
|
||||
return !last.time.completed
|
||||
})
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = pipe(
|
||||
messages(),
|
||||
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
|
||||
)
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
})
|
||||
|
||||
const last = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant") as AssistantMessage
|
||||
})
|
||||
|
||||
const model = createMemo(() => {
|
||||
if (!last()) return
|
||||
const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
|
||||
return model
|
||||
})
|
||||
|
||||
const tokens = createMemo(() => {
|
||||
if (!last()) return
|
||||
const tokens = last().tokens
|
||||
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
}).format(total)
|
||||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
if (!last()) return
|
||||
if (!model()?.limit.context) return 0
|
||||
const tokens = last().tokens
|
||||
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
return Math.round((total / model()!.limit.context) * 100)
|
||||
})
|
||||
|
||||
const getScrollParent = (el: HTMLElement | null): HTMLElement | undefined => {
|
||||
let p = el?.parentElement
|
||||
while (p && p !== document.body) {
|
||||
const s = getComputedStyle(p)
|
||||
if (s.overflowY === "auto" || s.overflowY === "scroll") return p
|
||||
p = p.parentElement
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!root()) return
|
||||
setScrollElement(getScrollParent(root()!))
|
||||
})
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const element = scrollElement()
|
||||
if (!element) return
|
||||
element.scrollTop = element.scrollHeight
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
size.height
|
||||
if (tail()) scrollToBottom()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (working()) {
|
||||
setTail(true)
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
|
||||
let lastScrollY = 0
|
||||
createEffect(() => {
|
||||
if (scroll.y < lastScrollY) {
|
||||
setTail(false)
|
||||
}
|
||||
lastScrollY = scroll.y
|
||||
})
|
||||
|
||||
const duration = (part: Part) => {
|
||||
switch (part.type) {
|
||||
default:
|
||||
if (
|
||||
"time" in part &&
|
||||
part.time &&
|
||||
"start" in part.time &&
|
||||
part.time.start &&
|
||||
"end" in part.time &&
|
||||
part.time.end
|
||||
) {
|
||||
const start = DateTime.fromMillis(part.time.start)
|
||||
const end = DateTime.fromMillis(part.time.end)
|
||||
return end.diff(start).toFormat("s")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
console.log("WHAT")
|
||||
console.log(JSON.stringify(messagesWithValidParts()))
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRoot}
|
||||
classList={{
|
||||
"select-text flex flex-col text-text-weak": true,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<div class="flex justify-end items-center self-stretch">
|
||||
<div class="flex items-center gap-6">
|
||||
<Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
|
||||
<Show when={context()}>
|
||||
<ProgressCircle percentage={context()!} />
|
||||
</Show>
|
||||
<div class="text-14-regular text-text-weak text-right">{context()}%</div>
|
||||
</Tooltip>
|
||||
<div class="text-14-regular text-text-strong text-right">{cost()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul role="list" class="flex flex-col items-start self-stretch">
|
||||
<For each={messagesWithValidParts()}>
|
||||
{(message) => (
|
||||
<div
|
||||
classList={{
|
||||
"flex flex-col gap-1 justify-center items-start self-stretch": true,
|
||||
"mt-6": hasTextPart(message),
|
||||
}}
|
||||
>
|
||||
<For each={sync.data.part[message.id]?.filter(valid) ?? []}>
|
||||
{(part) => (
|
||||
<li class="group/li">
|
||||
<Switch fallback={<div class="">{part.type}</div>}>
|
||||
<Match when={part.type === "text" && part}>
|
||||
{(part) => (
|
||||
<Switch>
|
||||
<Match when={message.role === "user"}>
|
||||
<div class="w-fit flex items-center px-3 py-1 rounded-md bg-surface-weak">
|
||||
<span class="text-14-regular text-text-strong whitespace-pre-wrap break-words">
|
||||
{part().text}
|
||||
</span>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={message.role === "assistant"}>
|
||||
<Markdown text={sync.sanitize(part().text)} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={part.type === "reasoning" && part}>
|
||||
{(part) => (
|
||||
<CollapsiblePart
|
||||
title={
|
||||
<Switch fallback={<span class="text-text-weak">Thinking</span>}>
|
||||
<Match when={part().time.end}>
|
||||
<span class="text-12-medium text-text-weak">Thought</span> for {duration(part())}s
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
>
|
||||
<Markdown text={part().text} />
|
||||
</CollapsiblePart>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={part.type === "tool" && part}>{(part) => <ToolPart part={part()} />}</Match>
|
||||
</Switch>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<Show when={false}>
|
||||
<Collapsible defaultOpen={false}>
|
||||
<Collapsible.Trigger>
|
||||
<div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted">
|
||||
<Icon name="file-code" />
|
||||
<span>Raw Session Data</span>
|
||||
<Collapsible.Arrow class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="mt-5">
|
||||
<ul role="list" class="space-y-2">
|
||||
<li>
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||
<Icon name="file-code" />
|
||||
<span>session</span>
|
||||
<Collapsible.Arrow class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code path="session.json" code={JSON.stringify(session(), null, 2)} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<>
|
||||
<li>
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||
<Icon name="file-code" />
|
||||
<span>{message.role === "user" ? "user" : "assistant"}</span>
|
||||
<Collapsible.Arrow class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code path={message.id + ".json"} code={JSON.stringify(message, null, 2)} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
<For each={sync.data.part[message.id]}>
|
||||
{(part) => (
|
||||
<li>
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
|
||||
<Icon name="file-code" />
|
||||
<span>{part.type}</span>
|
||||
<Collapsible.Arrow class="text-text-muted" />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Code path={message.id + "." + part.id + ".json"} code={JSON.stringify(part, null, 2)} />
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,37 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
|
||||
function isModelValid(model: ModelKey) {
|
||||
const provider = sync.data.provider.find((x) => x.id === model.providerID)
|
||||
return !!provider?.models[model.modelID]
|
||||
}
|
||||
|
||||
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
|
||||
for (const modelFn of modelFns) {
|
||||
const model = modelFn()
|
||||
if (!model) continue
|
||||
if (isModelValid(model)) return model
|
||||
}
|
||||
}
|
||||
|
||||
// Automatically update model when agent changes
|
||||
createEffect(() => {
|
||||
const value = agent.current()
|
||||
if (value.model) {
|
||||
if (isModelValid(value.model))
|
||||
model.set({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
// else
|
||||
// toast.show({
|
||||
// type: "warning",
|
||||
// message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
|
||||
// duration: 3000,
|
||||
// })
|
||||
}
|
||||
})
|
||||
|
||||
const agent = (() => {
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
|
||||
const [store, setStore] = createStore<{
|
||||
@@ -76,11 +107,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})()
|
||||
|
||||
const model = (() => {
|
||||
const list = createMemo(() =>
|
||||
sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
|
||||
)
|
||||
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
model: Record<string, ModelKey>
|
||||
recent: ModelKey[]
|
||||
@@ -95,27 +121,54 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
localStorage.setItem("model", JSON.stringify(store.recent))
|
||||
})
|
||||
|
||||
const fallback = createMemo(() => {
|
||||
if (store.recent.length) return store.recent[0]
|
||||
const list = createMemo(() =>
|
||||
sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
|
||||
)
|
||||
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
|
||||
|
||||
const fallbackModel = createMemo(() => {
|
||||
if (sync.data.config.model) {
|
||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||
if (isModelValid({ providerID, modelID })) {
|
||||
return {
|
||||
providerID,
|
||||
modelID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of store.recent) {
|
||||
if (isModelValid(item)) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
const provider = sync.data.provider[0]
|
||||
const model = Object.values(provider.models)[0]
|
||||
return { modelID: model.id, providerID: provider.id }
|
||||
return {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
}
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
const currentModel = createMemo(() => {
|
||||
const a = agent.current()
|
||||
return find(store.model[agent.current().name]) ?? find(a.model ?? fallback())
|
||||
const key = getFirstValidModel(
|
||||
() => store.model[a.name],
|
||||
() => a.model,
|
||||
fallbackModel,
|
||||
)!
|
||||
return find(key)
|
||||
})
|
||||
|
||||
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
|
||||
|
||||
return {
|
||||
list,
|
||||
current,
|
||||
current: currentModel,
|
||||
recent,
|
||||
list,
|
||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setStore("model", agent.current().name, model ?? fallback())
|
||||
setStore("model", agent.current().name, model ?? fallbackModel())
|
||||
if (options?.recent && model) {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 5) uniq.pop()
|
||||
@@ -279,10 +332,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
break
|
||||
case "file.watcher.updated":
|
||||
setTimeout(sync.load.changes, 1000)
|
||||
const relativePath = relative(event.properties.file)
|
||||
if (relativePath.startsWith(".git/")) return
|
||||
load(relativePath)
|
||||
// setTimeout(sync.load.changes, 1000)
|
||||
// const relativePath = relative(event.properties.file)
|
||||
// if (relativePath.startsWith(".git/")) return
|
||||
// load(relativePath)
|
||||
break
|
||||
}
|
||||
})
|
||||
@@ -429,13 +482,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
.sort((a, b) => b.id.localeCompare(a.id)),
|
||||
)
|
||||
|
||||
const working = createMemo(() => {
|
||||
const last = messages()[messages().length - 1]
|
||||
if (!last) return false
|
||||
if (last.role === "user") return true
|
||||
return !last.time.completed
|
||||
})
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = pipe(
|
||||
messages(),
|
||||
@@ -460,13 +506,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return sync.data.message[store.active]?.find((m) => m.id === store.activeMessage)
|
||||
})
|
||||
|
||||
const activeAssistantMessages = createMemo(() => {
|
||||
if (!store.active || !activeMessage()) return []
|
||||
return sync.data.message[store.active]?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == activeMessage()?.id,
|
||||
)
|
||||
})
|
||||
|
||||
const model = createMemo(() => {
|
||||
if (!last()) return
|
||||
const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
|
||||
@@ -504,7 +543,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return {
|
||||
active,
|
||||
activeMessage,
|
||||
activeAssistantMessages,
|
||||
lastUserMessage,
|
||||
cost,
|
||||
last,
|
||||
@@ -514,7 +552,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
messages,
|
||||
messagesWithValidParts,
|
||||
userMessages,
|
||||
working,
|
||||
// working,
|
||||
getMessageText,
|
||||
setActive(sessionId: string | undefined) {
|
||||
setStore("active", sessionId)
|
||||
|
||||
@@ -77,21 +77,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
break
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const parts = store.part[event.properties.part.messageID]
|
||||
const part = sanitizePart(event.properties.part)
|
||||
const parts = store.part[part.messageID]
|
||||
if (!parts) {
|
||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
||||
setStore("part", part.messageID, [part])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
|
||||
const result = Binary.search(parts, part.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
|
||||
setStore("part", part.messageID, result.index, reconcile(part))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.part.messageID,
|
||||
part.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.part)
|
||||
draft.splice(result.index, 0, part)
|
||||
}),
|
||||
)
|
||||
break
|
||||
@@ -121,6 +122,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
|
||||
const sanitize = (text: string) => text.replace(sanitizer(), "")
|
||||
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
||||
const sanitizePart = (part: Part) => {
|
||||
if (part.type === "tool") {
|
||||
if (part.state.status === "completed") {
|
||||
for (const key in part.state.metadata) {
|
||||
if (typeof part.state.metadata[key] === "string") {
|
||||
part.state.metadata[key] = sanitize(part.state.metadata[key] as string)
|
||||
}
|
||||
}
|
||||
for (const key in part.state.input) {
|
||||
if (typeof part.state.input[key] === "string") {
|
||||
part.state.input[key] = sanitize(part.state.input[key] as string)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return part
|
||||
}
|
||||
|
||||
return {
|
||||
data: store,
|
||||
@@ -155,7 +173,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
for (const message of messages.data!) {
|
||||
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
draft.part[message.info.id] = message.parts
|
||||
.slice()
|
||||
.map(sanitizePart)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,9 +3,7 @@ import "@/index.css"
|
||||
import { render } from "solid-js/web"
|
||||
import { Router, Route } from "@solidjs/router"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { Fonts } from "@opencode-ai/ui"
|
||||
import { ShikiProvider } from "./context/shiki"
|
||||
import { MarkedProvider } from "./context/marked"
|
||||
import { Fonts, ShikiProvider, MarkedProvider } from "@opencode-ai/ui"
|
||||
import { SDKProvider } from "./context/sdk"
|
||||
import { SyncProvider } from "./context/sync"
|
||||
import { LocalProvider } from "./context/local"
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { Button, List, SelectDialog, Tooltip, IconButton, Tabs, Icon, Accordion } from "@opencode-ai/ui"
|
||||
import {
|
||||
Button,
|
||||
List,
|
||||
SelectDialog,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Tabs,
|
||||
Icon,
|
||||
Accordion,
|
||||
Diff,
|
||||
Collapsible,
|
||||
Part,
|
||||
DiffChanges,
|
||||
ProgressCircle,
|
||||
Message,
|
||||
} from "@opencode-ai/ui"
|
||||
import { FileIcon } from "@/ui"
|
||||
import FileTree from "@/components/file-tree"
|
||||
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
|
||||
import { useLocal, type LocalFile, type TextSelection } from "@/context/local"
|
||||
import { useLocal, type LocalFile } from "@/context/local"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
import { ContentPart, PromptInput } from "@/components/prompt-input"
|
||||
@@ -21,7 +36,8 @@ import type { JSX } from "solid-js"
|
||||
import { Code } from "@/components/code"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { Diff } from "@/components/diff"
|
||||
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
||||
import { Markdown } from "@opencode-ai/ui"
|
||||
|
||||
export default function Page() {
|
||||
const local = useLocal()
|
||||
@@ -92,7 +108,7 @@ export default function Page() {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key.length === 1 && event.key !== "Unidentified") {
|
||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||
inputRef?.focus()
|
||||
}
|
||||
}
|
||||
@@ -164,6 +180,8 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const handleDiffTriggerClick = (event: MouseEvent) => {
|
||||
// disabling scroll to diff for now
|
||||
return
|
||||
const target = event.currentTarget as HTMLElement
|
||||
queueMicrotask(() => {
|
||||
if (target.getAttribute("aria-expanded") !== "true") return
|
||||
@@ -182,42 +200,11 @@ export default function Page() {
|
||||
}
|
||||
if (!session) return
|
||||
|
||||
interface SubmissionAttachment {
|
||||
path: string
|
||||
selection?: TextSelection
|
||||
label: string
|
||||
}
|
||||
|
||||
const createAttachmentKey = (path: string, selection?: TextSelection) => {
|
||||
if (!selection) return path
|
||||
return `${path}:${selection.startLine}:${selection.startChar}:${selection.endLine}:${selection.endChar}`
|
||||
}
|
||||
|
||||
const formatAttachmentLabel = (path: string, selection?: TextSelection) => {
|
||||
if (!selection) return getFilename(path)
|
||||
return `${getFilename(path)} (${selection.startLine}-${selection.endLine})`
|
||||
}
|
||||
|
||||
local.session.setActive(session.id)
|
||||
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
||||
|
||||
const text = parts.map((part) => part.content).join("")
|
||||
const attachments = new Map<string, SubmissionAttachment>()
|
||||
|
||||
const registerAttachment = (path: string, selection: TextSelection | undefined, label?: string) => {
|
||||
if (!path) return
|
||||
const key = createAttachmentKey(path, selection)
|
||||
if (attachments.has(key)) return
|
||||
attachments.set(key, {
|
||||
path,
|
||||
selection,
|
||||
label: label ?? formatAttachmentLabel(path, selection),
|
||||
})
|
||||
}
|
||||
|
||||
const promptAttachments = parts.filter((part) => part.type === "file")
|
||||
for (const part of promptAttachments) {
|
||||
registerAttachment(part.path, part.selection, part.content)
|
||||
}
|
||||
const attachments = parts.filter((part) => part.type === "file")
|
||||
|
||||
// const activeFile = local.context.active()
|
||||
// if (activeFile) {
|
||||
@@ -236,7 +223,7 @@ export default function Page() {
|
||||
// )
|
||||
// }
|
||||
|
||||
const attachmentParts = Array.from(attachments.values()).map((attachment) => {
|
||||
const attachmentParts = attachments.map((attachment) => {
|
||||
const absolute = toAbsolutePath(attachment.path)
|
||||
const query = attachment.selection
|
||||
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
||||
@@ -249,9 +236,9 @@ export default function Page() {
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: `@${attachment.label}`,
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
path: absolute,
|
||||
},
|
||||
@@ -275,7 +262,6 @@ export default function Page() {
|
||||
],
|
||||
},
|
||||
})
|
||||
local.session.setActive(session.id)
|
||||
}
|
||||
|
||||
const handleNewSession = () => {
|
||||
@@ -286,7 +272,7 @@ export default function Page() {
|
||||
const TabVisual = (props: { file: LocalFile }): JSX.Element => {
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<FileIcon node={props.file} class="_grayscale-100" />
|
||||
<FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" />
|
||||
<span
|
||||
classList={{
|
||||
"text-14-medium": true,
|
||||
@@ -325,15 +311,21 @@ export default function Page() {
|
||||
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
||||
<Tooltip value={props.file.path} placement="bottom" class="h-full">
|
||||
<div class="relative h-full">
|
||||
<Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}>
|
||||
<Tabs.Trigger
|
||||
value={props.file.path}
|
||||
class="group/tab pl-3 pr-1"
|
||||
onClick={() => props.onTabClick(props.file)}
|
||||
>
|
||||
<TabVisual file={props.file} />
|
||||
<IconButton
|
||||
icon="close"
|
||||
class="mt-0.5 opacity-0 text-text-muted/60 group-data-[selected]/tab:opacity-100
|
||||
group-data-[selected]/tab:text-text group-data-[selected]/tab:hover:bg-border-subtle
|
||||
hover:opacity-100 group-hover/tab:opacity-100"
|
||||
variant="ghost"
|
||||
onClick={() => props.onTabClose(props.file)}
|
||||
/>
|
||||
</Tabs.Trigger>
|
||||
<IconButton
|
||||
icon="close"
|
||||
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
|
||||
variant="ghost"
|
||||
onClick={() => props.onTabClose(props.file)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -392,9 +384,7 @@ export default function Page() {
|
||||
{(session) => {
|
||||
const diffs = createMemo(() => session.summary?.diffs ?? [])
|
||||
const filesChanged = createMemo(() => diffs().length)
|
||||
const additions = createMemo(() => diffs().reduce((acc, diff) => (acc ?? 0) + (diff.additions ?? 0), 0))
|
||||
const deletions = createMemo(() => diffs().reduce((acc, diff) => (acc ?? 0) + (diff.deletions ?? 0), 0))
|
||||
|
||||
const updated = DateTime.fromMillis(session.time.updated)
|
||||
return (
|
||||
<Tooltip placement="right" value={session.title}>
|
||||
<div>
|
||||
@@ -403,17 +393,19 @@ export default function Page() {
|
||||
{session.title}
|
||||
</span>
|
||||
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
|
||||
{DateTime.fromMillis(session.time.updated).toRelative()}
|
||||
{Math.abs(updated.diffNow().as("seconds")) < 60
|
||||
? "Now"
|
||||
: updated
|
||||
.toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
|
||||
?.replace(" ago", "")
|
||||
?.replace(/ days?/, "d")
|
||||
?.replace(" min.", "m")
|
||||
?.replace(" hr.", "h")}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center self-stretch">
|
||||
<span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span>
|
||||
<Show when={additions() || deletions()}>
|
||||
<div class="flex gap-2 justify-end items-center">
|
||||
<span class="text-12-mono text-right text-text-diff-add-base">{`+${additions()}`}</span>
|
||||
<span class="text-12-mono text-right text-text-diff-delete-base">{`-${deletions()}`}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<DiffChanges diff={diffs()} />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
@@ -434,13 +426,12 @@ export default function Page() {
|
||||
<Tabs onChange={handleTabChange}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="chat" class="flex gap-x-1.5 items-center">
|
||||
<Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
|
||||
<div>Chat</div>
|
||||
<Show when={local.session.active()}>
|
||||
<div class="flex flex-col h-4 px-2 -mr-2 justify-center items-center rounded-full bg-surface-base text-12-medium text-text-strong">
|
||||
{local.session.context()}%
|
||||
</div>
|
||||
</Show>
|
||||
<Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5">
|
||||
<ProgressCircle percentage={local.session.context() ?? 0} />
|
||||
<div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div>
|
||||
</Tooltip>
|
||||
</Tabs.Trigger>
|
||||
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
|
||||
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
|
||||
@@ -516,17 +507,17 @@ export default function Page() {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs.Content value="chat" class="select-text flex flex-col flex-1 min-h-0">
|
||||
<div class="p-6 pt-12 max-w-[904px] w-full mx-auto flex flex-col flex-1 min-h-0">
|
||||
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
|
||||
<div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0">
|
||||
<Show
|
||||
when={local.session.active()}
|
||||
fallback={
|
||||
<div class="flex flex-col pb-36 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
|
||||
<div class="flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
|
||||
<div class="text-20-medium text-text-weaker">New session</div>
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<Icon name="folder" size="small" />
|
||||
<div class="text-12-medium text-text-weak">
|
||||
{getDirectory(sync.data.path.directory)}/
|
||||
{getDirectory(sync.data.path.directory)}
|
||||
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -543,100 +534,104 @@ export default function Page() {
|
||||
}
|
||||
>
|
||||
{(activeSession) => (
|
||||
<div class="py-3 flex flex-col flex-1 min-h-0">
|
||||
<div class="flex items-start gap-8 flex-1 min-h-0">
|
||||
<div class="pt-3 flex flex-col flex-1 min-h-0">
|
||||
<div class="flex-1 min-h-0">
|
||||
<Show when={local.session.userMessages().length > 1}>
|
||||
<ul role="list" class="w-60 shrink-0 flex flex-col items-start gap-1">
|
||||
<ul
|
||||
role="list"
|
||||
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
|
||||
>
|
||||
<For each={local.session.userMessages()}>
|
||||
{(message) => (
|
||||
<li
|
||||
class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
|
||||
onClick={() => local.session.setActiveMessage(message.id)}
|
||||
>
|
||||
<div class="w-[18px] shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none">
|
||||
<g>
|
||||
<rect x="0" width="2" height="12" rx="1" fill="#CFCECD" />
|
||||
<rect x="4" width="2" height="12" rx="1" fill="#CFCECD" />
|
||||
<rect x="8" width="2" height="12" rx="1" fill="#CFCECD" />
|
||||
<rect x="12" width="2" height="12" rx="1" fill="#CFCECD" />
|
||||
<rect x="16" width="2" height="12" rx="1" fill="#CFCECD" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
data-active={local.session.activeMessage()?.id === message.id}
|
||||
classList={{
|
||||
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
|
||||
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
|
||||
}}
|
||||
{(message) => {
|
||||
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
||||
|
||||
return (
|
||||
<li
|
||||
class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
|
||||
onClick={() => local.session.setActiveMessage(message.id)}
|
||||
>
|
||||
{message.summary?.title ?? local.session.getMessageText(message)}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
<DiffChanges diff={diffs()} variant="bars" />
|
||||
<div
|
||||
data-active={local.session.activeMessage()?.id === message.id}
|
||||
classList={{
|
||||
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
|
||||
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
|
||||
}}
|
||||
>
|
||||
{message.summary?.title ?? local.session.getMessageText(message)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
|
||||
<div class="flex flex-col items-start gap-50 pb-[800px]">
|
||||
<div class="flex flex-col items-start gap-50 pb-50">
|
||||
<For each={local.session.userMessages()}>
|
||||
{(message) => {
|
||||
const title = createMemo(() => message.summary?.title)
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const parts = createMemo(() => sync.data.part[message.id])
|
||||
const prompt = createMemo(() => local.session.getMessageText(message))
|
||||
const title = createMemo(() => message.summary?.title)
|
||||
const summary = createMemo(() => message.summary?.body)
|
||||
const assistantMessages = createMemo(() => {
|
||||
return sync.data.message[activeSession().id]?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == message.id,
|
||||
) as AssistantMessageType[]
|
||||
})
|
||||
const working = createMemo(() => !summary())
|
||||
|
||||
return (
|
||||
<div
|
||||
data-message={message.id}
|
||||
class="flex flex-col items-start self-stretch gap-14 pt-1.5"
|
||||
class="flex flex-col items-start self-stretch gap-8 min-h-screen"
|
||||
>
|
||||
{/* Title */}
|
||||
<div class="flex flex-col items-start gap-2 self-stretch">
|
||||
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
|
||||
<h1 class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
|
||||
{title() ?? prompt()}
|
||||
</h1>
|
||||
<Show when={title}>
|
||||
<div class="text-12-regular text-text-base">{prompt()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={title}>
|
||||
<div class="-mt-8">
|
||||
<Message message={message} parts={parts()} />
|
||||
</div>
|
||||
</Show>
|
||||
{/* Summary */}
|
||||
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
||||
<Show when={summary}>
|
||||
<Show when={!working()}>
|
||||
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||
<h2 class="text-12-medium text-text-weak">Summary</h2>
|
||||
<div class="text-14-regular text-text-base self-stretch">{summary()}</div>
|
||||
<Show when={summary()}>
|
||||
<Markdown text={summary()!} />
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={message.summary?.diffs.length}>
|
||||
<Accordion class="w-full" multiple>
|
||||
<For each={message.summary?.diffs || []}>
|
||||
{(diff) => (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger onClick={handleDiffTriggerClick}>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="flex items-center justify-between w-full gap-5">
|
||||
<div class="grow flex items-center gap-5 min-w-0">
|
||||
<FileIcon
|
||||
node={{ path: diff.file, type: "file" }}
|
||||
class="shrink-0 size-4"
|
||||
/>
|
||||
<div class="flex">
|
||||
<div class="flex grow min-w-0">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span class="text-text-base">
|
||||
{getDirectory(diff.file)}/
|
||||
<span class="text-text-base truncate-start">
|
||||
{getDirectory(diff.file)}‎
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-text-strong">
|
||||
<span class="text-text-strong shrink-0">
|
||||
{getFilename(diff.file)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 items-center justify-end">
|
||||
<div class="flex gap-2 justify-end items-center">
|
||||
<span class="text-12-mono text-right text-text-diff-add-base">{`+${diff.additions}`}</span>
|
||||
<span class="text-12-mono text-right text-text-diff-delete-base">{`-${diff.deletions}`}</span>
|
||||
</div>
|
||||
<div class="shrink-0 flex gap-4 items-center justify-end">
|
||||
<DiffChanges diff={diff} />
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -658,13 +653,120 @@ export default function Page() {
|
||||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
</Show>
|
||||
</div>
|
||||
{/* Response */}
|
||||
<div data-todo="Response (Timeline)">
|
||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||
<h2 class="text-12-medium text-text-weak">Response</h2>
|
||||
</div>
|
||||
</Show>
|
||||
{/* Response */}
|
||||
<div class="w-full">
|
||||
<Switch>
|
||||
<Match when={working()}>
|
||||
{(_) => {
|
||||
const items = createMemo(() =>
|
||||
assistantMessages().flatMap((m) => sync.data.part[m.id]),
|
||||
)
|
||||
const finishedItems = createMemo(() =>
|
||||
items().filter(
|
||||
(p) =>
|
||||
(p?.type === "text" && p.time?.end) ||
|
||||
(p?.type === "reasoning" && p.time?.end) ||
|
||||
(p?.type === "tool" && p.state.status === "completed"),
|
||||
),
|
||||
)
|
||||
|
||||
const MINIMUM_DELAY = 800
|
||||
const [visibleCount, setVisibleCount] = createSignal(1)
|
||||
|
||||
createEffect(() => {
|
||||
const total = finishedItems().length
|
||||
if (total > visibleCount()) {
|
||||
const timer = setTimeout(() => {
|
||||
setVisibleCount((prev) => prev + 1)
|
||||
}, MINIMUM_DELAY)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
} else if (total < visibleCount()) {
|
||||
setVisibleCount(total)
|
||||
}
|
||||
})
|
||||
|
||||
const translateY = createMemo(() => {
|
||||
const total = visibleCount()
|
||||
if (total < 2) return "0px"
|
||||
return `-${(total - 2) * 48 - 8}px`
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
class="h-36 overflow-hidden pointer-events-none
|
||||
mask-alpha mask-y-from-66% mask-y-from-background-base mask-y-to-transparent"
|
||||
>
|
||||
<div
|
||||
class="w-full flex flex-col items-start self-stretch gap-2 py-10
|
||||
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{ transform: `translateY(${translateY()})` }}
|
||||
>
|
||||
<For each={finishedItems()}>
|
||||
{(part) => {
|
||||
const message = createMemo(() =>
|
||||
sync.data.message[part.sessionID].find(
|
||||
(m) => m.id === part.messageID,
|
||||
),
|
||||
)
|
||||
return (
|
||||
<div class="h-10 flex items-center w-full">
|
||||
<Switch>
|
||||
<Match when={part.type === "text" && part}>
|
||||
{(p) => (
|
||||
<div
|
||||
textContent={p().text}
|
||||
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={part.type === "reasoning" && part}>
|
||||
{(p) => <Part message={message()!} part={p()} />}
|
||||
</Match>
|
||||
<Match when={part.type === "tool" && part}>
|
||||
{(p) => <Part message={message()!} part={p()} />}
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Match>
|
||||
<Match when={!working()}>
|
||||
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
|
||||
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
|
||||
<div class="flex items-center gap-1 self-stretch">
|
||||
<div class="text-12-medium">
|
||||
<Switch>
|
||||
<Match when={expanded()}>Hide steps</Match>
|
||||
<Match when={!expanded()}>Show steps</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div class="w-full flex flex-col items-start self-stretch gap-3">
|
||||
<For each={assistantMessages()}>
|
||||
{(assistantMessage) => {
|
||||
const parts = createMemo(
|
||||
() => sync.data.part[assistantMessage.id],
|
||||
)
|
||||
return <Message message={assistantMessage} parts={parts()} />
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -699,21 +801,14 @@ export default function Page() {
|
||||
const draggedFile = local.file.node(id)
|
||||
if (!draggedFile) return null
|
||||
return (
|
||||
<div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent">
|
||||
<div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent">
|
||||
<TabVisual file={draggedFile} />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-x-0 px-6 max-w-[904px] flex flex-col justify-center items-center z-50 mx-auto": true,
|
||||
"bottom-8": true,
|
||||
// "bottom-8": !!local.session.active(),
|
||||
// "bottom-1/2 translate-y-1/2": !local.session.active(),
|
||||
}}
|
||||
>
|
||||
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
|
||||
<PromptInput
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
@@ -772,7 +867,7 @@ export default function Page() {
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular">
|
||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(i)}/
|
||||
{getDirectory(i)}
|
||||
</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
export function getFilename(path: string) {
|
||||
if (!path) return ""
|
||||
const trimmed = path.replace(/[\/]+$/, "")
|
||||
@@ -6,8 +8,10 @@ export function getFilename(path: string) {
|
||||
}
|
||||
|
||||
export function getDirectory(path: string) {
|
||||
const sync = useSync()
|
||||
const parts = path.split("/")
|
||||
return parts.slice(0, parts.length - 1).join("/")
|
||||
const dir = parts.slice(0, parts.length - 1).join("/")
|
||||
return dir ? sync.sanitize(dir + "/") : ""
|
||||
}
|
||||
|
||||
export function getFileExtension(path: string) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.15.17",
|
||||
"version": "0.15.30",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
@@ -20,6 +20,10 @@
|
||||
"@ai-sdk/amazon-bedrock": "2.2.10",
|
||||
"@ai-sdk/google-vertex": "3.0.16",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||
"@parcel/watcher-win32-x64": "2.5.1",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
#!/usr/bin/env bun
|
||||
import path from "path"
|
||||
const dir = new URL("..", import.meta.url).pathname
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
process.chdir(dir)
|
||||
import { $ } from "bun"
|
||||
|
||||
|
||||
@@ -40,16 +40,35 @@ for (const [name] of Object.entries(binaries)) {
|
||||
}
|
||||
await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${Script.channel}`
|
||||
|
||||
if (!Script.preview) {
|
||||
const major = Script.version.split(".")[0]
|
||||
const majorTag = `latest-${major}`
|
||||
for (const [name] of Object.entries(binaries)) {
|
||||
await $`cd dist/${name} && npm dist-tag add ${name}@${Script.version} ${majorTag}`
|
||||
}
|
||||
await $`cd ./dist/${pkg.name} && npm dist-tag add ${pkg.name}-ai@${Script.version} ${majorTag}`
|
||||
}
|
||||
|
||||
if (!Script.preview) {
|
||||
for (const key of Object.keys(binaries)) {
|
||||
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
|
||||
}
|
||||
|
||||
// Calculate SHA values
|
||||
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
|
||||
const [pkgver, _subver = ""] = Script.version.split(/(-.*)/, 2)
|
||||
|
||||
// arch
|
||||
const binaryPkgbuild = [
|
||||
@@ -57,7 +76,8 @@ if (!Script.preview) {
|
||||
"# Maintainer: adam",
|
||||
"",
|
||||
"pkgname='opencode-bin'",
|
||||
`pkgver=${Script.version.split("-")[0]}`,
|
||||
`pkgver=${pkgver}`,
|
||||
`_subver=${_subver}`,
|
||||
"options=('!debug' '!strip')",
|
||||
"pkgrel=1",
|
||||
"pkgdesc='The AI coding agent built for the terminal.'",
|
||||
@@ -68,10 +88,10 @@ if (!Script.preview) {
|
||||
"conflicts=('opencode')",
|
||||
"depends=('fzf' 'ripgrep')",
|
||||
"",
|
||||
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.zip::https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-arm64.zip")`,
|
||||
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.zip::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.zip")`,
|
||||
`sha256sums_aarch64=('${arm64Sha}')`,
|
||||
"",
|
||||
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.zip::https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-x64.zip")`,
|
||||
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.zip::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.zip")`,
|
||||
`sha256sums_x86_64=('${x64Sha}')`,
|
||||
"",
|
||||
"package() {",
|
||||
@@ -86,7 +106,8 @@ if (!Script.preview) {
|
||||
"# Maintainer: adam",
|
||||
"",
|
||||
"pkgname='opencode'",
|
||||
`pkgver=${Script.version.split("-")[0]}`,
|
||||
`pkgver=${pkgver}`,
|
||||
`_subver=${_subver}`,
|
||||
"options=('!debug' '!strip')",
|
||||
"pkgrel=1",
|
||||
"pkgdesc='The AI coding agent built for the terminal.'",
|
||||
@@ -98,7 +119,7 @@ if (!Script.preview) {
|
||||
"depends=('fzf' 'ripgrep')",
|
||||
"makedepends=('git' 'bun-bin' 'go')",
|
||||
"",
|
||||
`source=("opencode-\${pkgver}.tar.gz::https://github.com/sst/opencode/archive/v${Script.version}.tar.gz")`,
|
||||
`source=("opencode-\${pkgver}.tar.gz::https://github.com/sst/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`,
|
||||
`sha256sums=('SKIP')`,
|
||||
"",
|
||||
"build() {",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { z } from "zod/v4"
|
||||
import { z } from "zod"
|
||||
import { Config } from "../src/config/config"
|
||||
|
||||
const file = process.argv[2]
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import type {
|
||||
Agent as ACPAgent,
|
||||
AgentSideConnection,
|
||||
AuthenticateRequest,
|
||||
CancelNotification,
|
||||
InitializeRequest,
|
||||
LoadSessionRequest,
|
||||
NewSessionRequest,
|
||||
PermissionOption,
|
||||
PromptRequest,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
import {
|
||||
type Agent as ACPAgent,
|
||||
type AgentSideConnection,
|
||||
type AuthenticateRequest,
|
||||
type CancelNotification,
|
||||
type InitializeRequest,
|
||||
type LoadSessionRequest,
|
||||
type NewSessionRequest,
|
||||
type PermissionOption,
|
||||
type PlanEntry,
|
||||
type PromptRequest,
|
||||
type SetSessionModelRequest,
|
||||
type SetSessionModeRequest,
|
||||
type SetSessionModeResponse,
|
||||
type ToolCallContent,
|
||||
type ToolKind,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { Log } from "../util/log"
|
||||
import { ACPSessionManager } from "./session"
|
||||
@@ -25,24 +28,17 @@ import { Storage } from "@/storage/storage"
|
||||
import { Command } from "@/command"
|
||||
import { Agent as Agents } from "@/agent/agent"
|
||||
import { Permission } from "@/permission"
|
||||
import { Session } from "@/session"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import type { Config } from "@/config/config"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { z } from "zod"
|
||||
|
||||
export namespace ACP {
|
||||
const log = Log.create({ service: "acp-agent" })
|
||||
|
||||
// TODO: mcp servers?
|
||||
|
||||
type ToolKind =
|
||||
| "read"
|
||||
| "edit"
|
||||
| "delete"
|
||||
| "move"
|
||||
| "search"
|
||||
| "execute"
|
||||
| "think"
|
||||
| "fetch"
|
||||
| "switch_mode"
|
||||
| "other"
|
||||
|
||||
export class Agent implements ACPAgent {
|
||||
private sessionManager = new ACPSessionManager()
|
||||
private connection: AgentSideConnection
|
||||
@@ -157,6 +153,62 @@ export namespace ACP {
|
||||
})
|
||||
break
|
||||
case "completed":
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.output,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (kind === "edit") {
|
||||
const input = part.state.input
|
||||
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
|
||||
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
|
||||
const newText =
|
||||
typeof input["newString"] === "string"
|
||||
? input["newString"]
|
||||
: typeof input["content"] === "string"
|
||||
? input["content"]
|
||||
: ""
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText,
|
||||
newText,
|
||||
})
|
||||
}
|
||||
|
||||
if (part.tool === "todowrite") {
|
||||
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
|
||||
if (parsedTodos.success) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId: acpSession.id,
|
||||
update: {
|
||||
sessionUpdate: "plan",
|
||||
entries: parsedTodos.data.map((todo) => {
|
||||
const status: PlanEntry["status"] =
|
||||
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
|
||||
return {
|
||||
priority: "medium",
|
||||
status,
|
||||
content: todo.content,
|
||||
}
|
||||
}),
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send session update for todo", { error: err })
|
||||
})
|
||||
} else {
|
||||
log.error("failed to parse todo output", { error: parsedTodos.error })
|
||||
}
|
||||
}
|
||||
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId: acpSession.id,
|
||||
@@ -164,15 +216,8 @@ export namespace ACP {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "completed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.output,
|
||||
},
|
||||
},
|
||||
],
|
||||
kind,
|
||||
content,
|
||||
title: part.state.title,
|
||||
rawOutput: {
|
||||
output: part.state.output,
|
||||
@@ -258,11 +303,14 @@ export namespace ACP {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
// TODO: map acp mcp
|
||||
// mcpCapabilities: {
|
||||
// http: true,
|
||||
// sse: true,
|
||||
// },
|
||||
mcpCapabilities: {
|
||||
http: true,
|
||||
sse: true,
|
||||
},
|
||||
promptCapabilities: {
|
||||
embeddedContext: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
authMethods: [
|
||||
{
|
||||
@@ -287,6 +335,7 @@ export namespace ACP {
|
||||
const model = await defaultModel(this.config)
|
||||
const session = await this.sessionManager.create(params.cwd, params.mcpServers, model)
|
||||
|
||||
log.info("creating_session", { mcpServers: params.mcpServers.length })
|
||||
const load = await this.loadSession({
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers,
|
||||
@@ -325,6 +374,17 @@ export namespace ACP {
|
||||
name: command.name,
|
||||
description: command.description ?? "",
|
||||
}))
|
||||
const names = new Set(availableCommands.map((c) => c.name))
|
||||
if (!names.has("init"))
|
||||
availableCommands.push({
|
||||
name: "init",
|
||||
description: "create/update a AGENTS.md",
|
||||
})
|
||||
if (!names.has("compact"))
|
||||
availableCommands.push({
|
||||
name: "compact",
|
||||
description: "compact the session",
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
this.connection.sessionUpdate({
|
||||
@@ -346,6 +406,35 @@ export namespace ACP {
|
||||
|
||||
const currentModeId = availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id
|
||||
|
||||
const mcpServers: Record<string, Config.Mcp> = {}
|
||||
for (const server of params.mcpServers) {
|
||||
if ("type" in server) {
|
||||
mcpServers[server.name] = {
|
||||
url: server.url,
|
||||
headers: server.headers.reduce<Record<string, string>>((acc, { name, value }) => {
|
||||
acc[name] = value
|
||||
return acc
|
||||
}, {}),
|
||||
type: "remote",
|
||||
}
|
||||
} else {
|
||||
mcpServers[server.name] = {
|
||||
type: "local",
|
||||
command: [server.command, ...server.args],
|
||||
environment: server.env.reduce<Record<string, string>>((acc, { name, value }) => {
|
||||
acc[name] = value
|
||||
return acc
|
||||
}, {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(mcpServers).map(async ([key, mcp]) => {
|
||||
await MCP.add(key, mcp)
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
models: {
|
||||
@@ -452,25 +541,25 @@ export namespace ACP {
|
||||
|
||||
log.info("parts", { parts })
|
||||
|
||||
const cmd = await (async () => {
|
||||
const text = parts.filter((part) => part.type === "text").join("")
|
||||
const match = text.match(/^\/(\w+)\s*(.*)$/)
|
||||
if (!match) return
|
||||
const cmd = (() => {
|
||||
const text = parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("")
|
||||
.trim()
|
||||
|
||||
const [c, args] = match.slice(1)
|
||||
const command = await Command.get(c)
|
||||
if (!command) return
|
||||
return { command, args }
|
||||
if (!text.startsWith("/")) return
|
||||
|
||||
const [name, ...rest] = text.slice(1).split(/\s+/)
|
||||
return { name, args: rest.join(" ").trim() }
|
||||
})()
|
||||
|
||||
if (cmd) {
|
||||
await SessionPrompt.command({
|
||||
sessionID,
|
||||
command: cmd.command.name,
|
||||
arguments: cmd.args,
|
||||
agent,
|
||||
})
|
||||
} else {
|
||||
const done = {
|
||||
stopReason: "end_turn" as const,
|
||||
_meta: {},
|
||||
}
|
||||
|
||||
if (!cmd) {
|
||||
await SessionPrompt.prompt({
|
||||
sessionID,
|
||||
model: {
|
||||
@@ -480,12 +569,40 @@ export namespace ACP {
|
||||
parts,
|
||||
agent,
|
||||
})
|
||||
return done
|
||||
}
|
||||
|
||||
return {
|
||||
stopReason: "end_turn" as const,
|
||||
_meta: {},
|
||||
const command = await Command.get(cmd.name)
|
||||
if (command) {
|
||||
await SessionPrompt.command({
|
||||
sessionID,
|
||||
command: command.name,
|
||||
arguments: cmd.args,
|
||||
model: model.providerID + "/" + model.modelID,
|
||||
agent,
|
||||
})
|
||||
return done
|
||||
}
|
||||
|
||||
switch (cmd.name) {
|
||||
case "init":
|
||||
await Session.initialize({
|
||||
sessionID,
|
||||
messageID: Identifier.ascending("message"),
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
})
|
||||
break
|
||||
case "compact":
|
||||
await SessionCompaction.run({
|
||||
sessionID,
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
return done
|
||||
}
|
||||
|
||||
async cancel(params: CancelNotification) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Session } from "../session"
|
||||
import { Provider } from "../provider/provider"
|
||||
import type { ACPSessionState } from "./types"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Config } from "../config/config"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { generateObject, type ModelMessage } from "ai"
|
||||
import PROMPT_GENERATE from "./generate.txt"
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import z from "zod/v4"
|
||||
import { Auth } from "./index"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace AuthGithubCopilot {
|
||||
const CLIENT_ID = "Iv1.b507a08c87ecfe98"
|
||||
const DEVICE_CODE_URL = "https://github.com/login/device/code"
|
||||
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
||||
const COPILOT_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token"
|
||||
|
||||
interface DeviceCodeResponse {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
interface AccessTokenResponse {
|
||||
access_token?: string
|
||||
error?: string
|
||||
error_description?: string
|
||||
}
|
||||
|
||||
interface CopilotTokenResponse {
|
||||
token: string
|
||||
expires_at: number
|
||||
refresh_in: number
|
||||
endpoints: {
|
||||
api: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function authorize() {
|
||||
const deviceResponse = await fetch(DEVICE_CODE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: CLIENT_ID,
|
||||
scope: "read:user",
|
||||
}),
|
||||
})
|
||||
const deviceData: DeviceCodeResponse = await deviceResponse.json()
|
||||
return {
|
||||
device: deviceData.device_code,
|
||||
user: deviceData.user_code,
|
||||
verification: deviceData.verification_uri,
|
||||
interval: deviceData.interval || 5,
|
||||
expiry: deviceData.expires_in,
|
||||
}
|
||||
}
|
||||
|
||||
export async function poll(device_code: string) {
|
||||
const response = await fetch(ACCESS_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: CLIENT_ID,
|
||||
device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) return "failed"
|
||||
|
||||
const data: AccessTokenResponse = await response.json()
|
||||
|
||||
if (data.access_token) {
|
||||
// Store the GitHub OAuth token
|
||||
await Auth.set("github-copilot", {
|
||||
type: "oauth",
|
||||
refresh: data.access_token,
|
||||
access: "",
|
||||
expires: 0,
|
||||
})
|
||||
return "complete"
|
||||
}
|
||||
|
||||
if (data.error === "authorization_pending") return "pending"
|
||||
|
||||
if (data.error) return "failed"
|
||||
|
||||
return "pending"
|
||||
}
|
||||
|
||||
export async function access() {
|
||||
const info = await Auth.get("github-copilot")
|
||||
if (!info || info.type !== "oauth") return
|
||||
if (info.access && info.expires > Date.now()) return info.access
|
||||
|
||||
// Get new Copilot API token
|
||||
const response = await fetch(COPILOT_API_KEY_URL, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${info.refresh}`,
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
"Editor-Version": "vscode/1.99.3",
|
||||
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) return
|
||||
|
||||
const tokenData: CopilotTokenResponse = await response.json()
|
||||
|
||||
// Store the Copilot API token
|
||||
await Auth.set("github-copilot", {
|
||||
type: "oauth",
|
||||
refresh: info.refresh,
|
||||
access: tokenData.token,
|
||||
expires: tokenData.expires_at * 1000,
|
||||
})
|
||||
|
||||
return tokenData.token
|
||||
}
|
||||
|
||||
export const DeviceCodeError = NamedError.create("DeviceCodeError", z.object({}))
|
||||
|
||||
export const TokenExchangeError = NamedError.create(
|
||||
"TokenExchangeError",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const AuthenticationError = NamedError.create(
|
||||
"AuthenticationError",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const CopilotTokenError = NamedError.create(
|
||||
"CopilotTokenError",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Auth {
|
||||
export const Oauth = z
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import z from "zod/v4"
|
||||
import type { ZodType } from "zod/v4"
|
||||
import z from "zod"
|
||||
import type { ZodType } from "zod"
|
||||
import { Log } from "../util/log"
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
|
||||
@@ -6,9 +6,12 @@ export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
||||
directory,
|
||||
init: InstanceBootstrap,
|
||||
fn: async () => {
|
||||
const result = await cb()
|
||||
await Instance.dispose()
|
||||
return result
|
||||
try {
|
||||
const result = await cb()
|
||||
return result
|
||||
} finally {
|
||||
await Instance.dispose()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const AcpCommand = cmd({
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(Buffer.from(chunk), (err) => {
|
||||
process.stdout.write(chunk, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
@@ -56,7 +56,11 @@ export const AcpCommand = cmd({
|
||||
}, stream)
|
||||
|
||||
log.info("setup connection")
|
||||
process.stdin.resume()
|
||||
await new Promise((resolve, reject) => {
|
||||
process.stdin.on("end", resolve)
|
||||
process.stdin.on("error", reject)
|
||||
})
|
||||
})
|
||||
process.stdin.resume()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EOL } from "os"
|
||||
import { Config } from "../../../config/config"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
@@ -8,7 +9,7 @@ export const ConfigCommand = cmd({
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const config = await Config.get()
|
||||
console.log(JSON.stringify(config, null, 2))
|
||||
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ const FileSearchCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const results = await File.search({ query: args.query })
|
||||
console.log(results.join(EOL))
|
||||
process.stdout.write(results.join(EOL) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -30,7 +30,7 @@ const FileReadCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const content = await File.read(args.path)
|
||||
console.log(content)
|
||||
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -41,7 +41,7 @@ const FileStatusCommand = cmd({
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const status = await File.status()
|
||||
console.log(JSON.stringify(status, null, 2))
|
||||
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -57,7 +57,7 @@ const FileListCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const files = await File.list(args.path)
|
||||
console.log(JSON.stringify(files, null, 2))
|
||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { LSP } from "../../../lsp"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { Log } from "../../../util/log"
|
||||
import { EOL } from "os"
|
||||
|
||||
export const LSPCommand = cmd({
|
||||
command: "lsp",
|
||||
@@ -16,7 +17,7 @@ const DiagnosticsCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
await LSP.touchFile(args.file, true)
|
||||
console.log(JSON.stringify(await LSP.diagnostics(), null, 2))
|
||||
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -28,7 +29,7 @@ export const SymbolsCommand = cmd({
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
using _ = Log.Default.time("symbols")
|
||||
const results = await LSP.workspaceSymbol(args.query)
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -40,7 +41,7 @@ export const DocumentSymbolsCommand = cmd({
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
using _ = Log.Default.time("document-symbols")
|
||||
const results = await LSP.documentSymbol(args.uri)
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ const TreeCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
console.log(await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit }))
|
||||
process.stdout.write(await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit }) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -49,7 +49,7 @@ const FilesCommand = cmd({
|
||||
files.push(file)
|
||||
if (args.limit && files.length >= args.limit) break
|
||||
}
|
||||
console.log(files.join(EOL))
|
||||
process.stdout.write(files.join(EOL) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -78,6 +78,6 @@ const SearchCommand = cmd({
|
||||
glob: args.glob as string[] | undefined,
|
||||
limit: args.limit,
|
||||
})
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
},
|
||||
})
|
||||
|
||||