mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-19 13:14:32 +00:00
Compare commits
53 Commits
no-project
...
kit/effect
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c2b5b2c39 | ||
|
|
009d77c9d8 | ||
|
|
f3cf519d98 | ||
|
|
645c15351b | ||
|
|
f63a2a2636 | ||
|
|
84e62fc662 | ||
|
|
a7ea93528b | ||
|
|
d90e3a2833 | ||
|
|
1c74c2741a | ||
|
|
5d2f8d77f9 | ||
|
|
81be544981 | ||
|
|
773c1192dc | ||
|
|
5ddfe4ada5 | ||
|
|
a93d98bd94 | ||
|
|
54ed87d53c | ||
|
|
8ee939c741 | ||
|
|
1b0096bf61 | ||
|
|
8006c29db3 | ||
|
|
3f1c96a0bb | ||
|
|
3558deba4a | ||
|
|
c3ddc85cca | ||
|
|
a800583aea | ||
|
|
171e69c2fc | ||
|
|
822bb7b336 | ||
|
|
47cf267c23 | ||
|
|
976aae7e42 | ||
|
|
0ca51eebcf | ||
|
|
3256886e25 | ||
|
|
d2194f6dde | ||
|
|
bfd4787fcd | ||
|
|
58dce0148a | ||
|
|
79635b8b41 | ||
|
|
331dacf9db | ||
|
|
4ba7d3b406 | ||
|
|
a43783a6d4 | ||
|
|
37c5295111 | ||
|
|
56102ff642 | ||
|
|
1b86c27fb8 | ||
|
|
fe43bdb699 | ||
|
|
a849a17e93 | ||
|
|
0292f1b559 | ||
|
|
5dfe86dcb1 | ||
|
|
4b4dd2b882 | ||
|
|
bc949af623 | ||
|
|
9e7c136de7 | ||
|
|
fee3c196c5 | ||
|
|
6c047391bb | ||
|
|
350df0b261 | ||
|
|
fbabc97c4c | ||
|
|
7daea69e13 | ||
|
|
0772a95918 | ||
|
|
dadddc9c8c | ||
|
|
6708c3f6cf |
@@ -1,7 +1,7 @@
|
||||
---
|
||||
description: Translate content for a specified locale while preserving technical terms
|
||||
mode: subagent
|
||||
model: opencode/gemini-3-pro
|
||||
model: opencode/gemini-3.1-pro
|
||||
---
|
||||
|
||||
You are a professional translator and localization specialist.
|
||||
|
||||
21
bun.lock
21
bun.lock
@@ -46,7 +46,7 @@
|
||||
"@solidjs/router": "catalog:",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"luxon": "catalog:",
|
||||
@@ -227,7 +227,7 @@
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"effect": "catalog:",
|
||||
"electron-log": "^5",
|
||||
"electron-store": "^10",
|
||||
"electron-updater": "^6",
|
||||
@@ -324,7 +324,7 @@
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "4.0.0-beta.31",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
@@ -352,6 +352,7 @@
|
||||
"bun-pty": "0.4.8",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
@@ -401,6 +402,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
@@ -592,6 +594,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@effect/platform-node": "4.0.0-beta.35",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
@@ -615,7 +618,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"effect": "4.0.0-beta.35",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -973,9 +976,9 @@
|
||||
|
||||
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.31", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.31", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.31", "ioredis": "^5.7.0" } }, "sha512-KmVZwGsQRBMZZYPJwpL2vj6sxjBzfXhyA8RgsH5/cmckDTsZpVTyqODQ/FFzmCnMWuYjZoJGPghTDrVVDn/6ZA=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.35", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.35", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.35", "ioredis": "^5.7.0" } }, "sha512-HPc2xZASl9F9y/xJ01bQgFD6Jf9XP4Fcv/BlVTvG0Yr/uN63lwKZYr/VXor5K5krHfBDeCBD8y7/SICPYZoq3A=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.33", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.33" } }, "sha512-jaJnvYz1IiPZyN//fCJsvwnmujJS5KD8noCVVLhb4ZGCWKhQpt0x2iuax6HFzMlPEQSfl04GLU+PVKh0nkzPyA=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.35", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.35" } }, "sha512-9bPqNV988itKJ7MQoJuzmR014DB9EZRDOnhJt/+iJlb8qLoR9HnCzNJb9gfBdYhFmVYc8DMsQxG81rdJzpv9tg=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
@@ -2059,6 +2062,8 @@
|
||||
|
||||
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||
|
||||
"@types/cross-spawn": ["@types/cross-spawn@6.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
@@ -2747,7 +2752,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="],
|
||||
"effect": ["effect@4.0.0-beta.35", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-64j8dgJmoEMeq6Y3WLYcZIRqPZ5E/lqnULCf6QW5te3hQ/sa13UodWLGwBEviEqBoq72U8lArhVX+T7ntzhJGQ=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -5015,6 +5020,8 @@
|
||||
|
||||
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"@effect/platform-node/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
|
||||
|
||||
"@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||
|
||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||
|
||||
@@ -201,6 +201,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||
|
||||
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
|
||||
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
|
||||
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
|
||||
|
||||
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||
handler: "packages/console/function/src/log-processor.ts",
|
||||
link: [new sst.Secret("HONEYCOMB_API_KEY")],
|
||||
@@ -219,6 +223,9 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
EMAILOCTOPUS_API_KEY,
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
SALESFORCE_CLIENT_ID,
|
||||
SALESFORCE_CLIENT_SECRET,
|
||||
SALESFORCE_INSTANCE_URL,
|
||||
ZEN_BLACK_PRICE,
|
||||
ZEN_LITE_PRICE,
|
||||
new sst.Secret("ZEN_LIMITS"),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-VF3rXpIz9XbTTfM8YB98DJJOs4Sotaq5cSwIBUfbNDA=",
|
||||
"aarch64-linux": "sha256-cIE10+0xhb5u0TQedaDbEu6e40ypHnSBmh8unnhCDZE=",
|
||||
"aarch64-darwin": "sha256-d/l7g/4angRw/oxoSGpcYL0i9pNphgRChJwhva5Kypo=",
|
||||
"x86_64-darwin": "sha256-WQyuUKMfHpO1rpWsjhCXuG99iX2jEdSe3AVltxvt+1Y="
|
||||
"x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=",
|
||||
"aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=",
|
||||
"aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=",
|
||||
"x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"prepare": "husky",
|
||||
@@ -24,6 +25,7 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/platform-node": "4.0.0-beta.35",
|
||||
"@types/bun": "1.3.9",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
@@ -43,7 +45,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"effect": "4.0.0-beta.35",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
|
||||
@@ -3,8 +3,11 @@ import { serverNamePattern } from "../utils"
|
||||
|
||||
test("home renders and shows core entrypoints", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
const nav = page.locator('[data-component="sidebar-nav-desktop"]')
|
||||
|
||||
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
||||
await expect(nav.getByText("No projects open")).toBeVisible()
|
||||
await expect(nav.getByText("Open a project to get started")).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -7,12 +7,18 @@ test("shift+enter inserts a newline without submitting", async ({ page, gotoSess
|
||||
await expect(page).toHaveURL(/\/session\/?$/)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type("line one")
|
||||
await page.keyboard.press("Shift+Enter")
|
||||
await page.keyboard.type("line two")
|
||||
await prompt.focus()
|
||||
await expect(prompt).toBeFocused()
|
||||
|
||||
await prompt.pressSequentially("line one")
|
||||
await expect(prompt).toBeFocused()
|
||||
|
||||
await prompt.press("Shift+Enter")
|
||||
await expect(page).toHaveURL(/\/session\/?$/)
|
||||
await expect(prompt).toBeFocused()
|
||||
|
||||
await prompt.pressSequentially("line two")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/?$/)
|
||||
await expect(prompt).toContainText("line one")
|
||||
await expect(prompt).toContainText("line two")
|
||||
await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
|
||||
})
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"@solidjs/router": "catalog:",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"luxon": "catalog:",
|
||||
|
||||
@@ -274,12 +274,11 @@ export function SessionHeader() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
||||
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
||||
onClick={() => command.trigger("file.open")}
|
||||
aria-label={language.t("session.header.searchFiles")}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
|
||||
<Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
|
||||
<div class="flex min-w-0 flex-1 items-center overflow-visible">
|
||||
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
||||
{language.t("session.header.search.placeholder", {
|
||||
project: name(),
|
||||
|
||||
@@ -65,14 +65,11 @@ const debugTerminal = (...values: unknown[]) => {
|
||||
console.debug("[terminal]", ...values)
|
||||
}
|
||||
|
||||
const errorStatus = (err: unknown) => {
|
||||
const errorName = (err: unknown) => {
|
||||
if (!err || typeof err !== "object") return
|
||||
if (!("data" in err)) return
|
||||
const data = err.data
|
||||
if (!data || typeof data !== "object") return
|
||||
if (!("statusCode" in data)) return
|
||||
const status = data.statusCode
|
||||
return typeof status === "number" ? status : undefined
|
||||
if (!("name" in err)) return
|
||||
const errorName = err.name
|
||||
return typeof errorName === "string" ? errorName : undefined
|
||||
}
|
||||
|
||||
const useTerminalUiBindings = (input: {
|
||||
@@ -481,7 +478,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
.get({ ptyID: id })
|
||||
.then(() => false)
|
||||
.catch((err) => {
|
||||
if (errorStatus(err) === 404) return true
|
||||
if (errorName(err) === "NotFoundError") return true
|
||||
debugTerminal("failed to inspect terminal session", err)
|
||||
return false
|
||||
})
|
||||
|
||||
@@ -77,6 +77,7 @@ export function Titlebar() {
|
||||
|
||||
const canBack = createMemo(() => history.index > 0)
|
||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||
|
||||
const back = () => {
|
||||
const next = backPath(history)
|
||||
@@ -251,36 +252,38 @@ export function Titlebar() {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="flex items-center gap-0 transition-transform"
|
||||
classList={{
|
||||
"translate-x-0": !layout.sidebar.opened(),
|
||||
"-translate-x-[36px]": layout.sidebar.opened(),
|
||||
"duration-180 ease-out": !layout.sidebar.opened(),
|
||||
"duration-180 ease-in": layout.sidebar.opened(),
|
||||
}}
|
||||
>
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canBack()}
|
||||
onClick={back}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canForward()}
|
||||
onClick={forward}
|
||||
aria-label={language.t("common.goForward")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Show when={hasProjects()}>
|
||||
<div
|
||||
class="flex items-center gap-0 transition-transform"
|
||||
classList={{
|
||||
"translate-x-0": !layout.sidebar.opened(),
|
||||
"-translate-x-[36px]": layout.sidebar.opened(),
|
||||
"duration-180 ease-out": !layout.sidebar.opened(),
|
||||
"duration-180 ease-in": layout.sidebar.opened(),
|
||||
}}
|
||||
>
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canBack()}
|
||||
onClick={back}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||
disabled={!canForward()}
|
||||
onClick={forward}
|
||||
aria-label={language.t("common.goForward")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
@@ -238,9 +238,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
})
|
||||
const warnedDuplicates = new Set<string>()
|
||||
|
||||
type CommandCatalog = Record<string, CommandCatalogItem>
|
||||
const [catalog, setCatalog, _, catalogReady] = persisted(
|
||||
Persist.global("command.catalog.v1"),
|
||||
createStore<Record<string, CommandCatalogItem>>({}),
|
||||
createStore<CommandCatalog>({}),
|
||||
)
|
||||
|
||||
const bind = (id: string, def: KeybindConfig | undefined) => {
|
||||
@@ -259,7 +260,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
if (seen.has(opt.id)) {
|
||||
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
|
||||
warnedDuplicates.add(opt.id)
|
||||
console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
|
||||
console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -274,16 +275,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
createEffect(() => {
|
||||
if (!catalogReady()) return
|
||||
|
||||
for (const opt of registered()) {
|
||||
const id = actionId(opt.id)
|
||||
setCatalog(id, {
|
||||
title: opt.title,
|
||||
description: opt.description,
|
||||
category: opt.category,
|
||||
keybind: opt.keybind,
|
||||
slash: opt.slash,
|
||||
})
|
||||
}
|
||||
setCatalog(
|
||||
registered().reduce((acc, opt) => {
|
||||
const id = actionId(opt.id)
|
||||
acc[id] = {
|
||||
title: opt.title,
|
||||
description: opt.description,
|
||||
category: opt.category,
|
||||
keybind: opt.keybind,
|
||||
slash: opt.slash,
|
||||
}
|
||||
return acc
|
||||
}, {} as CommandCatalog),
|
||||
)
|
||||
})
|
||||
|
||||
const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
|
||||
|
||||
@@ -674,6 +674,8 @@ export const dict = {
|
||||
"sidebar.project.recentSessions": "Recent sessions",
|
||||
"sidebar.project.viewAllSessions": "View all sessions",
|
||||
"sidebar.project.clearNotifications": "Clear notifications",
|
||||
"sidebar.empty.title": "No projects open",
|
||||
"sidebar.empty.description": "Open a project to get started",
|
||||
|
||||
"debugBar.ariaLabel": "Development performance diagnostics",
|
||||
"debugBar.na": "n/a",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
batch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
For,
|
||||
on,
|
||||
onCleanup,
|
||||
@@ -277,16 +278,6 @@ export default function Layout(props: ParentProps) {
|
||||
setHoverProject(undefined)
|
||||
})
|
||||
|
||||
const autoselecting = createMemo(() => {
|
||||
if (params.dir) return false
|
||||
if (!state.autoselect) return false
|
||||
if (!pageReady()) return true
|
||||
if (!layoutReady()) return true
|
||||
const list = layout.projects.list()
|
||||
if (list.length > 0) return true
|
||||
return !!server.projects.last()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!state.autoselect) return
|
||||
const dir = params.dir
|
||||
@@ -572,33 +563,23 @@ export default function Layout(props: ParentProps) {
|
||||
return projects.find((p) => p.worktree === root)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
|
||||
(value) => {
|
||||
if (!value.ready) return
|
||||
if (!value.layoutReady) return
|
||||
if (!state.autoselect) return
|
||||
if (value.dir) return
|
||||
const [autoselecting] = createResource(async () => {
|
||||
await ready.promise
|
||||
await layout.ready.promise
|
||||
if (!untrack(() => state.autoselect)) return
|
||||
|
||||
const last = server.projects.last()
|
||||
const list = layout.projects.list()
|
||||
const last = server.projects.last()
|
||||
|
||||
if (value.list.length === 0) {
|
||||
if (!last) return
|
||||
setState("autoselect", false)
|
||||
openProject(last, false)
|
||||
navigateToProject(last)
|
||||
return
|
||||
}
|
||||
|
||||
const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
|
||||
if (!next) return
|
||||
setState("autoselect", false)
|
||||
openProject(next.worktree, false)
|
||||
navigateToProject(next.worktree)
|
||||
},
|
||||
),
|
||||
)
|
||||
if (list.length === 0) {
|
||||
if (!last) return
|
||||
await openProject(last, true)
|
||||
} else {
|
||||
const next = list.find((project) => project.worktree === last) ?? list[0]
|
||||
if (!next) return
|
||||
await openProject(next.worktree, true)
|
||||
}
|
||||
})
|
||||
|
||||
const workspaceName = (directory: string, projectId?: string, branch?: string) => {
|
||||
const key = workspaceKey(directory)
|
||||
@@ -1311,7 +1292,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
function openProject(directory: string, navigate = true) {
|
||||
layout.projects.open(directory)
|
||||
if (navigate) navigateToProject(directory)
|
||||
if (navigate) return navigateToProject(directory)
|
||||
}
|
||||
|
||||
const handleDeepLinks = (urls: string[]) => {
|
||||
@@ -1959,6 +1940,7 @@ export default function Layout(props: ParentProps) {
|
||||
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
||||
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
|
||||
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
|
||||
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
|
||||
const projectName = createMemo(() => {
|
||||
const item = project()
|
||||
if (!item) return ""
|
||||
@@ -2011,7 +1993,26 @@ export default function Layout(props: ParentProps) {
|
||||
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
|
||||
}}
|
||||
>
|
||||
<Show when={project()}>
|
||||
<Show
|
||||
when={project()}
|
||||
fallback={
|
||||
<Show when={empty()}>
|
||||
<div class="flex-1 min-h-0 -mt-4 flex items-center justify-center px-6 pb-64 text-center">
|
||||
<div class="mt-8 flex max-w-60 flex-col items-center gap-6 text-center">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("sidebar.empty.title")}</div>
|
||||
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||
{language.t("sidebar.empty.description")}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="large" icon="folder-add-left" onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<div class="shrink-0 pl-1 py-1">
|
||||
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
|
||||
@@ -2260,13 +2261,7 @@ export default function Layout(props: ParentProps) {
|
||||
helpLabel={() => language.t("sidebar.help")}
|
||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() =>
|
||||
mobile ? (
|
||||
<SidebarPanel project={currentProject} mobile />
|
||||
) : (
|
||||
<Show when={currentProject()}>
|
||||
<SidebarPanel project={currentProject} merged />
|
||||
</Show>
|
||||
)
|
||||
mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
|
||||
}
|
||||
/>
|
||||
)
|
||||
@@ -2367,7 +2362,7 @@ export default function Layout(props: ParentProps) {
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
|
||||
@@ -5,7 +5,12 @@ import { createResource, type Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
|
||||
type InitType = Promise<string> | string | null
|
||||
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
|
||||
type PersistedWithReady<T> = [
|
||||
Store<T>,
|
||||
SetStoreFunction<T>,
|
||||
InitType,
|
||||
Accessor<boolean> & { promise: undefined | Promise<any> },
|
||||
]
|
||||
|
||||
type PersistTarget = {
|
||||
storage?: string
|
||||
@@ -460,5 +465,12 @@ export function persisted<T>(
|
||||
{ initialValue: !isAsync },
|
||||
)
|
||||
|
||||
return [state, setState, init, () => ready() === true]
|
||||
return [
|
||||
state,
|
||||
setState,
|
||||
init,
|
||||
Object.assign(() => ready() === true, {
|
||||
promise: init instanceof Promise ? init : undefined,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -191,6 +191,33 @@ export function IconGemini(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function IconXiaomi(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C8.016 0 4.756.255 2.493 2.516.23 4.776 0 8.033 0 12.012c0 3.98.23 7.235 2.494 9.497C4.757 23.77 8.017 24 12 24c3.983 0 7.243-.23 9.506-2.491C23.77 19.247 24 15.99 24 12.012c0-3.984-.233-7.243-2.502-9.504C19.234.252 15.978 0 12 0zM4.906 7.405h5.624c1.47 0 3.007.068 3.764.827.746.746.827 2.233.83 3.676v4.54a.15.15 0 0 1-.152.147h-1.947a.15.15 0 0 1-.152-.148V11.83c-.002-.806-.048-1.634-.464-2.051-.358-.36-1.026-.441-1.72-.458H7.158a.15.15 0 0 0-.151.147v6.98a.15.15 0 0 1-.152.148H4.906a.15.15 0 0 1-.15-.148V7.554a.15.15 0 0 1 .15-.149zm12.131 0h1.949a.15.15 0 0 1 .15.15v8.892a.15.15 0 0 1-.15.148h-1.949a.15.15 0 0 1-.151-.148V7.554a.15.15 0 0 1 .151-.149zM8.92 10.948h2.046c.083 0 .15.066.15.147v5.352a.15.15 0 0 1-.15.148H8.92a.15.15 0 0 1-.152-.148v-5.352a.15.15 0 0 1 .152-.147Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconNvidia(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8.948 8.798v-1.43a6.7 6.7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851c-.398 0-.787-.062-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6.016 6.016 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964c-.37 0-.733-.035-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconArcee(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37 32" fill="currentColor">
|
||||
<path d="M20.4062 3.66602L4.24121 31.5928H0L18.2881 0L20.4062 3.66602Z" />
|
||||
<path d="M25.8838 13.1553L11.0752 31.5928H6.36719L23.9131 9.74512L25.8838 13.1553Z" />
|
||||
<path d="M36.5352 31.5928H21.6611L34.6191 28.2783L36.5352 31.5928Z" />
|
||||
<path d="M31.2627 22.4648L19.1699 31.5898H13.0762L29.4131 19.2617L31.2627 22.4648Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18" fill="none">
|
||||
|
||||
@@ -249,7 +249,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
|
||||
"go.meta.description":
|
||||
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5 و Kimi K2.5 و MiniMax M2.5.",
|
||||
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5 و Kimi K2.5 و MiniMax M2.5 وMiniMax M2.7.",
|
||||
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
|
||||
"go.hero.body":
|
||||
"يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.",
|
||||
@@ -297,7 +297,7 @@ export const dict = {
|
||||
"go.problem.item1": "أسعار اشتراك منخفضة التكلفة",
|
||||
"go.problem.item2": "حدود سخية ووصول موثوق",
|
||||
"go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين",
|
||||
"go.problem.item4": "يتضمن GLM-5 وKimi K2.5 وMiniMax M2.5",
|
||||
"go.problem.item4": "يتضمن GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7",
|
||||
"go.how.title": "كيف يعمل Go",
|
||||
"go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.",
|
||||
"go.how.step1.title": "أنشئ حسابًا",
|
||||
@@ -318,10 +318,10 @@ export const dict = {
|
||||
"go.faq.q1": "ما هو OpenCode Go؟",
|
||||
"go.faq.a1": "Go هو اشتراك منخفض التكلفة يمنحك وصولًا موثوقًا إلى نماذج مفتوحة المصدر قادرة على البرمجة الوكيلة.",
|
||||
"go.faq.q2": "ما النماذج التي يتضمنها Go؟",
|
||||
"go.faq.a2": "يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5، مع حدود سخية ووصول موثوق.",
|
||||
"go.faq.a2": "يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7، مع حدود سخية ووصول موثوق.",
|
||||
"go.faq.q3": "هل Go هو نفسه Zen؟",
|
||||
"go.faq.a3":
|
||||
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5 و Kimi K2.5 و MiniMax M2.5.",
|
||||
"لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5 و Kimi K2.5 و MiniMax M2.5 وMiniMax M2.7.",
|
||||
"go.faq.q4": "كم تكلفة Go؟",
|
||||
"go.faq.a4.p1.beforePricing": "تكلفة Go",
|
||||
"go.faq.a4.p1.pricingLink": "$5 للشهر الأول",
|
||||
@@ -345,7 +345,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟",
|
||||
"go.faq.a9":
|
||||
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5 مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
|
||||
"تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5 وKimi K2.5 وMiniMax M2.5 وMiniMax M2.7 مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
"zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",
|
||||
@@ -688,8 +688,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "جيف بيزوس",
|
||||
"enterprise.form.role.label": "المنصب",
|
||||
"enterprise.form.role.placeholder": "رئيس مجلس الإدارة التنفيذي",
|
||||
"enterprise.form.company.label": "الشركة",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "البريد الإلكتروني للشركة",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "رقم الهاتف",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "ما المشكلة التي تحاول حلها؟",
|
||||
"enterprise.form.message.placeholder": "نحتاج مساعدة في...",
|
||||
"enterprise.form.send": "إرسال",
|
||||
|
||||
@@ -253,7 +253,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
|
||||
"go.meta.description":
|
||||
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5, Kimi K2.5 e MiniMax M2.5.",
|
||||
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
|
||||
"go.hero.title": "Modelos de codificação de baixo custo para todos",
|
||||
"go.hero.body":
|
||||
"O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.",
|
||||
@@ -302,7 +302,7 @@ export const dict = {
|
||||
"go.problem.item1": "Preço de assinatura de baixo custo",
|
||||
"go.problem.item2": "Limites generosos e acesso confiável",
|
||||
"go.problem.item3": "Feito para o maior número possível de programadores",
|
||||
"go.problem.item4": "Inclui GLM-5, Kimi K2.5 e MiniMax M2.5",
|
||||
"go.problem.item4": "Inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7",
|
||||
"go.how.title": "Como o Go funciona",
|
||||
"go.how.body":
|
||||
"O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.",
|
||||
@@ -325,10 +325,10 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go é uma assinatura de baixo custo que oferece acesso confiável a modelos de código aberto capazes para codificação com agentes.",
|
||||
"go.faq.q2": "Quais modelos o Go inclui?",
|
||||
"go.faq.a2": "Go inclui GLM-5, Kimi K2.5 e MiniMax M2.5, com limites generosos e acesso confiável.",
|
||||
"go.faq.a2": "Go inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7, com limites generosos e acesso confiável.",
|
||||
"go.faq.q3": "O Go é o mesmo que o Zen?",
|
||||
"go.faq.a3":
|
||||
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5, Kimi K2.5 e MiniMax M2.5.",
|
||||
"Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
|
||||
"go.faq.q4": "Quanto custa o Go?",
|
||||
"go.faq.a4.p1.beforePricing": "O Go custa",
|
||||
"go.faq.a4.p1.pricingLink": "$5 no primeiro mês",
|
||||
@@ -353,7 +353,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?",
|
||||
"go.faq.a9":
|
||||
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5, Kimi K2.5 e MiniMax M2.5 com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
|
||||
"Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7 com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
|
||||
"zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",
|
||||
@@ -700,8 +700,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Cargo",
|
||||
"enterprise.form.role.placeholder": "Presidente Executivo",
|
||||
"enterprise.form.company.label": "Empresa",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "E-mail corporativo",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Telefone",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Qual problema você está tentando resolver?",
|
||||
"enterprise.form.message.placeholder": "Precisamos de ajuda com...",
|
||||
"enterprise.form.send": "Enviar",
|
||||
|
||||
@@ -251,7 +251,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
|
||||
"go.meta.description":
|
||||
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5, Kimi K2.5 og MiniMax M2.5.",
|
||||
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
|
||||
"go.hero.title": "Kodningsmodeller til lav pris for alle",
|
||||
"go.hero.body":
|
||||
"Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.",
|
||||
@@ -299,7 +299,7 @@ export const dict = {
|
||||
"go.problem.item1": "Lavpris abonnementspriser",
|
||||
"go.problem.item2": "Generøse grænser og pålidelig adgang",
|
||||
"go.problem.item3": "Bygget til så mange programmører som muligt",
|
||||
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5",
|
||||
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7",
|
||||
"go.how.title": "Hvordan Go virker",
|
||||
"go.how.body":
|
||||
"Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.",
|
||||
@@ -322,10 +322,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go er et lavprisabonnement, der giver dig pålidelig adgang til kapable open source-modeller til agentisk kodning.",
|
||||
"go.faq.q2": "Hvilke modeller inkluderer Go?",
|
||||
"go.faq.a2": "Go inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5, med generøse grænser og pålidelig adgang.",
|
||||
"go.faq.a2":
|
||||
"Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7, med generøse grænser og pålidelig adgang.",
|
||||
"go.faq.q3": "Er Go det samme som Zen?",
|
||||
"go.faq.a3":
|
||||
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5, Kimi K2.5 og MiniMax M2.5.",
|
||||
"Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
|
||||
"go.faq.q4": "Hvad koster Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go koster",
|
||||
"go.faq.a4.p1.pricingLink": "$5 første måned",
|
||||
@@ -349,7 +350,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Hvad er forskellen på gratis modeller og Go?",
|
||||
"go.faq.a9":
|
||||
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5 med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
|
||||
"Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7 med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",
|
||||
@@ -694,8 +695,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Rolle",
|
||||
"enterprise.form.role.placeholder": "Bestyrelsesformand",
|
||||
"enterprise.form.company.label": "Virksomhed",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Firma-e-mail",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Telefonnummer",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Hvilket problem prøver du at løse?",
|
||||
"enterprise.form.message.placeholder": "Vi har brug for hjælp med...",
|
||||
"enterprise.form.send": "Send",
|
||||
|
||||
@@ -253,7 +253,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
|
||||
"go.meta.description":
|
||||
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5, Kimi K2.5 und MiniMax M2.5.",
|
||||
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7.",
|
||||
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
|
||||
"go.hero.body":
|
||||
"Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.",
|
||||
@@ -301,7 +301,7 @@ export const dict = {
|
||||
"go.problem.item1": "Kostengünstiges Abonnement",
|
||||
"go.problem.item2": "Großzügige Limits und zuverlässiger Zugang",
|
||||
"go.problem.item3": "Für so viele Programmierer wie möglich gebaut",
|
||||
"go.problem.item4": "Beinhaltet GLM-5, Kimi K2.5 und MiniMax M2.5",
|
||||
"go.problem.item4": "Beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7",
|
||||
"go.how.title": "Wie Go funktioniert",
|
||||
"go.how.body":
|
||||
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.",
|
||||
@@ -324,10 +324,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go ist ein kostengünstiges Abonnement, das dir zuverlässigen Zugang zu leistungsfähigen Open-Source-Modellen für Agentic Coding bietet.",
|
||||
"go.faq.q2": "Welche Modelle beinhaltet Go?",
|
||||
"go.faq.a2": "Go beinhaltet GLM-5, Kimi K2.5 und MiniMax M2.5, mit großzügigen Limits und zuverlässigem Zugang.",
|
||||
"go.faq.a2":
|
||||
"Go beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7, mit großzügigen Limits und zuverlässigem Zugang.",
|
||||
"go.faq.q3": "Ist Go dasselbe wie Zen?",
|
||||
"go.faq.a3":
|
||||
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5, Kimi K2.5 und MiniMax M2.5.",
|
||||
"Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7.",
|
||||
"go.faq.q4": "Wie viel kostet Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go kostet",
|
||||
"go.faq.a4.p1.pricingLink": "$5 im ersten Monat",
|
||||
@@ -352,7 +353,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?",
|
||||
"go.faq.a9":
|
||||
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5, Kimi K2.5 und MiniMax M2.5 mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
|
||||
"Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5, Kimi K2.5, MiniMax M2.5 und MiniMax M2.7 mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
|
||||
"zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",
|
||||
@@ -699,8 +700,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Rolle",
|
||||
"enterprise.form.role.placeholder": "Executive Chairman",
|
||||
"enterprise.form.company.label": "Unternehmen",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Firmen-E-Mail",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Telefonnummer",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Welches Problem versuchen Sie zu lösen?",
|
||||
"enterprise.form.message.placeholder": "Wir brauchen Hilfe bei...",
|
||||
"enterprise.form.send": "Senden",
|
||||
|
||||
@@ -248,7 +248,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Low cost coding models for everyone",
|
||||
"go.meta.description":
|
||||
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5, Kimi K2.5, and MiniMax M2.5.",
|
||||
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7.",
|
||||
"go.hero.title": "Low cost coding models for everyone",
|
||||
"go.hero.body":
|
||||
"Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.",
|
||||
@@ -295,7 +295,7 @@ export const dict = {
|
||||
"go.problem.item1": "Low cost subscription pricing",
|
||||
"go.problem.item2": "Generous limits and reliable access",
|
||||
"go.problem.item3": "Built for as many programmers as possible",
|
||||
"go.problem.item4": "Includes GLM-5, Kimi K2.5, and MiniMax M2.5",
|
||||
"go.problem.item4": "Includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7",
|
||||
"go.how.title": "How Go works",
|
||||
"go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.",
|
||||
"go.how.step1.title": "Create an account",
|
||||
@@ -317,10 +317,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go is a low-cost subscription that gives you reliable access to capable open-source models for agentic coding.",
|
||||
"go.faq.q2": "What models does Go include?",
|
||||
"go.faq.a2": "Go includes GLM-5, Kimi K2.5, and MiniMax M2.5, with generous limits and reliable access.",
|
||||
"go.faq.a2":
|
||||
"Go includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7, with generous limits and reliable access.",
|
||||
"go.faq.q3": "Is Go the same as Zen?",
|
||||
"go.faq.a3":
|
||||
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5, Kimi K2.5, and MiniMax M2.5.",
|
||||
"No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7.",
|
||||
"go.faq.q4": "How much does Go cost?",
|
||||
"go.faq.a4.p1.beforePricing": "Go costs",
|
||||
"go.faq.a4.p1.pricingLink": "$5 first month",
|
||||
@@ -344,7 +345,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "What is the difference between free models and Go?",
|
||||
"go.faq.a9":
|
||||
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5, Kimi K2.5, and MiniMax M2.5 with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
|
||||
"Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5, Kimi K2.5, MiniMax M2.5, and MiniMax M2.7 with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} not supported",
|
||||
@@ -689,8 +690,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Role",
|
||||
"enterprise.form.role.placeholder": "Executive Chairman",
|
||||
"enterprise.form.company.label": "Company",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Company email",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Phone number",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "What problem are you trying to solve?",
|
||||
"enterprise.form.message.placeholder": "We need help with...",
|
||||
"enterprise.form.send": "Send",
|
||||
|
||||
@@ -254,7 +254,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
|
||||
"go.meta.description":
|
||||
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5, Kimi K2.5 y MiniMax M2.5.",
|
||||
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7.",
|
||||
"go.hero.title": "Modelos de programación de bajo coste para todos",
|
||||
"go.hero.body":
|
||||
"Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.",
|
||||
@@ -303,7 +303,7 @@ export const dict = {
|
||||
"go.problem.item1": "Precios de suscripción de bajo coste",
|
||||
"go.problem.item2": "Límites generosos y acceso fiable",
|
||||
"go.problem.item3": "Creado para tantos programadores como sea posible",
|
||||
"go.problem.item4": "Incluye GLM-5, Kimi K2.5 y MiniMax M2.5",
|
||||
"go.problem.item4": "Incluye GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7",
|
||||
"go.how.title": "Cómo funciona Go",
|
||||
"go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.",
|
||||
"go.how.step1.title": "Crear una cuenta",
|
||||
@@ -325,10 +325,10 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go es una suscripción de bajo coste que te da acceso fiable a modelos de código abierto capaces para programación agéntica.",
|
||||
"go.faq.q2": "¿Qué modelos incluye Go?",
|
||||
"go.faq.a2": "Go incluye GLM-5, Kimi K2.5 y MiniMax M2.5, con límites generosos y acceso fiable.",
|
||||
"go.faq.a2": "Go incluye GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7, con límites generosos y acceso fiable.",
|
||||
"go.faq.q3": "¿Es Go lo mismo que Zen?",
|
||||
"go.faq.a3":
|
||||
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5, Kimi K2.5 y MiniMax M2.5.",
|
||||
"No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7.",
|
||||
"go.faq.q4": "¿Cuánto cuesta Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go cuesta",
|
||||
"go.faq.a4.p1.pricingLink": "$5 el primer mes",
|
||||
@@ -353,7 +353,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?",
|
||||
"go.faq.a9":
|
||||
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5, Kimi K2.5 y MiniMax M2.5 con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
|
||||
"Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5, Kimi K2.5, MiniMax M2.5 y MiniMax M2.7 con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.",
|
||||
"zen.api.error.modelNotSupported": "Modelo {{model}} no soportado",
|
||||
@@ -699,8 +699,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Rol",
|
||||
"enterprise.form.role.placeholder": "Presidente Ejecutivo",
|
||||
"enterprise.form.company.label": "Empresa",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Correo de empresa",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Teléfono",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "¿Qué problema estás intentando resolver?",
|
||||
"enterprise.form.message.placeholder": "Necesitamos ayuda con...",
|
||||
"enterprise.form.send": "Enviar",
|
||||
|
||||
@@ -255,7 +255,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Modèles de code à faible coût pour tous",
|
||||
"go.meta.description":
|
||||
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5, Kimi K2.5 et MiniMax M2.5.",
|
||||
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7.",
|
||||
"go.hero.title": "Modèles de code à faible coût pour tous",
|
||||
"go.hero.body":
|
||||
"Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.",
|
||||
@@ -303,7 +303,7 @@ export const dict = {
|
||||
"go.problem.item1": "Prix d'abonnement bas",
|
||||
"go.problem.item2": "Limites généreuses et accès fiable",
|
||||
"go.problem.item3": "Conçu pour autant de programmeurs que possible",
|
||||
"go.problem.item4": "Inclut GLM-5, Kimi K2.5 et MiniMax M2.5",
|
||||
"go.problem.item4": "Inclut GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7",
|
||||
"go.how.title": "Comment fonctionne Go",
|
||||
"go.how.body":
|
||||
"Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.",
|
||||
@@ -326,10 +326,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go est un abonnement à faible coût qui vous donne un accès fiable à des modèles open source performants pour le codage agentique.",
|
||||
"go.faq.q2": "Quels modèles Go inclut-il ?",
|
||||
"go.faq.a2": "Go inclut GLM-5, Kimi K2.5 et MiniMax M2.5, avec des limites généreuses et un accès fiable.",
|
||||
"go.faq.a2":
|
||||
"Go inclut GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7, avec des limites généreuses et un accès fiable.",
|
||||
"go.faq.q3": "Est-ce que Go est la même chose que Zen ?",
|
||||
"go.faq.a3":
|
||||
"Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5, Kimi K2.5 et MiniMax M2.5.",
|
||||
"Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7.",
|
||||
"go.faq.q4": "Combien coûte Go ?",
|
||||
"go.faq.a4.p1.beforePricing": "Go coûte",
|
||||
"go.faq.a4.p1.pricingLink": "$5 le premier mois",
|
||||
@@ -353,7 +354,7 @@ export const dict = {
|
||||
"Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.",
|
||||
"go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?",
|
||||
"go.faq.a9":
|
||||
"Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5, Kimi K2.5 et MiniMax M2.5 avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).",
|
||||
"Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5, Kimi K2.5, MiniMax M2.5 et MiniMax M2.7 avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.",
|
||||
"zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge",
|
||||
@@ -706,8 +707,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Poste",
|
||||
"enterprise.form.role.placeholder": "Président exécutif",
|
||||
"enterprise.form.company.label": "Entreprise",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "E-mail professionnel",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Téléphone",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Quel problème essayez-vous de résoudre ?",
|
||||
"enterprise.form.message.placeholder": "Nous avons besoin d'aide pour...",
|
||||
"enterprise.form.send": "Envoyer",
|
||||
|
||||
@@ -251,7 +251,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Modelli di coding a basso costo per tutti",
|
||||
"go.meta.description":
|
||||
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5, Kimi K2.5 e MiniMax M2.5.",
|
||||
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
|
||||
"go.hero.title": "Modelli di coding a basso costo per tutti",
|
||||
"go.hero.body":
|
||||
"Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.",
|
||||
@@ -299,7 +299,7 @@ export const dict = {
|
||||
"go.problem.item1": "Prezzo di abbonamento a basso costo",
|
||||
"go.problem.item2": "Limiti generosi e accesso affidabile",
|
||||
"go.problem.item3": "Costruito per il maggior numero possibile di programmatori",
|
||||
"go.problem.item4": "Include GLM-5, Kimi K2.5 e MiniMax M2.5",
|
||||
"go.problem.item4": "Include GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7",
|
||||
"go.how.title": "Come funziona Go",
|
||||
"go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.",
|
||||
"go.how.step1.title": "Crea un account",
|
||||
@@ -321,10 +321,10 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go è un abbonamento a basso costo che ti dà un accesso affidabile a modelli open source capaci per il coding agentico.",
|
||||
"go.faq.q2": "Quali modelli include Go?",
|
||||
"go.faq.a2": "Go include GLM-5, Kimi K2.5 e MiniMax M2.5, con limiti generosi e accesso affidabile.",
|
||||
"go.faq.a2": "Go include GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7, con limiti generosi e accesso affidabile.",
|
||||
"go.faq.q3": "Go è lo stesso di Zen?",
|
||||
"go.faq.a3":
|
||||
"No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5, Kimi K2.5 e MiniMax M2.5.",
|
||||
"No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7.",
|
||||
"go.faq.q4": "Quanto costa Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go costa",
|
||||
"go.faq.a4.p1.pricingLink": "$5 il primo mese",
|
||||
@@ -349,7 +349,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?",
|
||||
"go.faq.a9":
|
||||
"I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5, Kimi K2.5 e MiniMax M2.5 con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).",
|
||||
"I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5, Kimi K2.5, MiniMax M2.5 e MiniMax M2.7 con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.",
|
||||
"zen.api.error.modelNotSupported": "Modello {{model}} non supportato",
|
||||
@@ -696,8 +696,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Ruolo",
|
||||
"enterprise.form.role.placeholder": "Presidente Esecutivo",
|
||||
"enterprise.form.company.label": "Azienda",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Email aziendale",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Numero di telefono",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Quale problema stai cercando di risolvere?",
|
||||
"enterprise.form.message.placeholder": "Abbiamo bisogno di aiuto con...",
|
||||
"enterprise.form.send": "Invia",
|
||||
|
||||
@@ -250,7 +250,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル",
|
||||
"go.meta.description":
|
||||
"Goは最初の月$5、その後$10/月で、GLM-5、Kimi K2.5、MiniMax M2.5に対して5時間のゆとりあるリクエスト上限があります。",
|
||||
"Goは最初の月$5、その後$10/月で、GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7に対して5時間のゆとりあるリクエスト上限があります。",
|
||||
"go.hero.title": "すべての人のための低価格なコーディングモデル",
|
||||
"go.hero.body":
|
||||
"Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。",
|
||||
@@ -299,7 +299,7 @@ export const dict = {
|
||||
"go.problem.item1": "低価格なサブスクリプション料金",
|
||||
"go.problem.item2": "十分な制限と安定したアクセス",
|
||||
"go.problem.item3": "できるだけ多くのプログラマーのために構築",
|
||||
"go.problem.item4": "GLM-5、Kimi K2.5、MiniMax M2.5を含む",
|
||||
"go.problem.item4": "GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7を含む",
|
||||
"go.how.title": "Goの仕組み",
|
||||
"go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。",
|
||||
"go.how.step1.title": "アカウントを作成",
|
||||
@@ -321,10 +321,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Goは、エージェント型コーディングのための有能なオープンソースモデルへの安定したアクセスを提供する低価格なサブスクリプションです。",
|
||||
"go.faq.q2": "Goにはどのモデルが含まれますか?",
|
||||
"go.faq.a2": "Goには、GLM-5、Kimi K2.5、MiniMax M2.5が含まれており、十分な制限と安定したアクセスが提供されます。",
|
||||
"go.faq.a2":
|
||||
"Goには、GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7が含まれており、十分な制限と安定したアクセスが提供されます。",
|
||||
"go.faq.q3": "GoはZenと同じですか?",
|
||||
"go.faq.a3":
|
||||
"いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5、Kimi K2.5、MiniMax M2.5のオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。",
|
||||
"いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7のオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。",
|
||||
"go.faq.q4": "Goの料金は?",
|
||||
"go.faq.a4.p1.beforePricing": "Goは",
|
||||
"go.faq.a4.p1.pricingLink": "最初の月$5",
|
||||
@@ -349,7 +350,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "無料モデルとGoの違いは何ですか?",
|
||||
"go.faq.a9":
|
||||
"無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5、Kimi K2.5、MiniMax M2.5が含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。",
|
||||
"無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5、Kimi K2.5、MiniMax M2.5、MiniMax M2.7が含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。",
|
||||
"zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません",
|
||||
@@ -697,8 +698,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "ジェフ・ベゾス",
|
||||
"enterprise.form.role.label": "役職",
|
||||
"enterprise.form.role.placeholder": "会長",
|
||||
"enterprise.form.company.label": "会社名",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "会社メールアドレス",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "電話番号",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "どのような課題を解決したいですか?",
|
||||
"enterprise.form.message.placeholder": "これについて支援が必要です...",
|
||||
"enterprise.form.send": "送信",
|
||||
|
||||
@@ -247,7 +247,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델",
|
||||
"go.meta.description":
|
||||
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
|
||||
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
|
||||
"go.hero.title": "모두를 위한 저비용 코딩 모델",
|
||||
"go.hero.body":
|
||||
"Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.",
|
||||
@@ -296,7 +296,7 @@ export const dict = {
|
||||
"go.problem.item1": "저렴한 구독 가격",
|
||||
"go.problem.item2": "넉넉한 한도와 안정적인 액세스",
|
||||
"go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨",
|
||||
"go.problem.item4": "GLM-5, Kimi K2.5, MiniMax M2.5 포함",
|
||||
"go.problem.item4": "GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7 포함",
|
||||
"go.how.title": "Go 작동 방식",
|
||||
"go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.",
|
||||
"go.how.step1.title": "계정 생성",
|
||||
@@ -317,10 +317,11 @@ export const dict = {
|
||||
"go.faq.q1": "OpenCode Go란 무엇인가요?",
|
||||
"go.faq.a1": "Go는 에이전트 코딩을 위한 유능한 오픈 소스 모델에 대해 안정적인 액세스를 제공하는 저비용 구독입니다.",
|
||||
"go.faq.q2": "Go에는 어떤 모델이 포함되나요?",
|
||||
"go.faq.a2": "Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 GLM-5, Kimi K2.5, MiniMax M2.5가 포함됩니다.",
|
||||
"go.faq.a2":
|
||||
"Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7가 포함됩니다.",
|
||||
"go.faq.q3": "Go는 Zen과 같은가요?",
|
||||
"go.faq.a3":
|
||||
"아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.",
|
||||
"아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.",
|
||||
"go.faq.q4": "Go 비용은 얼마인가요?",
|
||||
"go.faq.a4.p1.beforePricing": "Go 비용은",
|
||||
"go.faq.a4.p1.pricingLink": "첫 달 $5",
|
||||
@@ -344,7 +345,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?",
|
||||
"go.faq.a9":
|
||||
"무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5, Kimi K2.5, MiniMax M2.5를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).",
|
||||
"무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5, Kimi K2.5, MiniMax M2.5, MiniMax M2.7를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.",
|
||||
"zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다",
|
||||
@@ -688,8 +689,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "홍길동",
|
||||
"enterprise.form.role.label": "직책",
|
||||
"enterprise.form.role.placeholder": "CTO / 개발 팀장",
|
||||
"enterprise.form.company.label": "회사",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "회사 이메일",
|
||||
"enterprise.form.email.placeholder": "name@company.com",
|
||||
"enterprise.form.phone.label": "전화번호",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "어떤 문제를 해결하고 싶으신가요?",
|
||||
"enterprise.form.message.placeholder": "도움이 필요한 부분은...",
|
||||
"enterprise.form.send": "전송",
|
||||
|
||||
@@ -251,7 +251,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Rimelige kodemodeller for alle",
|
||||
"go.meta.description":
|
||||
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5, Kimi K2.5 og MiniMax M2.5.",
|
||||
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
|
||||
"go.hero.title": "Rimelige kodemodeller for alle",
|
||||
"go.hero.body":
|
||||
"Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.",
|
||||
@@ -299,7 +299,7 @@ export const dict = {
|
||||
"go.problem.item1": "Rimelig abonnementspris",
|
||||
"go.problem.item2": "Rause grenser og pålitelig tilgang",
|
||||
"go.problem.item3": "Bygget for så mange programmerere som mulig",
|
||||
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5",
|
||||
"go.problem.item4": "Inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7",
|
||||
"go.how.title": "Hvordan Go fungerer",
|
||||
"go.how.body":
|
||||
"Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.",
|
||||
@@ -322,10 +322,10 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go er et rimelig abonnement som gir deg pålitelig tilgang til kapable åpen kildekode-modeller for agent-koding.",
|
||||
"go.faq.q2": "Hvilke modeller inkluderer Go?",
|
||||
"go.faq.a2": "Go inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5, med rause grenser og pålitelig tilgang.",
|
||||
"go.faq.a2": "Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7, med rause grenser og pålitelig tilgang.",
|
||||
"go.faq.q3": "Er Go det samme som Zen?",
|
||||
"go.faq.a3":
|
||||
"Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5, Kimi K2.5 og MiniMax M2.5.",
|
||||
"Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7.",
|
||||
"go.faq.q4": "Hva koster Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go koster",
|
||||
"go.faq.a4.p1.pricingLink": "$5 første måned",
|
||||
@@ -350,7 +350,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?",
|
||||
"go.faq.a9":
|
||||
"Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5, Kimi K2.5 og MiniMax M2.5 med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).",
|
||||
"Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5, Kimi K2.5, MiniMax M2.5 og MiniMax M2.7 med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.",
|
||||
"zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke",
|
||||
@@ -695,8 +695,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Rolle",
|
||||
"enterprise.form.role.placeholder": "Styreleder",
|
||||
"enterprise.form.company.label": "Selskap",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Bedrifts-e-post",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Telefonnummer",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Hvilket problem prøver dere å løse?",
|
||||
"enterprise.form.message.placeholder": "Vi trenger hjelp med...",
|
||||
"enterprise.form.send": "Send",
|
||||
|
||||
@@ -252,7 +252,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego",
|
||||
"go.meta.description":
|
||||
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5, Kimi K2.5 i MiniMax M2.5.",
|
||||
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7.",
|
||||
"go.hero.title": "Niskokosztowe modele do kodowania dla każdego",
|
||||
"go.hero.body":
|
||||
"Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.",
|
||||
@@ -300,7 +300,7 @@ export const dict = {
|
||||
"go.problem.item1": "Niskokosztowa cena subskrypcji",
|
||||
"go.problem.item2": "Hojne limity i niezawodny dostęp",
|
||||
"go.problem.item3": "Stworzony dla jak największej liczby programistów",
|
||||
"go.problem.item4": "Zawiera GLM-5, Kimi K2.5 i MiniMax M2.5",
|
||||
"go.problem.item4": "Zawiera GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7",
|
||||
"go.how.title": "Jak działa Go",
|
||||
"go.how.body":
|
||||
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.",
|
||||
@@ -323,10 +323,10 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go to niskokosztowa subskrypcja, która daje niezawodny dostęp do zdolnych modeli open source dla agentów kodujących.",
|
||||
"go.faq.q2": "Jakie modele zawiera Go?",
|
||||
"go.faq.a2": "Go zawiera GLM-5, Kimi K2.5 i MiniMax M2.5, z hojnymi limitami i niezawodnym dostępem.",
|
||||
"go.faq.a2": "Go zawiera GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7, z hojnymi limitami i niezawodnym dostępem.",
|
||||
"go.faq.q3": "Czy Go to to samo co Zen?",
|
||||
"go.faq.a3":
|
||||
"Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5, Kimi K2.5 i MiniMax M2.5.",
|
||||
"Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7.",
|
||||
"go.faq.q4": "Ile kosztuje Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go kosztuje",
|
||||
"go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc",
|
||||
@@ -351,7 +351,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?",
|
||||
"go.faq.a9":
|
||||
"Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5, Kimi K2.5 i MiniMax M2.5 z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).",
|
||||
"Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5, Kimi K2.5, MiniMax M2.5 i MiniMax M2.7 z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.",
|
||||
"zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany",
|
||||
@@ -698,8 +698,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Rola",
|
||||
"enterprise.form.role.placeholder": "Prezes Zarządu",
|
||||
"enterprise.form.company.label": "Firma",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "E-mail firmowy",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Numer telefonu",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Jaki problem próbujesz rozwiązać?",
|
||||
"enterprise.form.message.placeholder": "Potrzebujemy pomocy z...",
|
||||
"enterprise.form.send": "Wyślij",
|
||||
|
||||
@@ -255,7 +255,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Недорогие модели для кодинга для всех",
|
||||
"go.meta.description":
|
||||
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5, Kimi K2.5 и MiniMax M2.5.",
|
||||
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7.",
|
||||
"go.hero.title": "Недорогие модели для кодинга для всех",
|
||||
"go.hero.body":
|
||||
"Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.",
|
||||
@@ -304,7 +304,7 @@ export const dict = {
|
||||
"go.problem.item1": "Недорогая подписка",
|
||||
"go.problem.item2": "Щедрые лимиты и надежный доступ",
|
||||
"go.problem.item3": "Создан для максимального числа программистов",
|
||||
"go.problem.item4": "Включает GLM-5, Kimi K2.5 и MiniMax M2.5",
|
||||
"go.problem.item4": "Включает GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7",
|
||||
"go.how.title": "Как работает Go",
|
||||
"go.how.body":
|
||||
"Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.",
|
||||
@@ -327,10 +327,10 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go — это недорогая подписка, дающая надежный доступ к мощным моделям с открытым исходным кодом для агентов-программистов.",
|
||||
"go.faq.q2": "Какие модели включает Go?",
|
||||
"go.faq.a2": "Go включает GLM-5, Kimi K2.5 и MiniMax M2.5, с щедрыми лимитами и надежным доступом.",
|
||||
"go.faq.a2": "Go включает GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7, с щедрыми лимитами и надежным доступом.",
|
||||
"go.faq.q3": "Go — это то же самое, что и Zen?",
|
||||
"go.faq.a3":
|
||||
"Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5, Kimi K2.5 и MiniMax M2.5.",
|
||||
"Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7.",
|
||||
"go.faq.q4": "Сколько стоит Go?",
|
||||
"go.faq.a4.p1.beforePricing": "Go стоит",
|
||||
"go.faq.a4.p1.pricingLink": "$5 за первый месяц",
|
||||
@@ -355,7 +355,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "В чем разница между бесплатными моделями и Go?",
|
||||
"go.faq.a9":
|
||||
"Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5, Kimi K2.5 и MiniMax M2.5 с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).",
|
||||
"Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5, Kimi K2.5, MiniMax M2.5 и MiniMax M2.7 с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.",
|
||||
"zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается",
|
||||
@@ -703,8 +703,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Джефф Безос",
|
||||
"enterprise.form.role.label": "Роль",
|
||||
"enterprise.form.role.placeholder": "Исполнительный председатель",
|
||||
"enterprise.form.company.label": "Компания",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Корпоративная почта",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Номер телефона",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Какую проблему вы пытаетесь решить?",
|
||||
"enterprise.form.message.placeholder": "Нам нужна помощь с...",
|
||||
"enterprise.form.send": "Отправить",
|
||||
|
||||
@@ -250,7 +250,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
|
||||
"go.meta.description":
|
||||
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5, Kimi K2.5 และ MiniMax M2.5",
|
||||
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7",
|
||||
"go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
|
||||
"go.hero.body":
|
||||
"Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน",
|
||||
@@ -297,7 +297,7 @@ export const dict = {
|
||||
"go.problem.item1": "ราคาการสมัครสมาชิกที่ต่ำ",
|
||||
"go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
|
||||
"go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้",
|
||||
"go.problem.item4": "รวมถึง GLM-5, Kimi K2.5 และ MiniMax M2.5",
|
||||
"go.problem.item4": "รวมถึง GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7",
|
||||
"go.how.title": "Go ทำงานอย่างไร",
|
||||
"go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้",
|
||||
"go.how.step1.title": "สร้างบัญชี",
|
||||
@@ -319,10 +319,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go คือการสมัครสมาชิกราคาประหยัดที่ให้คุณเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสำหรับการเขียนโค้ดแบบเอเจนต์ได้อย่างน่าเชื่อถือ",
|
||||
"go.faq.q2": "Go รวมโมเดลอะไรบ้าง?",
|
||||
"go.faq.a2": "Go รวมถึง GLM-5, Kimi K2.5 และ MiniMax M2.5 พร้อมขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
|
||||
"go.faq.a2":
|
||||
"Go รวมถึง GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7 พร้อมขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้",
|
||||
"go.faq.q3": "Go เหมือนกับ Zen หรือไม่?",
|
||||
"go.faq.a3":
|
||||
"ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5, Kimi K2.5 และ MiniMax M2.5 อย่างเชื่อถือได้",
|
||||
"ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7 อย่างเชื่อถือได้",
|
||||
"go.faq.q4": "Go ราคาเท่าไหร่?",
|
||||
"go.faq.a4.p1.beforePricing": "Go ราคา",
|
||||
"go.faq.a4.p1.pricingLink": "$5 เดือนแรก",
|
||||
@@ -346,7 +347,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?",
|
||||
"go.faq.a9":
|
||||
"โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5, Kimi K2.5 และ MiniMax M2.5 ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)",
|
||||
"โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5, Kimi K2.5, MiniMax M2.5 และ MiniMax M2.7 ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง",
|
||||
"zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}",
|
||||
@@ -691,8 +692,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "ตำแหน่ง",
|
||||
"enterprise.form.role.placeholder": "ประธานกรรมการบริหาร",
|
||||
"enterprise.form.company.label": "บริษัท",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "อีเมลบริษัท",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "หมายเลขโทรศัพท์",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "คุณกำลังพยายามแก้ปัญหาอะไร?",
|
||||
"enterprise.form.message.placeholder": "เราต้องการความช่วยเหลือเรื่อง...",
|
||||
"enterprise.form.send": "ส่ง",
|
||||
|
||||
@@ -253,7 +253,7 @@ export const dict = {
|
||||
|
||||
"go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri",
|
||||
"go.meta.description":
|
||||
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5 ve MiniMax M2.5 için cömert 5 saatlik istek limitleri sunar.",
|
||||
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 için cömert 5 saatlik istek limitleri sunar.",
|
||||
"go.hero.title": "Herkes için düşük maliyetli kodlama modelleri",
|
||||
"go.hero.body":
|
||||
"Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.",
|
||||
@@ -302,7 +302,7 @@ export const dict = {
|
||||
"go.problem.item1": "Düşük maliyetli abonelik fiyatlandırması",
|
||||
"go.problem.item2": "Cömert limitler ve güvenilir erişim",
|
||||
"go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi",
|
||||
"go.problem.item4": "GLM-5, Kimi K2.5 ve MiniMax M2.5 içerir",
|
||||
"go.problem.item4": "GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 içerir",
|
||||
"go.how.title": "Go nasıl çalışır?",
|
||||
"go.how.body":
|
||||
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.",
|
||||
@@ -325,10 +325,11 @@ export const dict = {
|
||||
"go.faq.a1":
|
||||
"Go, ajan tabanlı kodlama için yetenekli açık kaynaklı modellere güvenilir erişim sağlayan düşük maliyetli bir aboneliktir.",
|
||||
"go.faq.q2": "Go hangi modelleri içerir?",
|
||||
"go.faq.a2": "Go, cömert limitler ve güvenilir erişim ile GLM-5, Kimi K2.5 ve MiniMax M2.5 modellerini içerir.",
|
||||
"go.faq.a2":
|
||||
"Go, cömert limitler ve güvenilir erişim ile GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 modellerini içerir.",
|
||||
"go.faq.q3": "Go, Zen ile aynı mı?",
|
||||
"go.faq.a3":
|
||||
"Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5 ve MiniMax M2.5 açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.",
|
||||
"Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.",
|
||||
"go.faq.q4": "Go ne kadar?",
|
||||
"go.faq.a4.p1.beforePricing": "Go'nun maliyeti",
|
||||
"go.faq.a4.p1.pricingLink": "İlk ay $5",
|
||||
@@ -353,7 +354,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?",
|
||||
"go.faq.a9":
|
||||
"Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5, Kimi K2.5 ve MiniMax M2.5 modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).",
|
||||
"Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5, Kimi K2.5, MiniMax M2.5 ve MiniMax M2.7 modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.",
|
||||
"zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor",
|
||||
@@ -700,8 +701,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "Rol",
|
||||
"enterprise.form.role.placeholder": "Yönetim Kurulu Başkanı",
|
||||
"enterprise.form.company.label": "Şirket",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "Şirket e-postası",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "Telefon numarası",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "Hangi problemi çözmeye çalışıyorsunuz?",
|
||||
"enterprise.form.message.placeholder": "Şu konuda yardıma ihtiyacımız var...",
|
||||
"enterprise.form.send": "Gönder",
|
||||
|
||||
@@ -240,7 +240,8 @@ export const dict = {
|
||||
"zen.privacy.exceptionsLink": "以下例外情况除外",
|
||||
|
||||
"go.title": "OpenCode Go | 人人可用的低成本编程模型",
|
||||
"go.meta.description": "Go 首月 $5,之后 $10/月,提供对 GLM-5、Kimi K2.5 和 MiniMax M2.5 的 5 小时充裕请求额度。",
|
||||
"go.meta.description":
|
||||
"Go 首月 $5,之后 $10/月,提供对 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 的 5 小时充裕请求额度。",
|
||||
"go.hero.title": "人人可用的低成本编程模型",
|
||||
"go.hero.body":
|
||||
"Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。",
|
||||
@@ -287,7 +288,7 @@ export const dict = {
|
||||
"go.problem.item1": "低成本订阅定价",
|
||||
"go.problem.item2": "充裕的限额和可靠的访问",
|
||||
"go.problem.item3": "为尽可能多的程序员打造",
|
||||
"go.problem.item4": "包含 GLM-5, Kimi K2.5, 和 MiniMax M2.5",
|
||||
"go.problem.item4": "包含 GLM-5, Kimi K2.5, MiniMax M2.5 和 MiniMax M2.7",
|
||||
"go.how.title": "Go 如何工作",
|
||||
"go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。",
|
||||
"go.how.step1.title": "创建账户",
|
||||
@@ -306,10 +307,10 @@ export const dict = {
|
||||
"go.faq.q1": "什么是 OpenCode Go?",
|
||||
"go.faq.a1": "Go 是一项低成本订阅服务,为您提供对强大的开源模型的可靠访问,用于代理编程。",
|
||||
"go.faq.q2": "Go 包含哪些模型?",
|
||||
"go.faq.a2": "Go 包含 GLM-5, Kimi K2.5, 和 MiniMax M2.5,并提供充裕的限额和可靠的访问。",
|
||||
"go.faq.a2": "Go 包含 GLM-5, Kimi K2.5, MiniMax M2.5 和 MiniMax M2.7,并提供充裕的限额和可靠的访问。",
|
||||
"go.faq.q3": "Go 和 Zen 一样吗?",
|
||||
"go.faq.a3":
|
||||
"不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5、Kimi K2.5 和 MiniMax M2.5 等开源模型。",
|
||||
"不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 等开源模型。",
|
||||
"go.faq.q4": "Go 多少钱?",
|
||||
"go.faq.a4.p1.beforePricing": "Go 费用为",
|
||||
"go.faq.a4.p1.pricingLink": "首月 $5",
|
||||
@@ -331,7 +332,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "免费模型和 Go 之间的区别是什么?",
|
||||
"go.faq.a9":
|
||||
"免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5, Kimi K2.5, 和 MiniMax M2.5,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。",
|
||||
"免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5, Kimi K2.5, MiniMax M2.5 和 MiniMax M2.7,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。",
|
||||
"zen.api.error.modelNotSupported": "不支持模型 {{model}}",
|
||||
@@ -669,8 +670,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "Jeff Bezos",
|
||||
"enterprise.form.role.label": "角色",
|
||||
"enterprise.form.role.placeholder": "执行主席",
|
||||
"enterprise.form.company.label": "公司",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "公司邮箱",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "电话号码",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "您想解决什么问题?",
|
||||
"enterprise.form.message.placeholder": "我们需要帮助...",
|
||||
"enterprise.form.send": "发送",
|
||||
|
||||
@@ -240,7 +240,8 @@ export const dict = {
|
||||
"zen.privacy.exceptionsLink": "以下例外情況",
|
||||
|
||||
"go.title": "OpenCode Go | 低成本全民編碼模型",
|
||||
"go.meta.description": "Go 首月 $5,之後 $10/月,提供對 GLM-5、Kimi K2.5 和 MiniMax M2.5 的 5 小時充裕請求額度。",
|
||||
"go.meta.description":
|
||||
"Go 首月 $5,之後 $10/月,提供對 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 的 5 小時充裕請求額度。",
|
||||
"go.hero.title": "低成本全民編碼模型",
|
||||
"go.hero.body":
|
||||
"Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。",
|
||||
@@ -287,7 +288,7 @@ export const dict = {
|
||||
"go.problem.item1": "低成本訂閱定價",
|
||||
"go.problem.item2": "寬裕的限額與穩定存取",
|
||||
"go.problem.item3": "專為盡可能多的程式設計師打造",
|
||||
"go.problem.item4": "包含 GLM-5、Kimi K2.5 與 MiniMax M2.5",
|
||||
"go.problem.item4": "包含 GLM-5、Kimi K2.5、MiniMax M2.5 與 MiniMax M2.7",
|
||||
"go.how.title": "Go 如何運作",
|
||||
"go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。",
|
||||
"go.how.step1.title": "建立帳號",
|
||||
@@ -306,10 +307,10 @@ export const dict = {
|
||||
"go.faq.q1": "什麼是 OpenCode Go?",
|
||||
"go.faq.a1": "Go 是一個低成本訂閱方案,讓你穩定存取強大的開源模型以進行代理編碼。",
|
||||
"go.faq.q2": "Go 包含哪些模型?",
|
||||
"go.faq.a2": "Go 包含 GLM-5、Kimi K2.5 與 MiniMax M2.5,並提供寬裕的限額與穩定存取。",
|
||||
"go.faq.a2": "Go 包含 GLM-5、Kimi K2.5、MiniMax M2.5 與 MiniMax M2.7,並提供寬裕的限額與穩定存取。",
|
||||
"go.faq.q3": "Go 與 Zen 一樣嗎?",
|
||||
"go.faq.a3":
|
||||
"不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5、Kimi K2.5 和 MiniMax M2.5 等開源模型。",
|
||||
"不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5、Kimi K2.5、MiniMax M2.5 和 MiniMax M2.7 等開源模型。",
|
||||
"go.faq.q4": "Go 費用是多少?",
|
||||
"go.faq.a4.p1.beforePricing": "Go 費用為",
|
||||
"go.faq.a4.p1.pricingLink": "首月 $5",
|
||||
@@ -331,7 +332,7 @@ export const dict = {
|
||||
|
||||
"go.faq.q9": "免費模型與 Go 有什麼區別?",
|
||||
"go.faq.a9":
|
||||
"免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5、Kimi K2.5 與 MiniMax M2.5,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。",
|
||||
"免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5、Kimi K2.5、MiniMax M2.5 與 MiniMax M2.7,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。",
|
||||
|
||||
"zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。",
|
||||
"zen.api.error.modelNotSupported": "不支援模型 {{model}}",
|
||||
@@ -668,8 +669,12 @@ export const dict = {
|
||||
"enterprise.form.name.placeholder": "傑夫·貝佐斯",
|
||||
"enterprise.form.role.label": "職稱",
|
||||
"enterprise.form.role.placeholder": "執行董事長",
|
||||
"enterprise.form.company.label": "公司",
|
||||
"enterprise.form.company.placeholder": "Acme Inc",
|
||||
"enterprise.form.email.label": "公司 Email",
|
||||
"enterprise.form.email.placeholder": "jeff@amazon.com",
|
||||
"enterprise.form.phone.label": "電話號碼",
|
||||
"enterprise.form.phone.placeholder": "+1 234 567 8900",
|
||||
"enterprise.form.message.label": "你想解決什麼問題?",
|
||||
"enterprise.form.message.placeholder": "我們需要幫助來...",
|
||||
"enterprise.form.send": "傳送",
|
||||
|
||||
81
packages/console/app/src/lib/salesforce.ts
Normal file
81
packages/console/app/src/lib/salesforce.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
async function login() {
|
||||
const url = Resource.SALESFORCE_INSTANCE_URL.value.replace(/\/$/, "")
|
||||
const clientId = Resource.SALESFORCE_CLIENT_ID.value
|
||||
const clientSecret = Resource.SALESFORCE_CLIENT_SECRET.value
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
})
|
||||
|
||||
const res = await fetch(`${url}/services/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params.toString(),
|
||||
}).catch((err) => {
|
||||
console.error("Failed to fetch Salesforce access token:", err)
|
||||
})
|
||||
|
||||
if (!res) return
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch Salesforce access token:", res.status, await res.text())
|
||||
return
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { access_token?: string; instance_url?: string }
|
||||
if (!data.access_token) {
|
||||
console.error("Salesforce auth response did not include an access token")
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
token: data.access_token,
|
||||
url: data.instance_url ?? url,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SalesforceLeadInput {
|
||||
name: string
|
||||
role: string
|
||||
company?: string
|
||||
email: string
|
||||
phone?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export async function createLead(input: SalesforceLeadInput): Promise<boolean> {
|
||||
const auth = await login()
|
||||
if (!auth) return false
|
||||
|
||||
const res = await fetch(`${auth.url}/services/data/v59.0/sobjects/Lead`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
LastName: input.name,
|
||||
Company: input.company?.trim() || "Website",
|
||||
Email: input.email,
|
||||
Phone: input.phone ?? null,
|
||||
Title: input.role,
|
||||
Description: input.message,
|
||||
LeadSource: "Website",
|
||||
}),
|
||||
}).catch((err) => {
|
||||
console.error("Failed to create Salesforce lead:", err)
|
||||
})
|
||||
|
||||
if (!res) return false
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("Failed to create Salesforce lead:", res.status, await res.text())
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -2,11 +2,15 @@ import type { APIEvent } from "@solidjs/start/server"
|
||||
import { AWS } from "@opencode-ai/console-core/aws.js"
|
||||
import { i18n } from "~/i18n"
|
||||
import { localeFromRequest } from "~/lib/language"
|
||||
import { createLead } from "~/lib/salesforce"
|
||||
|
||||
interface EnterpriseFormData {
|
||||
name: string
|
||||
role: string
|
||||
company?: string
|
||||
email: string
|
||||
phone?: string
|
||||
alias?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -14,33 +18,56 @@ export async function POST(event: APIEvent) {
|
||||
const dict = i18n(localeFromRequest(event.request))
|
||||
try {
|
||||
const body = (await event.request.json()) as EnterpriseFormData
|
||||
const trap = typeof body.alias === "string" ? body.alias.trim() : ""
|
||||
|
||||
if (trap) {
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name || !body.role || !body.email || !body.message) {
|
||||
return Response.json({ error: dict["enterprise.form.error.allFieldsRequired"] }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(body.email)) {
|
||||
return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 })
|
||||
}
|
||||
|
||||
// Create email content
|
||||
const emailContent = `
|
||||
${body.message}<br><br>
|
||||
--<br>
|
||||
${body.name}<br>
|
||||
${body.role}<br>
|
||||
${body.email}`.trim()
|
||||
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
|
||||
${body.phone ? `${body.phone}<br>` : ""}`.trim()
|
||||
|
||||
// Send email using AWS SES
|
||||
await AWS.sendEmail({
|
||||
to: "contact@anoma.ly",
|
||||
subject: `Enterprise Inquiry from ${body.name}`,
|
||||
body: emailContent,
|
||||
replyTo: body.email,
|
||||
})
|
||||
const [lead, mail] = await Promise.all([
|
||||
createLead({
|
||||
name: body.name,
|
||||
role: body.role,
|
||||
company: body.company,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
message: body.message,
|
||||
}),
|
||||
AWS.sendEmail({
|
||||
to: "contact@anoma.ly",
|
||||
subject: `Enterprise Inquiry from ${body.name}`,
|
||||
body: emailContent,
|
||||
replyTo: body.email,
|
||||
}).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
console.error("Failed to send enterprise email:", err)
|
||||
return false
|
||||
},
|
||||
),
|
||||
])
|
||||
|
||||
if (!lead && !mail) {
|
||||
console.error("Enterprise inquiry delivery failed", { email: body.email })
|
||||
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
|
||||
}
|
||||
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
} catch (error) {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
--color-text-strong: hsl(0, 5%, 12%);
|
||||
--color-text-inverted: hsl(0, 20%, 99%);
|
||||
--color-text-success: hsl(119, 100%, 35%);
|
||||
--color-text-error: hsl(4, 72%, 45%);
|
||||
|
||||
--color-border: hsl(30, 2%, 81%);
|
||||
--color-border-weak: hsl(0, 1%, 85%);
|
||||
@@ -50,6 +51,7 @@
|
||||
--color-text-strong: hsl(0, 15%, 94%);
|
||||
--color-text-inverted: hsl(0, 9%, 7%);
|
||||
--color-text-success: hsl(119, 60%, 72%);
|
||||
--color-text-error: hsl(4, 76%, 72%);
|
||||
|
||||
--color-border: hsl(0, 3%, 28%);
|
||||
--color-border-weak: hsl(0, 4%, 23%);
|
||||
@@ -454,6 +456,13 @@
|
||||
color: var(--color-text-success);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-component="error-message"] {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem 0;
|
||||
color: var(--color-text-error);
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,15 @@ export default function Enterprise() {
|
||||
const [formData, setFormData] = createSignal({
|
||||
name: "",
|
||||
role: "",
|
||||
company: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
alias: "",
|
||||
message: "",
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
||||
const [showSuccess, setShowSuccess] = createSignal(false)
|
||||
const [error, setError] = createSignal("")
|
||||
|
||||
const handleInputChange = (field: string) => (e: Event) => {
|
||||
const target = e.target as HTMLInputElement | HTMLTextAreaElement
|
||||
@@ -26,6 +30,8 @@ export default function Enterprise() {
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
setShowSuccess(false)
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
@@ -42,13 +48,21 @@ export default function Enterprise() {
|
||||
setFormData({
|
||||
name: "",
|
||||
role: "",
|
||||
company: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
alias: "",
|
||||
message: "",
|
||||
})
|
||||
setTimeout(() => setShowSuccess(false), 5000)
|
||||
return
|
||||
}
|
||||
|
||||
const data = (await response.json().catch(() => null)) as { error?: string } | null
|
||||
setError(data?.error ?? i18n.t("enterprise.form.error.internalServer"))
|
||||
} catch (error) {
|
||||
console.error("Failed to submit form:", error)
|
||||
setError(i18n.t("enterprise.form.error.internalServer"))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -147,6 +161,19 @@ export default function Enterprise() {
|
||||
<div data-component="enterprise-column-2">
|
||||
<div data-component="enterprise-form">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div class="sr-only" aria-hidden="true">
|
||||
<input
|
||||
type="text"
|
||||
name="alias"
|
||||
tabIndex={-1}
|
||||
autocomplete="new-password"
|
||||
inputmode="none"
|
||||
spellcheck={false}
|
||||
value={formData().alias}
|
||||
onInput={handleInputChange("alias")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-component="form-group">
|
||||
<label for="name">{i18n.t("enterprise.form.name.label")}</label>
|
||||
<input
|
||||
@@ -171,6 +198,17 @@ export default function Enterprise() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-component="form-group">
|
||||
<label for="company">{i18n.t("enterprise.form.company.label")}</label>
|
||||
<input
|
||||
id="company"
|
||||
type="text"
|
||||
value={formData().company}
|
||||
onInput={handleInputChange("company")}
|
||||
placeholder={i18n.t("enterprise.form.company.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-component="form-group">
|
||||
<label for="email">{i18n.t("enterprise.form.email.label")}</label>
|
||||
<input
|
||||
@@ -183,6 +221,17 @@ export default function Enterprise() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-component="form-group">
|
||||
<label for="phone">{i18n.t("enterprise.form.phone.label")}</label>
|
||||
<input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData().phone}
|
||||
onInput={handleInputChange("phone")}
|
||||
placeholder={i18n.t("enterprise.form.phone.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-component="form-group">
|
||||
<label for="message">{i18n.t("enterprise.form.message.label")}</label>
|
||||
<textarea
|
||||
@@ -201,6 +250,7 @@ export default function Enterprise() {
|
||||
</form>
|
||||
|
||||
{showSuccess() && <div data-component="success-message">{i18n.t("enterprise.form.success")}</div>}
|
||||
{error() && <div data-component="error-message">{error()}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,8 @@ function LimitsGraph(props: { href: string }) {
|
||||
const models = [
|
||||
{ id: "glm", name: "GLM-5", req: 1150, d: "120ms" },
|
||||
{ id: "kimi", name: "Kimi K2.5", req: 1850, d: "240ms" },
|
||||
{ id: "minimax", name: "MiniMax M2.5", req: 20000, d: "360ms" },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", req: 14000, d: "330ms" },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", req: 20000, d: "360ms" },
|
||||
]
|
||||
|
||||
const w = 720
|
||||
|
||||
@@ -279,6 +279,7 @@ export function LiteSection() {
|
||||
<li>Kimi K2.5</li>
|
||||
<li>GLM-5</li>
|
||||
<li>MiniMax M2.5</li>
|
||||
<li>MiniMax M2.7</li>
|
||||
</ul>
|
||||
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
|
||||
<button
|
||||
|
||||
@@ -8,12 +8,15 @@ import { querySessionInfo } from "../common"
|
||||
import {
|
||||
IconAlibaba,
|
||||
IconAnthropic,
|
||||
IconArcee,
|
||||
IconGemini,
|
||||
IconMiniMax,
|
||||
IconMoonshotAI,
|
||||
IconNvidia,
|
||||
IconOpenAI,
|
||||
IconStealth,
|
||||
IconXai,
|
||||
IconXiaomi,
|
||||
IconZai,
|
||||
} from "~/component/icon"
|
||||
import { useI18n } from "~/context/i18n"
|
||||
@@ -29,6 +32,9 @@ const getModelLab = (modelId: string) => {
|
||||
if (modelId.startsWith("qwen")) return "Alibaba"
|
||||
if (modelId.startsWith("minimax")) return "MiniMax"
|
||||
if (modelId.startsWith("grok")) return "xAI"
|
||||
if (modelId.startsWith("mimo")) return "Xiaomi"
|
||||
if (modelId.startsWith("nemotron")) return "NVIDIA"
|
||||
if (modelId.startsWith("trinity")) return "Arcee"
|
||||
return "Stealth"
|
||||
}
|
||||
|
||||
@@ -139,6 +145,12 @@ export function ModelSection() {
|
||||
return <IconXai width={16} height={16} />
|
||||
case "MiniMax":
|
||||
return <IconMiniMax width={16} height={16} />
|
||||
case "Xiaomi":
|
||||
return <IconXiaomi width={16} height={16} />
|
||||
case "NVIDIA":
|
||||
return <IconNvidia width={16} height={16} />
|
||||
case "Arcee":
|
||||
return <IconArcee width={16} height={16} />
|
||||
default:
|
||||
return <IconStealth width={16} height={16} />
|
||||
}
|
||||
|
||||
@@ -74,8 +74,9 @@ export async function handler(
|
||||
const dict = i18n(localeFromRequest(input.request))
|
||||
const t = (key: Key, params?: Record<string, string | number>) => resolve(dict[key], params)
|
||||
const ADMIN_WORKSPACES = [
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
||||
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // anomaly
|
||||
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // benchmark
|
||||
"wrk_01KKZDKDWCS1VTJF8QTX62DD50", // contributors
|
||||
]
|
||||
|
||||
try {
|
||||
@@ -97,8 +98,8 @@ export async function handler(
|
||||
const zenData = ZenData.list(opts.modelList)
|
||||
const modelInfo = validateModel(zenData, model)
|
||||
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
||||
const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
|
||||
const trialProvider = await trialLimiter?.check()
|
||||
const trialLimiter = createTrialLimiter(modelInfo.trialProviders, ip)
|
||||
const trialProviders = await trialLimiter?.check()
|
||||
const rateLimiter = createRateLimiter(
|
||||
modelInfo.id,
|
||||
modelInfo.allowAnonymous,
|
||||
@@ -119,8 +120,9 @@ export async function handler(
|
||||
zenData,
|
||||
authInfo,
|
||||
modelInfo,
|
||||
ip,
|
||||
sessionId,
|
||||
trialProvider,
|
||||
trialProviders,
|
||||
retry,
|
||||
stickyProvider,
|
||||
)
|
||||
@@ -330,6 +332,7 @@ export async function handler(
|
||||
logger.metric({
|
||||
"error.type": error.constructor.name,
|
||||
"error.message": error.message,
|
||||
"error.cause": error.cause?.toString(),
|
||||
})
|
||||
|
||||
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
|
||||
@@ -400,8 +403,9 @@ export async function handler(
|
||||
zenData: ZenData,
|
||||
authInfo: AuthInfo,
|
||||
modelInfo: ModelInfo,
|
||||
ip: string,
|
||||
sessionId: string,
|
||||
trialProvider: string | undefined,
|
||||
trialProviders: string[] | undefined,
|
||||
retry: RetryOptions,
|
||||
stickyProvider: string | undefined,
|
||||
) {
|
||||
@@ -410,15 +414,17 @@ export async function handler(
|
||||
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
|
||||
}
|
||||
|
||||
if (trialProvider) {
|
||||
return modelInfo.providers.find((provider) => provider.id === trialProvider)
|
||||
}
|
||||
|
||||
if (stickyProvider) {
|
||||
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
|
||||
if (provider) return provider
|
||||
}
|
||||
|
||||
if (trialProviders) {
|
||||
const trialProvider = trialProviders[Math.floor(Math.random() * trialProviders.length)]
|
||||
const provider = modelInfo.providers.find((provider) => provider.id === trialProvider)
|
||||
if (provider) return provider
|
||||
}
|
||||
|
||||
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
|
||||
const providers = modelInfo.providers
|
||||
.filter((provider) => !provider.disabled)
|
||||
@@ -426,10 +432,11 @@ export async function handler(
|
||||
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
|
||||
|
||||
// Use the last 4 characters of session ID to select a provider
|
||||
const identifier = sessionId.length ? sessionId : ip
|
||||
let h = 0
|
||||
const l = sessionId.length
|
||||
const l = identifier.length
|
||||
for (let i = l - 4; i < l; i++) {
|
||||
h = (h * 31 + sessionId.charCodeAt(i)) | 0 // 32-bit int
|
||||
h = (h * 31 + identifier.charCodeAt(i)) | 0 // 32-bit int
|
||||
}
|
||||
const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1
|
||||
const provider = providers[index || 0]
|
||||
|
||||
@@ -175,7 +175,8 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
|
||||
outputTokens: usage.output_tokens ?? 0,
|
||||
reasoningTokens: undefined,
|
||||
cacheReadTokens: usage.cache_read_input_tokens ?? undefined,
|
||||
cacheWrite5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens ?? undefined,
|
||||
cacheWrite5mTokens:
|
||||
usage.cache_creation?.ephemeral_5m_input_tokens ?? usage.cache_creation_input_tokens ?? undefined,
|
||||
cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? undefined,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
import { UsageInfo } from "./provider/provider"
|
||||
import { Subscription } from "@opencode-ai/console-core/subscription.js"
|
||||
|
||||
export function createTrialLimiter(trialProvider: string | undefined, ip: string) {
|
||||
if (!trialProvider) return
|
||||
export function createTrialLimiter(trialProviders: string[] | undefined, ip: string) {
|
||||
if (!trialProviders) return
|
||||
if (!ip) return
|
||||
|
||||
const limit = Subscription.getFreeLimits().promoTokens
|
||||
@@ -24,7 +24,7 @@ export function createTrialLimiter(trialProvider: string | undefined, ip: string
|
||||
)
|
||||
|
||||
_isTrial = (data?.usage ?? 0) < limit
|
||||
return _isTrial ? trialProvider : undefined
|
||||
return _isTrial ? trialProviders : undefined
|
||||
},
|
||||
track: async (usageInfo: UsageInfo) => {
|
||||
if (!_isTrial) return
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable, BlackPlans, UsageTable } from "../src/schema/billing.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
import { KeyTable } from "../src/schema/key.sql.js"
|
||||
import { BlackData } from "../src/black.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
import { getWeekBounds } from "../src/util/date.js"
|
||||
@@ -10,13 +11,46 @@ import { getWeekBounds } from "../src/util/date.js"
|
||||
// get input from command line
|
||||
const identifier = process.argv[2]
|
||||
if (!identifier) {
|
||||
console.error("Usage: bun lookup-user.ts <email|workspaceID>")
|
||||
console.error("Usage: bun lookup-user.ts <email|workspaceID|apiKey>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// loop up by workspace ID
|
||||
if (identifier.startsWith("wrk_")) {
|
||||
await printWorkspace(identifier)
|
||||
} else {
|
||||
}
|
||||
// lookup by API key ID
|
||||
else if (identifier.startsWith("key_")) {
|
||||
const key = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(KeyTable)
|
||||
.where(eq(KeyTable.id, identifier))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!key) {
|
||||
console.error("API key not found")
|
||||
process.exit(1)
|
||||
}
|
||||
await printWorkspace(key.workspaceID)
|
||||
}
|
||||
// lookup by API key value
|
||||
else if (identifier.startsWith("sk-")) {
|
||||
const key = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(KeyTable)
|
||||
.where(eq(KeyTable.key, identifier))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!key) {
|
||||
console.error("API key not found")
|
||||
process.exit(1)
|
||||
}
|
||||
await printWorkspace(key.workspaceID)
|
||||
}
|
||||
// lookup by email
|
||||
else {
|
||||
const authData = await Database.use(async (tx) =>
|
||||
tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)),
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ export namespace ZenData {
|
||||
allowAnonymous: z.boolean().optional(),
|
||||
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
|
||||
stickyProvider: z.enum(["strict", "prefer"]).optional(),
|
||||
trialProvider: z.string().optional(),
|
||||
trialProviders: z.array(z.string()).optional(),
|
||||
fallbackProvider: z.string().optional(),
|
||||
rateLimit: z.number().optional(),
|
||||
providers: z.array(
|
||||
|
||||
12
packages/console/core/sst-env.d.ts
vendored
12
packages/console/core/sst-env.d.ts
vendored
@@ -95,6 +95,18 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_INSTANCE_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
12
packages/console/function/sst-env.d.ts
vendored
12
packages/console/function/sst-env.d.ts
vendored
@@ -95,6 +95,18 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_INSTANCE_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
12
packages/console/resource/sst-env.d.ts
vendored
12
packages/console/resource/sst-env.d.ts
vendored
@@ -95,6 +95,18 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_INSTANCE_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"effect": "4.0.0-beta.31",
|
||||
"effect": "catalog:",
|
||||
"electron-log": "^5",
|
||||
"electron-store": "^10",
|
||||
"electron-updater": "^6",
|
||||
|
||||
12
packages/enterprise/sst-env.d.ts
vendored
12
packages/enterprise/sst-env.d.ts
vendored
@@ -95,6 +95,18 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_INSTANCE_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
12
packages/function/sst-env.d.ts
vendored
12
packages/function/sst-env.d.ts
vendored
@@ -95,6 +95,18 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_CLIENT_SECRET": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"SALESFORCE_INSTANCE_URL": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_PUBLISHABLE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
|
||||
@@ -9,71 +9,55 @@
|
||||
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
|
||||
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
|
||||
|
||||
# opencode Effect guide
|
||||
# opencode Effect rules
|
||||
|
||||
Instructions to follow when writing Effect.
|
||||
Use these rules when writing or migrating Effect code.
|
||||
|
||||
## Schemas
|
||||
See `specs/effect-migration.md` for the compact pattern reference and examples.
|
||||
|
||||
- Use `Schema.Class` for data types with multiple fields.
|
||||
- Use branded schemas (`Schema.brand`) for single-value types.
|
||||
|
||||
## Services
|
||||
|
||||
- Services use `ServiceMap.Service<ServiceName, ServiceName.Service>()("@console/<Name>")`.
|
||||
- In `Layer.effect`, always return service implementations with `ServiceName.of({ ... })`, never a plain object.
|
||||
|
||||
## Errors
|
||||
|
||||
- Use `Schema.TaggedErrorClass` for typed errors.
|
||||
- For defect-like causes, use `Schema.Defect` instead of `unknown`.
|
||||
- In `Effect.gen`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||
|
||||
## Effects
|
||||
## Core
|
||||
|
||||
- Use `Effect.gen(function* () { ... })` for composition.
|
||||
- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
|
||||
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers.
|
||||
- **`Effect.callback`** (not `Effect.async`) for callback-based APIs. The classic `Effect.async` was renamed to `Effect.callback` in effect-smol/v4.
|
||||
|
||||
## Time
|
||||
|
||||
- Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
|
||||
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers.
|
||||
- Use `Effect.callback` for callback-based APIs.
|
||||
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
|
||||
|
||||
## Errors
|
||||
## Schemas and errors
|
||||
|
||||
- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||
- Use `Schema.Class` for multi-field data.
|
||||
- Use branded schemas (`Schema.brand`) for single-value types.
|
||||
- Use `Schema.TaggedErrorClass` for typed errors.
|
||||
- Use `Schema.Defect` instead of `unknown` for defect-like causes.
|
||||
- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||
|
||||
## Instance-scoped Effect services
|
||||
## Runtime vs Instances
|
||||
|
||||
Services that need per-directory lifecycle (created/destroyed per instance) go through the `Instances` LayerMap:
|
||||
- Use the shared runtime for process-wide services with one lifecycle for the whole app.
|
||||
- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup.
|
||||
- If two open directories should not share one copy of the service, it belongs in `Instances`.
|
||||
- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals.
|
||||
|
||||
1. Define a `ServiceMap.Service` with a `static readonly layer` (see `FileWatcherService`, `QuestionService`, `PermissionService`, `ProviderAuthService`).
|
||||
2. Add it to `InstanceServices` union and `Layer.mergeAll(...)` in `src/effect/instances.ts`.
|
||||
3. Use `InstanceContext` inside the layer to read `directory` and `project` instead of `Instance.*` globals.
|
||||
4. Call from legacy code via `runPromiseInstance(MyService.use((s) => s.method()))`.
|
||||
## Preferred Effect services
|
||||
|
||||
### Instance.bind — ALS context for native callbacks
|
||||
- In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
|
||||
- Prefer `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O.
|
||||
- Prefer `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers.
|
||||
- Prefer `HttpClient.HttpClient` instead of raw `fetch`.
|
||||
- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code.
|
||||
- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
|
||||
|
||||
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and returns a wrapper that restores it synchronously when called.
|
||||
## Instance.bind — ALS for native callbacks
|
||||
|
||||
**Use it** when passing callbacks to native C/C++ addons (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
|
||||
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
|
||||
|
||||
**Don't need it** for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers — Node.js ALS propagates through those automatically.
|
||||
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
|
||||
|
||||
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
|
||||
|
||||
```typescript
|
||||
// Native addon callback — needs Instance.bind
|
||||
const cb = Instance.bind((err, evts) => {
|
||||
Bus.publish(MyEvent, { ... })
|
||||
})
|
||||
nativeAddon.subscribe(dir, cb)
|
||||
```
|
||||
|
||||
## Flag → Effect.Config migration
|
||||
|
||||
Flags in `src/flag/flag.ts` are being migrated from static `truthy(...)` reads to `Config.boolean(...).pipe(Config.withDefault(false))` as their consumers get effectified.
|
||||
|
||||
- Effectful flags return `Config<boolean>` and are read with `yield*` inside `Effect.gen`.
|
||||
- The default `ConfigProvider` reads from `process.env`, so env vars keep working.
|
||||
- Tests can override via `ConfigProvider.layer(ConfigProvider.fromUnknown({ ... }))`.
|
||||
- Keep all flags in `flag.ts` as the single registry — just change the implementation from `truthy()` to `Config.boolean()` when the consumer moves to Effect.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prepare": "effect-language-service patch || true",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test --timeout 30000",
|
||||
"build": "bun run script/build.ts",
|
||||
@@ -42,11 +43,12 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
@@ -95,7 +97,7 @@
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.87",
|
||||
"@opentui/solid": "0.1.87",
|
||||
"@effect/platform-node": "4.0.0-beta.31",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -108,6 +110,7 @@
|
||||
"bun-pty": "0.4.8",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
|
||||
@@ -11,46 +11,52 @@ const seed = async () => {
|
||||
const { Instance } = await import("../src/project/instance")
|
||||
const { InstanceBootstrap } = await import("../src/project/bootstrap")
|
||||
const { Config } = await import("../src/config/config")
|
||||
const { disposeRuntime } = await import("../src/effect/runtime")
|
||||
const { Session } = await import("../src/session")
|
||||
const { MessageID, PartID } = await import("../src/session/schema")
|
||||
const { Project } = await import("../src/project/project")
|
||||
const { ModelID, ProviderID } = await import("../src/provider/schema")
|
||||
const { ToolRegistry } = await import("../src/tool/registry")
|
||||
|
||||
await Instance.provide({
|
||||
directory: dir,
|
||||
init: InstanceBootstrap,
|
||||
fn: async () => {
|
||||
await Config.waitForDependencies()
|
||||
await ToolRegistry.ids()
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: dir,
|
||||
init: InstanceBootstrap,
|
||||
fn: async () => {
|
||||
await Config.waitForDependencies()
|
||||
await ToolRegistry.ids()
|
||||
|
||||
const session = await Session.create({ title })
|
||||
const messageID = MessageID.ascending()
|
||||
const partID = PartID.ascending()
|
||||
const message = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "user" as const,
|
||||
time: { created: now },
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: ProviderID.make(providerID),
|
||||
modelID: ModelID.make(modelID),
|
||||
},
|
||||
}
|
||||
const part = {
|
||||
id: partID,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
type: "text" as const,
|
||||
text,
|
||||
time: { start: now },
|
||||
}
|
||||
await Session.updateMessage(message)
|
||||
await Session.updatePart(part)
|
||||
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
|
||||
},
|
||||
})
|
||||
const session = await Session.create({ title })
|
||||
const messageID = MessageID.ascending()
|
||||
const partID = PartID.ascending()
|
||||
const message = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "user" as const,
|
||||
time: { created: now },
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: ProviderID.make(providerID),
|
||||
modelID: ModelID.make(modelID),
|
||||
},
|
||||
}
|
||||
const part = {
|
||||
id: partID,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
type: "text" as const,
|
||||
text,
|
||||
time: { start: now },
|
||||
}
|
||||
await Session.updateMessage(message)
|
||||
await Session.updatePart(part)
|
||||
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await Instance.disposeAll().catch(() => {})
|
||||
await disposeRuntime().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
await seed()
|
||||
|
||||
144
packages/opencode/specs/effect-migration.md
Normal file
144
packages/opencode/specs/effect-migration.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Effect patterns
|
||||
|
||||
Practical reference for new and migrated Effect code in `packages/opencode`.
|
||||
|
||||
## Choose scope
|
||||
|
||||
Use the shared runtime for process-wide services with one lifecycle for the whole app.
|
||||
|
||||
Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup.
|
||||
|
||||
- Shared runtime: config readers, stateless helpers, global clients
|
||||
- Instance-scoped: watchers, per-project caches, session state, project-bound background work
|
||||
|
||||
Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`.
|
||||
|
||||
## Service shape
|
||||
|
||||
For a fully migrated module, use the public namespace directly:
|
||||
|
||||
```ts
|
||||
export namespace Foo {
|
||||
export interface Interface {
|
||||
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
return Service.of({
|
||||
get: Effect.fn("Foo.get")(function* (id) {
|
||||
return yield* ...
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer))
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace
|
||||
- Export `defaultLayer` only when wiring dependencies is useful
|
||||
- Use the direct namespace form once the module is fully migrated
|
||||
|
||||
## Temporary mixed-mode pattern
|
||||
|
||||
Prefer a single namespace whenever possible.
|
||||
|
||||
Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
|
||||
|
||||
```ts
|
||||
export namespace FooEffect {
|
||||
export interface Interface {
|
||||
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
|
||||
export const layer = Layer.effect(...)
|
||||
}
|
||||
```
|
||||
|
||||
Then keep the old boundary thin:
|
||||
|
||||
```ts
|
||||
export namespace Foo {
|
||||
export function get(id: FooID) {
|
||||
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Remove the `Effect` suffix when the boundary split is gone.
|
||||
|
||||
## Scheduled Tasks
|
||||
|
||||
For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
|
||||
|
||||
## Preferred Effect services
|
||||
|
||||
In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
|
||||
|
||||
Prefer these first:
|
||||
|
||||
- `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O
|
||||
- `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers
|
||||
- `HttpClient.HttpClient` instead of raw `fetch`
|
||||
- `Path.Path` instead of mixing path helpers into service code when you already need a path service
|
||||
- `Config` for effect-native configuration reads
|
||||
- `Clock` / `DateTime` for time reads inside effects
|
||||
|
||||
## Child processes
|
||||
|
||||
For child process work in services, yield `ChildProcessSpawner.ChildProcessSpawner` in the layer and use `ChildProcess.make(...)`.
|
||||
|
||||
Keep shelling-out code inside the service, not in callers.
|
||||
|
||||
## Shared leaf models
|
||||
|
||||
Shared schema or model files can stay outside the service namespace when lower layers also depend on them.
|
||||
|
||||
That is fine for leaf files like `schema.ts`. Keep the service surface in the owning namespace.
|
||||
|
||||
## Migration checklist
|
||||
|
||||
Done now:
|
||||
|
||||
- [x] `AccountEffect` (mixed-mode)
|
||||
- [x] `AuthEffect` (mixed-mode)
|
||||
- [x] `TruncateEffect` (mixed-mode)
|
||||
- [x] `Question`
|
||||
- [x] `PermissionNext`
|
||||
- [x] `ProviderAuth`
|
||||
- [x] `FileWatcher`
|
||||
- [x] `FileTime`
|
||||
- [x] `Format`
|
||||
- [x] `Vcs`
|
||||
- [x] `Skill`
|
||||
- [x] `Discovery`
|
||||
- [x] `File`
|
||||
- [x] `Snapshot`
|
||||
|
||||
Still open and likely worth migrating:
|
||||
|
||||
- [ ] `Plugin`
|
||||
- [ ] `ToolRegistry`
|
||||
- [ ] `Pty`
|
||||
- [ ] `Worktree`
|
||||
- [ ] `Installation`
|
||||
- [ ] `Bus`
|
||||
- [ ] `Command`
|
||||
- [ ] `Config`
|
||||
- [ ] `Session`
|
||||
- [ ] `SessionProcessor`
|
||||
- [ ] `SessionPrompt`
|
||||
- [ ] `SessionCompaction`
|
||||
- [ ] `Provider`
|
||||
- [ ] `Project`
|
||||
- [ ] `LSP`
|
||||
- [ ] `MCP`
|
||||
@@ -108,8 +108,8 @@ const mapAccountServiceError =
|
||||
),
|
||||
)
|
||||
|
||||
export namespace AccountService {
|
||||
export interface Service {
|
||||
export namespace AccountEffect {
|
||||
export interface Interface {
|
||||
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
|
||||
readonly list: () => Effect.Effect<Account[], AccountError>
|
||||
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
|
||||
@@ -124,11 +124,11 @@ export namespace AccountService {
|
||||
readonly login: (url: string) => Effect.Effect<Login, AccountError>
|
||||
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountService extends ServiceMap.Service<AccountService, AccountService.Service>()("@opencode/Account") {
|
||||
static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||
AccountService,
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const repo = yield* AccountRepo
|
||||
const http = yield* HttpClient.HttpClient
|
||||
@@ -148,8 +148,6 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
// Returns a usable access token for a stored account row, refreshing and
|
||||
// persisting it when the cached token has expired.
|
||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||
@@ -218,11 +216,11 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
)
|
||||
})
|
||||
|
||||
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
|
||||
const token = Effect.fn("Account.token")((accountID: AccountID) =>
|
||||
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
|
||||
)
|
||||
|
||||
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
|
||||
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
|
||||
const accounts = yield* repo.list()
|
||||
const [errors, results] = yield* Effect.partition(
|
||||
accounts,
|
||||
@@ -237,7 +235,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
return results
|
||||
})
|
||||
|
||||
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
|
||||
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return []
|
||||
|
||||
@@ -246,7 +244,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
return yield* fetchOrgs(account.url, accessToken)
|
||||
})
|
||||
|
||||
const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
|
||||
const resolved = yield* resolveAccess(accountID)
|
||||
if (Option.isNone(resolved)) return Option.none()
|
||||
|
||||
@@ -270,7 +268,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
return Option.some(parsed.config)
|
||||
})
|
||||
|
||||
const login = Effect.fn("AccountService.login")(function* (server: string) {
|
||||
const login = Effect.fn("Account.login")(function* (server: string) {
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
@@ -291,7 +289,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
})
|
||||
})
|
||||
|
||||
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
|
||||
const poll = Effect.fn("Account.poll")(function* (input: Login) {
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
@@ -337,7 +335,7 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
return new PollSuccess({ email: account.email })
|
||||
})
|
||||
|
||||
return AccountService.of({
|
||||
return Service.of({
|
||||
active: repo.active,
|
||||
list: repo.list,
|
||||
orgsByAccount,
|
||||
@@ -352,8 +350,5 @@ export class AccountService extends ServiceMap.Service<AccountService, AccountSe
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly defaultLayer = AccountService.layer.pipe(
|
||||
Layer.provide(AccountRepo.layer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||
}
|
||||
@@ -5,20 +5,20 @@ import {
|
||||
type AccountError,
|
||||
type AccessToken,
|
||||
AccountID,
|
||||
AccountService,
|
||||
AccountEffect,
|
||||
OrgID,
|
||||
} from "./service"
|
||||
} from "./effect"
|
||||
|
||||
export { AccessToken, AccountID, OrgID } from "./service"
|
||||
export { AccessToken, AccountID, OrgID } from "./effect"
|
||||
|
||||
import { runtime } from "@/effect/runtime"
|
||||
|
||||
function runSync<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runSync(AccountService.use(f))
|
||||
function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runSync(AccountEffect.Service.use(f))
|
||||
}
|
||||
|
||||
function runPromise<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runPromise(AccountService.use(f))
|
||||
function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
|
||||
return runtime.runPromise(AccountEffect.Service.use(f))
|
||||
}
|
||||
|
||||
export namespace Account {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
import { SystemPrompt } from "../session/system"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Truncate } from "../tool/truncation"
|
||||
import { Truncate } from "../tool/truncate"
|
||||
import { Auth } from "../auth"
|
||||
import { ProviderTransform } from "../provider/transform"
|
||||
|
||||
@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
||||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||
import { Global } from "@/global"
|
||||
import path from "path"
|
||||
|
||||
@@ -28,31 +28,31 @@ export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||
export const Info = Schema.Union([Oauth, Api, WellKnown])
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
|
||||
export class AuthError extends Schema.TaggedErrorClass<AuthError>()("AuthError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
const file = path.join(Global.Path.data, "auth.json")
|
||||
|
||||
const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
|
||||
const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause })
|
||||
|
||||
export namespace AuthService {
|
||||
export interface Service {
|
||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
|
||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
|
||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
|
||||
readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
|
||||
export namespace AuthEffect {
|
||||
export interface Interface {
|
||||
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthError>
|
||||
readonly all: () => Effect.Effect<Record<string, Info>, AuthError>
|
||||
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthError>
|
||||
readonly remove: (key: string) => Effect.Effect<void, AuthError>
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthService extends ServiceMap.Service<AuthService, AuthService.Service>()("@opencode/Auth") {
|
||||
static readonly layer = Layer.effect(
|
||||
AuthService,
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const decode = Schema.decodeUnknownOption(Info)
|
||||
|
||||
const all = Effect.fn("AuthService.all")(() =>
|
||||
const all = Effect.fn("Auth.all")(() =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
||||
@@ -62,11 +62,11 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("AuthService.get")(function* (providerID: string) {
|
||||
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
||||
return (yield* all())[providerID]
|
||||
})
|
||||
|
||||
const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) {
|
||||
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
if (norm !== key) delete data[key]
|
||||
@@ -77,7 +77,7 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
|
||||
})
|
||||
})
|
||||
|
||||
const remove = Effect.fn("AuthService.remove")(function* (key: string) {
|
||||
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
||||
const norm = key.replace(/\/+$/, "")
|
||||
const data = yield* all()
|
||||
delete data[key]
|
||||
@@ -88,14 +88,7 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
|
||||
})
|
||||
})
|
||||
|
||||
return AuthService.of({
|
||||
get,
|
||||
all,
|
||||
set,
|
||||
remove,
|
||||
})
|
||||
return Service.of({ get, all, set, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly defaultLayer = AuthService.layer
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import * as S from "./service"
|
||||
import * as S from "./effect"
|
||||
|
||||
export { OAUTH_DUMMY_KEY } from "./service"
|
||||
export { OAUTH_DUMMY_KEY } from "./effect"
|
||||
|
||||
function runPromise<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) {
|
||||
return runtime.runPromise(S.AuthService.use(f))
|
||||
function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) {
|
||||
return runtime.runPromise(S.AuthEffect.Service.use(f))
|
||||
}
|
||||
|
||||
export namespace Auth {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import z from "zod"
|
||||
import type { ZodType } from "zod"
|
||||
import type { ZodObject, ZodRawShape } from "zod"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace BusEvent {
|
||||
@@ -9,7 +9,7 @@ export namespace BusEvent {
|
||||
|
||||
const registry = new Map<string, Definition>()
|
||||
|
||||
export function define<Type extends string, Properties extends ZodType>(type: Type, properties: Properties) {
|
||||
export function define<Type extends string, Properties extends ZodObject<ZodRawShape>>(type: Type, properties: Properties) {
|
||||
const result = {
|
||||
type,
|
||||
properties,
|
||||
|
||||
@@ -4,7 +4,7 @@ export const GlobalBus = new EventEmitter<{
|
||||
event: [
|
||||
{
|
||||
directory?: string
|
||||
payload: any
|
||||
payload: { type: string; properties: Record<string, unknown> }
|
||||
},
|
||||
]
|
||||
}>()
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import z from "zod"
|
||||
import { Effect, Fiber, Layer, PubSub, ServiceMap, Stream } from "effect"
|
||||
import { Log } from "../util/log"
|
||||
import { Instance } from "../project/instance"
|
||||
import { BusEvent } from "./bus-event"
|
||||
import { GlobalBus } from "./global"
|
||||
import { registerDisposer } from "../effect/instance-registry"
|
||||
import { forkInstance, runCallbackInstance, runPromiseInstance } from "../effect/runtime"
|
||||
|
||||
export namespace Bus {
|
||||
const log = Log.create({ service: "bus" })
|
||||
type Subscription = (event: any) => void
|
||||
|
||||
export const InstanceDisposed = BusEvent.define(
|
||||
"server.instance.disposed",
|
||||
@@ -15,91 +17,125 @@ export namespace Bus {
|
||||
}),
|
||||
)
|
||||
|
||||
const state = Instance.state(
|
||||
() => {
|
||||
const subscriptions = new Map<any, Subscription[]>()
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
subscriptions,
|
||||
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
|
||||
type: D["type"]
|
||||
properties: z.infer<D["properties"]>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly publish: <D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
properties: z.output<D["properties"]>,
|
||||
) => Effect.Effect<void>
|
||||
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
||||
readonly subscribeAll: () => Stream.Stream<Payload>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const pubsubs = new Map<string, PubSub.PubSub<Payload>>()
|
||||
const wildcardPubSub = yield* PubSub.unbounded<Payload>()
|
||||
|
||||
const getOrCreate = Effect.fnUntraced(function* (type: string) {
|
||||
let ps = pubsubs.get(type)
|
||||
if (!ps) {
|
||||
ps = yield* PubSub.unbounded<Payload>()
|
||||
pubsubs.set(type, ps)
|
||||
}
|
||||
return ps
|
||||
})
|
||||
|
||||
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||
return Effect.gen(function* () {
|
||||
const payload: Payload = { type: def.type, properties }
|
||||
log.info("publishing", { type: def.type })
|
||||
|
||||
const ps = pubsubs.get(def.type)
|
||||
if (ps) yield* PubSub.publish(ps, payload)
|
||||
yield* PubSub.publish(wildcardPubSub, payload)
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory: Instance.directory,
|
||||
payload,
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
async (entry) => {
|
||||
const wildcard = entry.subscriptions.get("*")
|
||||
if (!wildcard) return
|
||||
const event = {
|
||||
type: InstanceDisposed.type,
|
||||
properties: {
|
||||
directory: Instance.directory,
|
||||
},
|
||||
|
||||
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
|
||||
log.info("subscribing", { type: def.type })
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const ps = yield* getOrCreate(def.type)
|
||||
return Stream.fromPubSub(ps) as Stream.Stream<Payload<D>>
|
||||
}),
|
||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
||||
}
|
||||
for (const sub of [...wildcard]) {
|
||||
sub(event)
|
||||
|
||||
function subscribeAll(): Stream.Stream<Payload> {
|
||||
log.info("subscribing", { type: "*" })
|
||||
return Stream.fromPubSub(wildcardPubSub).pipe(
|
||||
Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
return Service.of({ publish, subscribe, subscribeAll })
|
||||
}),
|
||||
)
|
||||
|
||||
export async function publish<Definition extends BusEvent.Definition>(
|
||||
def: Definition,
|
||||
properties: z.output<Definition["properties"]>,
|
||||
) {
|
||||
const payload = {
|
||||
type: def.type,
|
||||
properties,
|
||||
}
|
||||
log.info("publishing", {
|
||||
type: def.type,
|
||||
})
|
||||
const pending = []
|
||||
for (const key of [def.type, "*"]) {
|
||||
const match = state().subscriptions.get(key)
|
||||
for (const sub of match ?? []) {
|
||||
pending.push(sub(payload))
|
||||
}
|
||||
}
|
||||
GlobalBus.emit("event", {
|
||||
directory: Instance.directory,
|
||||
payload,
|
||||
})
|
||||
return Promise.all(pending)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy adapters — plain function API wrapping the Effect service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runStream(stream: (svc: Interface) => Stream.Stream<Payload>, callback: (event: any) => void) {
|
||||
return runCallbackInstance(
|
||||
Service.use((svc) => stream(svc).pipe(Stream.runForEach((msg) => Effect.sync(() => callback(msg))))),
|
||||
)
|
||||
}
|
||||
|
||||
export function subscribe<Definition extends BusEvent.Definition>(
|
||||
def: Definition,
|
||||
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
|
||||
) {
|
||||
return raw(def.type, callback)
|
||||
export function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||
return runPromiseInstance(Service.use((svc) => svc.publish(def, properties)))
|
||||
}
|
||||
|
||||
export function once<Definition extends BusEvent.Definition>(
|
||||
def: Definition,
|
||||
callback: (event: {
|
||||
type: Definition["type"]
|
||||
properties: z.infer<Definition["properties"]>
|
||||
}) => "done" | undefined,
|
||||
) {
|
||||
const unsub = subscribe(def, (event) => {
|
||||
if (callback(event)) unsub()
|
||||
})
|
||||
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => void) {
|
||||
return runStream((svc) => svc.subscribe(def), callback)
|
||||
}
|
||||
|
||||
export function subscribeAll(callback: (event: any) => void) {
|
||||
return raw("*", callback)
|
||||
}
|
||||
const directory = Instance.directory
|
||||
let manualUnsub = false
|
||||
|
||||
function raw(type: string, callback: (event: any) => void) {
|
||||
log.info("subscribing", { type })
|
||||
const subscriptions = state().subscriptions
|
||||
let match = subscriptions.get(type) ?? []
|
||||
match.push(callback)
|
||||
subscriptions.set(type, match)
|
||||
const fiber = forkInstance(
|
||||
Service.use((svc) =>
|
||||
svc.subscribeAll().pipe(Stream.runForEach((msg) => Effect.sync(() => callback(msg)))),
|
||||
).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (!manualUnsub) {
|
||||
callback({ type: InstanceDisposed.type, properties: { directory } })
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Interrupt the fiber before the layer is invalidated, awaiting
|
||||
// completion so the refCount drops and the scope can close.
|
||||
const unregister = registerDisposer(
|
||||
(dir) => (dir !== directory ? Promise.resolve() : Effect.runPromise(Fiber.interrupt(fiber))),
|
||||
-1,
|
||||
)
|
||||
|
||||
return () => {
|
||||
log.info("unsubscribing", { type })
|
||||
const match = subscriptions.get(type)
|
||||
if (!match) return
|
||||
const index = match.indexOf(callback)
|
||||
if (index === -1) return
|
||||
match.splice(index, 1)
|
||||
manualUnsub = true
|
||||
unregister()
|
||||
fiber.interruptUnsafe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cmd } from "./cmd"
|
||||
import { Duration, Effect, Match, Option } from "effect"
|
||||
import { UI } from "../ui"
|
||||
import { runtime } from "@/effect/runtime"
|
||||
import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
|
||||
import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect"
|
||||
import { type AccountError } from "@/account/schema"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
@@ -17,7 +17,7 @@ const isActiveOrgChoice = (
|
||||
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
|
||||
|
||||
const loginEffect = Effect.fn("login")(function* (url: string) {
|
||||
const service = yield* AccountService
|
||||
const service = yield* AccountEffect.Service
|
||||
|
||||
yield* Prompt.intro("Log in")
|
||||
const login = yield* service.login(url)
|
||||
@@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
|
||||
})
|
||||
|
||||
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
|
||||
const service = yield* AccountService
|
||||
const service = yield* AccountEffect.Service
|
||||
const accounts = yield* service.list()
|
||||
if (accounts.length === 0) return yield* println("Not logged in")
|
||||
|
||||
@@ -98,7 +98,7 @@ interface OrgChoice {
|
||||
}
|
||||
|
||||
const switchEffect = Effect.fn("switch")(function* () {
|
||||
const service = yield* AccountService
|
||||
const service = yield* AccountEffect.Service
|
||||
|
||||
const groups = yield* service.orgsByAccount()
|
||||
if (groups.length === 0) return yield* println("Not logged in")
|
||||
@@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
|
||||
})
|
||||
|
||||
const orgsEffect = Effect.fn("orgs")(function* () {
|
||||
const service = yield* AccountService
|
||||
const service = yield* AccountEffect.Service
|
||||
|
||||
const groups = yield* service.orgsByAccount()
|
||||
if (groups.length === 0) return yield* println("No accounts found")
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
|
||||
import { MessageID, PartID } from "../../../session/schema"
|
||||
import { ToolRegistry } from "../../../tool/registry"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { PermissionNext } from "../../../permission/next"
|
||||
import { PermissionNext } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
@@ -51,7 +51,7 @@ export const ModelsCommand = cmd({
|
||||
}
|
||||
|
||||
if (args.provider) {
|
||||
const provider = providers[args.provider]
|
||||
const provider = providers[ProviderID.make(args.provider)]
|
||||
if (!provider) {
|
||||
UI.error(`Provider not found: ${args.provider}`)
|
||||
return
|
||||
|
||||
@@ -112,21 +112,15 @@ export const PrCommand = cmd({
|
||||
UI.println("Starting opencode...")
|
||||
UI.println()
|
||||
|
||||
// Launch opencode TUI with session ID if available
|
||||
const { spawn } = await import("child_process")
|
||||
const opencodeArgs = sessionId ? ["-s", sessionId] : []
|
||||
const opencodeProcess = spawn("opencode", opencodeArgs, {
|
||||
stdio: "inherit",
|
||||
const opencodeProcess = Process.spawn(["opencode", ...opencodeArgs], {
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
opencodeProcess.on("exit", (code) => {
|
||||
if (code === 0) resolve()
|
||||
else reject(new Error(`opencode exited with code ${code}`))
|
||||
})
|
||||
opencodeProcess.on("error", reject)
|
||||
})
|
||||
const code = await opencodeProcess.exited
|
||||
if (code !== 0) throw new Error(`opencode exited with code ${code}`)
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@@ -46,9 +46,13 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
const inputs: Record<string, string> = {}
|
||||
if (method.prompts) {
|
||||
for (const prompt of method.prompts) {
|
||||
if (prompt.condition && !prompt.condition(inputs)) {
|
||||
continue
|
||||
if (prompt.when) {
|
||||
const value = inputs[prompt.when.key]
|
||||
if (value === undefined) continue
|
||||
const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
|
||||
if (!matches) continue
|
||||
}
|
||||
if (prompt.condition && !prompt.condition(inputs)) continue
|
||||
if (prompt.type === "select") {
|
||||
const value = await prompts.select({
|
||||
message: prompt.message,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
|
||||
import { Server } from "../../server/server"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { PermissionNext } from "../../permission/next"
|
||||
import { PermissionNext } from "../../permission"
|
||||
import { Tool } from "../../tool/tool"
|
||||
import { GlobTool } from "../../tool/glob"
|
||||
import { GrepTool } from "../../tool/grep"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { Link } from "../ui/link"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2"
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
|
||||
import { DialogModel } from "./dialog-model"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
@@ -27,6 +27,7 @@ export function createDialogProviderOptions() {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const options = createMemo(() => {
|
||||
return pipe(
|
||||
sync.data.provider_next.all,
|
||||
@@ -69,10 +70,29 @@ export function createDialogProviderOptions() {
|
||||
if (index == null) return
|
||||
const method = methods[index]
|
||||
if (method.type === "oauth") {
|
||||
let inputs: Record<string, string> | undefined
|
||||
if (method.prompts?.length) {
|
||||
const value = await PromptsMethod({
|
||||
dialog,
|
||||
prompts: method.prompts,
|
||||
})
|
||||
if (!value) return
|
||||
inputs = value
|
||||
}
|
||||
|
||||
const result = await sdk.client.provider.oauth.authorize({
|
||||
providerID: provider.id,
|
||||
method: index,
|
||||
inputs,
|
||||
})
|
||||
if (result.error) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message: JSON.stringify(result.error),
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
if (result.data?.method === "code") {
|
||||
dialog.replace(() => (
|
||||
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
|
||||
@@ -257,3 +277,53 @@ function ApiMethod(props: ApiMethodProps) {
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface PromptsMethodProps {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
prompts: NonNullable<ProviderAuthMethod["prompts"]>[number][]
|
||||
}
|
||||
async function PromptsMethod(props: PromptsMethodProps) {
|
||||
const inputs: Record<string, string> = {}
|
||||
for (const prompt of props.prompts) {
|
||||
if (prompt.when) {
|
||||
const value = inputs[prompt.when.key]
|
||||
if (value === undefined) continue
|
||||
const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
|
||||
if (!matches) continue
|
||||
}
|
||||
|
||||
if (prompt.type === "select") {
|
||||
const value = await new Promise<string | null>((resolve) => {
|
||||
props.dialog.replace(
|
||||
() => (
|
||||
<DialogSelect
|
||||
title={prompt.message}
|
||||
options={prompt.options.map((x) => ({
|
||||
title: x.label,
|
||||
value: x.value,
|
||||
description: x.hint,
|
||||
}))}
|
||||
onSelect={(option) => resolve(option.value)}
|
||||
/>
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
if (value === null) return null
|
||||
inputs[prompt.key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
const value = await new Promise<string | null>((resolve) => {
|
||||
props.dialog.replace(
|
||||
() => (
|
||||
<DialogPrompt title={prompt.message} placeholder={prompt.placeholder} onConfirm={(value) => resolve(value)} />
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
if (value === null) return null
|
||||
inputs[prompt.key] = value
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export namespace Workspace {
|
||||
await parseSSE(res.body, stop, (event) => {
|
||||
GlobalBus.emit("event", {
|
||||
directory: space.id,
|
||||
payload: event,
|
||||
payload: event as { type: string; properties: Record<string, unknown> },
|
||||
})
|
||||
})
|
||||
// Wait 250ms and retry if SSE connection fails
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Project } from "@/project/project"
|
||||
export declare namespace InstanceContext {
|
||||
export interface Shape {
|
||||
readonly directory: string
|
||||
readonly worktree: string
|
||||
readonly project: Project.Info
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
const disposers = new Set<(directory: string) => Promise<void>>()
|
||||
const disposers = new Set<{
|
||||
fn: (directory: string) => Promise<void>
|
||||
priority: number
|
||||
}>()
|
||||
|
||||
export function registerDisposer(disposer: (directory: string) => Promise<void>) {
|
||||
disposers.add(disposer)
|
||||
export function registerDisposer(disposer: (directory: string) => Promise<void>, priority = 0) {
|
||||
const item = {
|
||||
fn: disposer,
|
||||
priority,
|
||||
}
|
||||
disposers.add(item)
|
||||
return () => {
|
||||
disposers.delete(disposer)
|
||||
disposers.delete(item)
|
||||
}
|
||||
}
|
||||
|
||||
export async function disposeInstance(directory: string) {
|
||||
await Promise.allSettled([...disposers].map((disposer) => disposer(directory)))
|
||||
const list = [...disposers].sort((a, b) => a.priority - b.priority)
|
||||
const seen = new Set<number>()
|
||||
for (const item of list) {
|
||||
if (seen.has(item.priority)) continue
|
||||
seen.add(item.priority)
|
||||
await Promise.allSettled(list.filter((x) => x.priority === item.priority).map((x) => x.fn(directory)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,54 @@
|
||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
import { InstanceContext } from "./instance-context"
|
||||
import { ProviderAuthService } from "@/provider/auth-service"
|
||||
import { QuestionService } from "@/question/service"
|
||||
import { PermissionService } from "@/permission/service"
|
||||
import { FileWatcherService } from "@/file/watcher"
|
||||
import { VcsService } from "@/project/vcs"
|
||||
import { FileTimeService } from "@/file/time"
|
||||
import { FormatService } from "@/format"
|
||||
import { FileService } from "@/file"
|
||||
import { SkillService } from "@/skill/skill"
|
||||
import { Bus } from "@/bus"
|
||||
import { File } from "@/file"
|
||||
import { FileTime } from "@/file/time"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Format } from "@/format"
|
||||
import { PermissionNext } from "@/permission"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { Question } from "@/question"
|
||||
import { Skill } from "@/skill/skill"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { InstanceContext } from "./instance-context"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
|
||||
export { InstanceContext } from "./instance-context"
|
||||
|
||||
export type InstanceServices =
|
||||
| QuestionService
|
||||
| PermissionService
|
||||
| ProviderAuthService
|
||||
| FileWatcherService
|
||||
| VcsService
|
||||
| FileTimeService
|
||||
| FormatService
|
||||
| FileService
|
||||
| SkillService
|
||||
| Bus.Service
|
||||
| Question.Service
|
||||
| PermissionNext.Service
|
||||
| ProviderAuth.Service
|
||||
| FileWatcher.Service
|
||||
| Vcs.Service
|
||||
| FileTime.Service
|
||||
| Format.Service
|
||||
| File.Service
|
||||
| Skill.Service
|
||||
| Snapshot.Service
|
||||
|
||||
function lookup(directory: string) {
|
||||
const project = Instance.project
|
||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project }))
|
||||
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
|
||||
// the full instance context (directory, worktree, project). We read from the
|
||||
// legacy Instance ALS here, which is safe because lookup is only triggered via
|
||||
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
|
||||
// This should go away once the old Instance type is removed and lookup can load
|
||||
// the full context directly.
|
||||
function lookup(_key: string) {
|
||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
||||
return Layer.mergeAll(
|
||||
Layer.fresh(QuestionService.layer),
|
||||
Layer.fresh(PermissionService.layer),
|
||||
Layer.fresh(ProviderAuthService.layer),
|
||||
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(VcsService.layer),
|
||||
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(FormatService.layer),
|
||||
Layer.fresh(FileService.layer),
|
||||
Layer.fresh(SkillService.layer),
|
||||
Layer.fresh(Bus.layer),
|
||||
Layer.fresh(Question.layer),
|
||||
Layer.fresh(PermissionNext.layer),
|
||||
Layer.fresh(ProviderAuth.defaultLayer),
|
||||
Layer.fresh(FileWatcher.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(Vcs.layer),
|
||||
Layer.fresh(FileTime.layer).pipe(Layer.orDie),
|
||||
Layer.fresh(Format.layer),
|
||||
Layer.fresh(File.layer),
|
||||
Layer.fresh(Skill.defaultLayer),
|
||||
Layer.fresh(Snapshot.defaultLayer),
|
||||
).pipe(Layer.provide(ctx))
|
||||
}
|
||||
|
||||
@@ -57,8 +68,4 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
|
||||
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
|
||||
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
|
||||
}
|
||||
|
||||
static invalidate(directory: string): Effect.Effect<void, never, Instances> {
|
||||
return Instances.use((map) => map.invalidate(directory))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { AccountService } from "@/account/service"
|
||||
import { AuthService } from "@/auth/service"
|
||||
import { Effect, Fiber, Layer, ManagedRuntime } from "effect"
|
||||
import { AccountEffect } from "@/account/effect"
|
||||
import { AuthEffect } from "@/auth/effect"
|
||||
import { Instances } from "@/effect/instances"
|
||||
import type { InstanceServices } from "@/effect/instances"
|
||||
import { TruncateEffect } from "@/tool/truncate-effect"
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
export const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)),
|
||||
Layer.mergeAll(
|
||||
AccountEffect.defaultLayer, //
|
||||
TruncateEffect.defaultLayer,
|
||||
Instances.layer,
|
||||
).pipe(Layer.provideMerge(AuthEffect.layer)),
|
||||
)
|
||||
|
||||
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
|
||||
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
||||
}
|
||||
|
||||
export function forkInstance<A, E>(
|
||||
effect: Effect.Effect<A, E, InstanceServices>,
|
||||
): Fiber.Fiber<A, E> {
|
||||
return runtime.runFork(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
||||
}
|
||||
|
||||
export function runCallbackInstance<A, E>(
|
||||
effect: Effect.Effect<A, E, InstanceServices>,
|
||||
): (interruptor?: number) => void {
|
||||
return runtime.runCallback(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
|
||||
}
|
||||
|
||||
export function disposeRuntime() {
|
||||
return runtime.dispose()
|
||||
}
|
||||
|
||||
@@ -1,272 +1,20 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import z from "zod"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import ignore from "ignore"
|
||||
import { Log } from "../util/log"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { Global } from "../global"
|
||||
import { git } from "@/util/git"
|
||||
import { Protected } from "./protected"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
|
||||
const log = Log.create({ service: "file" })
|
||||
|
||||
const binaryExtensions = new Set([
|
||||
"exe",
|
||||
"dll",
|
||||
"pdb",
|
||||
"bin",
|
||||
"so",
|
||||
"dylib",
|
||||
"o",
|
||||
"a",
|
||||
"lib",
|
||||
"wav",
|
||||
"mp3",
|
||||
"ogg",
|
||||
"oga",
|
||||
"ogv",
|
||||
"ogx",
|
||||
"flac",
|
||||
"aac",
|
||||
"wma",
|
||||
"m4a",
|
||||
"weba",
|
||||
"mp4",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"webm",
|
||||
"mkv",
|
||||
"zip",
|
||||
"tar",
|
||||
"gz",
|
||||
"gzip",
|
||||
"bz",
|
||||
"bz2",
|
||||
"bzip",
|
||||
"bzip2",
|
||||
"7z",
|
||||
"rar",
|
||||
"xz",
|
||||
"lz",
|
||||
"z",
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"dmg",
|
||||
"iso",
|
||||
"img",
|
||||
"vmdk",
|
||||
"ttf",
|
||||
"otf",
|
||||
"woff",
|
||||
"woff2",
|
||||
"eot",
|
||||
"sqlite",
|
||||
"db",
|
||||
"mdb",
|
||||
"apk",
|
||||
"ipa",
|
||||
"aab",
|
||||
"xapk",
|
||||
"app",
|
||||
"pkg",
|
||||
"deb",
|
||||
"rpm",
|
||||
"snap",
|
||||
"flatpak",
|
||||
"appimage",
|
||||
"msi",
|
||||
"msp",
|
||||
"jar",
|
||||
"war",
|
||||
"ear",
|
||||
"class",
|
||||
"kotlin_module",
|
||||
"dex",
|
||||
"vdex",
|
||||
"odex",
|
||||
"oat",
|
||||
"art",
|
||||
"wasm",
|
||||
"wat",
|
||||
"bc",
|
||||
"ll",
|
||||
"s",
|
||||
"ko",
|
||||
"sys",
|
||||
"drv",
|
||||
"efi",
|
||||
"rom",
|
||||
"com",
|
||||
"cmd",
|
||||
"ps1",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
])
|
||||
|
||||
const imageExtensions = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"bmp",
|
||||
"webp",
|
||||
"ico",
|
||||
"tif",
|
||||
"tiff",
|
||||
"svg",
|
||||
"svgz",
|
||||
"avif",
|
||||
"apng",
|
||||
"jxl",
|
||||
"heic",
|
||||
"heif",
|
||||
"raw",
|
||||
"cr2",
|
||||
"nef",
|
||||
"arw",
|
||||
"dng",
|
||||
"orf",
|
||||
"raf",
|
||||
"pef",
|
||||
"x3f",
|
||||
])
|
||||
|
||||
const textExtensions = new Set([
|
||||
"ts",
|
||||
"tsx",
|
||||
"mts",
|
||||
"cts",
|
||||
"mtsx",
|
||||
"ctsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
"ps1",
|
||||
"psm1",
|
||||
"cmd",
|
||||
"bat",
|
||||
"json",
|
||||
"jsonc",
|
||||
"json5",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"md",
|
||||
"mdx",
|
||||
"txt",
|
||||
"xml",
|
||||
"html",
|
||||
"htm",
|
||||
"css",
|
||||
"scss",
|
||||
"sass",
|
||||
"less",
|
||||
"graphql",
|
||||
"gql",
|
||||
"sql",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
"env",
|
||||
])
|
||||
|
||||
const textNames = new Set([
|
||||
"dockerfile",
|
||||
"makefile",
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
".editorconfig",
|
||||
".npmrc",
|
||||
".nvmrc",
|
||||
".prettierrc",
|
||||
".eslintrc",
|
||||
])
|
||||
|
||||
function isImageByExtension(filepath: string): boolean {
|
||||
const ext = path.extname(filepath).toLowerCase().slice(1)
|
||||
return imageExtensions.has(ext)
|
||||
}
|
||||
|
||||
function isTextByExtension(filepath: string): boolean {
|
||||
const ext = path.extname(filepath).toLowerCase().slice(1)
|
||||
return textExtensions.has(ext)
|
||||
}
|
||||
|
||||
function isTextByName(filepath: string): boolean {
|
||||
const name = path.basename(filepath).toLowerCase()
|
||||
return textNames.has(name)
|
||||
}
|
||||
|
||||
function getImageMimeType(filepath: string): string {
|
||||
const ext = path.extname(filepath).toLowerCase().slice(1)
|
||||
const mimeTypes: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
bmp: "image/bmp",
|
||||
webp: "image/webp",
|
||||
ico: "image/x-icon",
|
||||
tif: "image/tiff",
|
||||
tiff: "image/tiff",
|
||||
svg: "image/svg+xml",
|
||||
svgz: "image/svg+xml",
|
||||
avif: "image/avif",
|
||||
apng: "image/apng",
|
||||
jxl: "image/jxl",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
}
|
||||
return mimeTypes[ext] || "image/" + ext
|
||||
}
|
||||
|
||||
function isBinaryByExtension(filepath: string): boolean {
|
||||
const ext = path.extname(filepath).toLowerCase().slice(1)
|
||||
return binaryExtensions.has(ext)
|
||||
}
|
||||
|
||||
function isImage(mimeType: string): boolean {
|
||||
return mimeType.startsWith("image/")
|
||||
}
|
||||
|
||||
function shouldEncode(mimeType: string): boolean {
|
||||
const type = mimeType.toLowerCase()
|
||||
log.info("shouldEncode", { type })
|
||||
if (!type) return false
|
||||
|
||||
if (type.startsWith("text/")) return false
|
||||
if (type.includes("charset=")) return false
|
||||
|
||||
const parts = type.split("/", 2)
|
||||
const top = parts[0]
|
||||
|
||||
const tops = ["image", "audio", "video", "font", "model", "multipart"]
|
||||
if (tops.includes(top)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
import { git } from "@/util/git"
|
||||
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import fs from "fs"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import ignore from "ignore"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
import { Protected } from "./protected"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
|
||||
export namespace File {
|
||||
export const Info = z
|
||||
@@ -336,28 +84,270 @@ export namespace File {
|
||||
}
|
||||
|
||||
export function init() {
|
||||
return runPromiseInstance(FileService.use((s) => s.init()))
|
||||
return runPromiseInstance(Service.use((svc) => svc.init()))
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return runPromiseInstance(FileService.use((s) => s.status()))
|
||||
return runPromiseInstance(Service.use((svc) => svc.status()))
|
||||
}
|
||||
|
||||
export async function read(file: string): Promise<Content> {
|
||||
return runPromiseInstance(FileService.use((s) => s.read(file)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.read(file)))
|
||||
}
|
||||
|
||||
export async function list(dir?: string) {
|
||||
return runPromiseInstance(FileService.use((s) => s.list(dir)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.list(dir)))
|
||||
}
|
||||
|
||||
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
||||
return runPromiseInstance(FileService.use((s) => s.search(input)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.search(input)))
|
||||
}
|
||||
}
|
||||
|
||||
export namespace FileService {
|
||||
export interface Service {
|
||||
const log = Log.create({ service: "file" })
|
||||
|
||||
const binary = new Set([
|
||||
"exe",
|
||||
"dll",
|
||||
"pdb",
|
||||
"bin",
|
||||
"so",
|
||||
"dylib",
|
||||
"o",
|
||||
"a",
|
||||
"lib",
|
||||
"wav",
|
||||
"mp3",
|
||||
"ogg",
|
||||
"oga",
|
||||
"ogv",
|
||||
"ogx",
|
||||
"flac",
|
||||
"aac",
|
||||
"wma",
|
||||
"m4a",
|
||||
"weba",
|
||||
"mp4",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"webm",
|
||||
"mkv",
|
||||
"zip",
|
||||
"tar",
|
||||
"gz",
|
||||
"gzip",
|
||||
"bz",
|
||||
"bz2",
|
||||
"bzip",
|
||||
"bzip2",
|
||||
"7z",
|
||||
"rar",
|
||||
"xz",
|
||||
"lz",
|
||||
"z",
|
||||
"pdf",
|
||||
"doc",
|
||||
"docx",
|
||||
"ppt",
|
||||
"pptx",
|
||||
"xls",
|
||||
"xlsx",
|
||||
"dmg",
|
||||
"iso",
|
||||
"img",
|
||||
"vmdk",
|
||||
"ttf",
|
||||
"otf",
|
||||
"woff",
|
||||
"woff2",
|
||||
"eot",
|
||||
"sqlite",
|
||||
"db",
|
||||
"mdb",
|
||||
"apk",
|
||||
"ipa",
|
||||
"aab",
|
||||
"xapk",
|
||||
"app",
|
||||
"pkg",
|
||||
"deb",
|
||||
"rpm",
|
||||
"snap",
|
||||
"flatpak",
|
||||
"appimage",
|
||||
"msi",
|
||||
"msp",
|
||||
"jar",
|
||||
"war",
|
||||
"ear",
|
||||
"class",
|
||||
"kotlin_module",
|
||||
"dex",
|
||||
"vdex",
|
||||
"odex",
|
||||
"oat",
|
||||
"art",
|
||||
"wasm",
|
||||
"wat",
|
||||
"bc",
|
||||
"ll",
|
||||
"s",
|
||||
"ko",
|
||||
"sys",
|
||||
"drv",
|
||||
"efi",
|
||||
"rom",
|
||||
"com",
|
||||
"cmd",
|
||||
"ps1",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
])
|
||||
|
||||
const image = new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"bmp",
|
||||
"webp",
|
||||
"ico",
|
||||
"tif",
|
||||
"tiff",
|
||||
"svg",
|
||||
"svgz",
|
||||
"avif",
|
||||
"apng",
|
||||
"jxl",
|
||||
"heic",
|
||||
"heif",
|
||||
"raw",
|
||||
"cr2",
|
||||
"nef",
|
||||
"arw",
|
||||
"dng",
|
||||
"orf",
|
||||
"raf",
|
||||
"pef",
|
||||
"x3f",
|
||||
])
|
||||
|
||||
const text = new Set([
|
||||
"ts",
|
||||
"tsx",
|
||||
"mts",
|
||||
"cts",
|
||||
"mtsx",
|
||||
"ctsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"mjs",
|
||||
"cjs",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
"ps1",
|
||||
"psm1",
|
||||
"cmd",
|
||||
"bat",
|
||||
"json",
|
||||
"jsonc",
|
||||
"json5",
|
||||
"yaml",
|
||||
"yml",
|
||||
"toml",
|
||||
"md",
|
||||
"mdx",
|
||||
"txt",
|
||||
"xml",
|
||||
"html",
|
||||
"htm",
|
||||
"css",
|
||||
"scss",
|
||||
"sass",
|
||||
"less",
|
||||
"graphql",
|
||||
"gql",
|
||||
"sql",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
"env",
|
||||
])
|
||||
|
||||
const textName = new Set([
|
||||
"dockerfile",
|
||||
"makefile",
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
".editorconfig",
|
||||
".npmrc",
|
||||
".nvmrc",
|
||||
".prettierrc",
|
||||
".eslintrc",
|
||||
])
|
||||
|
||||
const mime: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
bmp: "image/bmp",
|
||||
webp: "image/webp",
|
||||
ico: "image/x-icon",
|
||||
tif: "image/tiff",
|
||||
tiff: "image/tiff",
|
||||
svg: "image/svg+xml",
|
||||
svgz: "image/svg+xml",
|
||||
avif: "image/avif",
|
||||
apng: "image/apng",
|
||||
jxl: "image/jxl",
|
||||
heic: "image/heic",
|
||||
heif: "image/heif",
|
||||
}
|
||||
|
||||
type Entry = { files: string[]; dirs: string[] }
|
||||
|
||||
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
|
||||
const name = (file: string) => path.basename(file).toLowerCase()
|
||||
const isImageByExtension = (file: string) => image.has(ext(file))
|
||||
const isTextByExtension = (file: string) => text.has(ext(file))
|
||||
const isTextByName = (file: string) => textName.has(name(file))
|
||||
const isBinaryByExtension = (file: string) => binary.has(ext(file))
|
||||
const isImage = (mimeType: string) => mimeType.startsWith("image/")
|
||||
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
|
||||
|
||||
function shouldEncode(mimeType: string) {
|
||||
const type = mimeType.toLowerCase()
|
||||
log.info("shouldEncode", { type })
|
||||
if (!type) return false
|
||||
if (type.startsWith("text/")) return false
|
||||
if (type.includes("charset=")) return false
|
||||
const top = type.split("/", 2)[0]
|
||||
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
|
||||
}
|
||||
|
||||
const hidden = (item: string) => {
|
||||
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
|
||||
}
|
||||
|
||||
const sortHiddenLast = (items: string[], prefer: boolean) => {
|
||||
if (prefer) return items
|
||||
const visible: string[] = []
|
||||
const hiddenItems: string[] = []
|
||||
for (const item of items) {
|
||||
if (hidden(item)) hiddenItems.push(item)
|
||||
else visible.push(item)
|
||||
}
|
||||
return [...visible, ...hiddenItems]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<File.Info[]>
|
||||
readonly read: (file: string) => Effect.Effect<File.Content>
|
||||
@@ -369,89 +359,83 @@ export namespace FileService {
|
||||
type?: "file" | "directory"
|
||||
}) => Effect.Effect<string[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class FileService extends ServiceMap.Service<FileService, FileService.Service>()("@opencode/File") {
|
||||
static readonly layer = Layer.effect(
|
||||
FileService,
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
|
||||
// File cache state
|
||||
type Entry = { files: string[]; dirs: string[] }
|
||||
let cache: Entry = { files: [], dirs: [] }
|
||||
let task: Promise<void> | undefined
|
||||
|
||||
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
|
||||
|
||||
function kick() {
|
||||
if (task) return task
|
||||
task = (async () => {
|
||||
// Disable scanning if in root of file system
|
||||
if (instance.directory === path.parse(instance.directory).root) return
|
||||
const next: Entry = { files: [], dirs: [] }
|
||||
try {
|
||||
if (isGlobalHome) {
|
||||
const dirs = new Set<string>()
|
||||
const protectedNames = Protected.names()
|
||||
const scan = Effect.fn("File.scan")(function* () {
|
||||
if (instance.directory === path.parse(instance.directory).root) return
|
||||
const next: Entry = { files: [], dirs: [] }
|
||||
|
||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||
yield* Effect.promise(async () => {
|
||||
if (isGlobalHome) {
|
||||
const dirs = new Set<string>()
|
||||
const protectedNames = Protected.names()
|
||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||
const top = await fs.promises
|
||||
.readdir(instance.directory, { withFileTypes: true })
|
||||
.catch(() => [] as fs.Dirent[])
|
||||
|
||||
const top = await fs.promises
|
||||
.readdir(instance.directory, { withFileTypes: true })
|
||||
.catch(() => [] as fs.Dirent[])
|
||||
for (const entry of top) {
|
||||
if (!entry.isDirectory()) continue
|
||||
if (shouldIgnoreName(entry.name)) continue
|
||||
dirs.add(entry.name + "/")
|
||||
|
||||
for (const entry of top) {
|
||||
if (!entry.isDirectory()) continue
|
||||
if (shouldIgnoreName(entry.name)) continue
|
||||
dirs.add(entry.name + "/")
|
||||
|
||||
const base = path.join(instance.directory, entry.name)
|
||||
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
||||
for (const child of children) {
|
||||
if (!child.isDirectory()) continue
|
||||
if (shouldIgnoreNested(child.name)) continue
|
||||
dirs.add(entry.name + "/" + child.name + "/")
|
||||
}
|
||||
}
|
||||
|
||||
next.dirs = Array.from(dirs).toSorted()
|
||||
} else {
|
||||
const set = new Set<string>()
|
||||
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
|
||||
next.files.push(file)
|
||||
let current = file
|
||||
while (true) {
|
||||
const dir = path.dirname(current)
|
||||
if (dir === ".") break
|
||||
if (dir === current) break
|
||||
current = dir
|
||||
if (set.has(dir)) continue
|
||||
set.add(dir)
|
||||
next.dirs.push(dir + "/")
|
||||
}
|
||||
const base = path.join(instance.directory, entry.name)
|
||||
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
||||
for (const child of children) {
|
||||
if (!child.isDirectory()) continue
|
||||
if (shouldIgnoreNested(child.name)) continue
|
||||
dirs.add(entry.name + "/" + child.name + "/")
|
||||
}
|
||||
}
|
||||
|
||||
next.dirs = Array.from(dirs).toSorted()
|
||||
} else {
|
||||
const seen = new Set<string>()
|
||||
for await (const file of Ripgrep.files({ cwd: instance.directory })) {
|
||||
next.files.push(file)
|
||||
let current = file
|
||||
while (true) {
|
||||
const dir = path.dirname(current)
|
||||
if (dir === ".") break
|
||||
if (dir === current) break
|
||||
current = dir
|
||||
if (seen.has(dir)) continue
|
||||
seen.add(dir)
|
||||
next.dirs.push(dir + "/")
|
||||
}
|
||||
}
|
||||
cache = next
|
||||
} finally {
|
||||
task = undefined
|
||||
}
|
||||
})()
|
||||
return task
|
||||
}
|
||||
})
|
||||
|
||||
const getFiles = async () => {
|
||||
void kick()
|
||||
return cache
|
||||
}
|
||||
|
||||
const init = Effect.fn("FileService.init")(function* () {
|
||||
yield* Effect.promise(() => kick())
|
||||
cache = next
|
||||
})
|
||||
|
||||
const status = Effect.fn("FileService.status")(function* () {
|
||||
const getFiles = () => cache
|
||||
|
||||
const scope = yield* Scope.Scope
|
||||
let fiber: Fiber.Fiber<void> | undefined
|
||||
|
||||
const init = Effect.fn("File.init")(function* () {
|
||||
if (!fiber) {
|
||||
fiber = yield* scan().pipe(
|
||||
Effect.catchCause(() => Effect.void),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
}
|
||||
yield* Fiber.join(fiber)
|
||||
})
|
||||
|
||||
const status = Effect.fn("File.status")(function* () {
|
||||
if (instance.project.vcs !== "git") return []
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
@@ -461,14 +445,13 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
})
|
||||
).text()
|
||||
|
||||
const changedFiles: File.Info[] = []
|
||||
const changed: File.Info[] = []
|
||||
|
||||
if (diffOutput.trim()) {
|
||||
const lines = diffOutput.trim().split("\n")
|
||||
for (const line of lines) {
|
||||
const [added, removed, filepath] = line.split("\t")
|
||||
changedFiles.push({
|
||||
path: filepath,
|
||||
for (const line of diffOutput.trim().split("\n")) {
|
||||
const [added, removed, file] = line.split("\t")
|
||||
changed.push({
|
||||
path: file,
|
||||
added: added === "-" ? 0 : parseInt(added, 10),
|
||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||
status: "modified",
|
||||
@@ -494,14 +477,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
).text()
|
||||
|
||||
if (untrackedOutput.trim()) {
|
||||
const untrackedFiles = untrackedOutput.trim().split("\n")
|
||||
for (const filepath of untrackedFiles) {
|
||||
for (const file of untrackedOutput.trim().split("\n")) {
|
||||
try {
|
||||
const content = await Filesystem.readText(path.join(instance.directory, filepath))
|
||||
const lines = content.split("\n").length
|
||||
changedFiles.push({
|
||||
path: filepath,
|
||||
added: lines,
|
||||
const content = await Filesystem.readText(path.join(instance.directory, file))
|
||||
changed.push({
|
||||
path: file,
|
||||
added: content.split("\n").length,
|
||||
removed: 0,
|
||||
status: "added",
|
||||
})
|
||||
@@ -511,7 +492,6 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
}
|
||||
}
|
||||
|
||||
// Get deleted files
|
||||
const deletedOutput = (
|
||||
await git(
|
||||
[
|
||||
@@ -531,50 +511,51 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
).text()
|
||||
|
||||
if (deletedOutput.trim()) {
|
||||
const deletedFiles = deletedOutput.trim().split("\n")
|
||||
for (const filepath of deletedFiles) {
|
||||
changedFiles.push({
|
||||
path: filepath,
|
||||
for (const file of deletedOutput.trim().split("\n")) {
|
||||
changed.push({
|
||||
path: file,
|
||||
added: 0,
|
||||
removed: 0, // Could get original line count but would require another git command
|
||||
removed: 0,
|
||||
status: "deleted",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changedFiles.map((x) => {
|
||||
const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path)
|
||||
return changed.map((item) => {
|
||||
const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
|
||||
return {
|
||||
...x,
|
||||
...item,
|
||||
path: path.relative(instance.directory, full),
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const read = Effect.fn("FileService.read")(function* (file: string) {
|
||||
const read = Effect.fn("File.read")(function* (file: string) {
|
||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||
using _ = log.time("read", { file })
|
||||
const full = path.join(instance.directory, file)
|
||||
|
||||
if (!Instance.containsPath(full)) {
|
||||
throw new Error(`Access denied: path escapes project directory`)
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
|
||||
// Fast path: check extension before any filesystem operations
|
||||
if (isImageByExtension(file)) {
|
||||
if (await Filesystem.exists(full)) {
|
||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||
const content = buffer.toString("base64")
|
||||
const mimeType = getImageMimeType(file)
|
||||
return { type: "text", content, mimeType, encoding: "base64" }
|
||||
return {
|
||||
type: "text",
|
||||
content: buffer.toString("base64"),
|
||||
mimeType: getImageMimeType(file),
|
||||
encoding: "base64",
|
||||
}
|
||||
}
|
||||
return { type: "text", content: "" }
|
||||
}
|
||||
|
||||
const text = isTextByExtension(file) || isTextByName(file)
|
||||
const knownText = isTextByExtension(file) || isTextByName(file)
|
||||
|
||||
if (isBinaryByExtension(file) && !text) {
|
||||
if (isBinaryByExtension(file) && !knownText) {
|
||||
return { type: "binary", content: "" }
|
||||
}
|
||||
|
||||
@@ -583,7 +564,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
}
|
||||
|
||||
const mimeType = Filesystem.mimeType(full)
|
||||
const encode = text ? false : shouldEncode(mimeType)
|
||||
const encode = knownText ? false : shouldEncode(mimeType)
|
||||
|
||||
if (encode && !isImage(mimeType)) {
|
||||
return { type: "binary", content: "", mimeType }
|
||||
@@ -591,8 +572,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
|
||||
if (encode) {
|
||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
||||
const content = buffer.toString("base64")
|
||||
return { type: "text", content, mimeType, encoding: "base64" }
|
||||
return {
|
||||
type: "text",
|
||||
content: buffer.toString("base64"),
|
||||
mimeType,
|
||||
encoding: "base64",
|
||||
}
|
||||
}
|
||||
|
||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
||||
@@ -603,7 +588,9 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
).text()
|
||||
if (!diff.trim()) {
|
||||
diff = (
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory })
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||
cwd: instance.directory,
|
||||
})
|
||||
).text()
|
||||
}
|
||||
if (diff.trim()) {
|
||||
@@ -612,64 +599,64 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
})
|
||||
const diff = formatPatch(patch)
|
||||
return { type: "text", content, patch, diff }
|
||||
return {
|
||||
type: "text",
|
||||
content,
|
||||
patch,
|
||||
diff: formatPatch(patch),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "text", content }
|
||||
})
|
||||
})
|
||||
|
||||
const list = Effect.fn("FileService.list")(function* (dir?: string) {
|
||||
const list = Effect.fn("File.list")(function* (dir?: string) {
|
||||
return yield* Effect.promise(async () => {
|
||||
const exclude = [".git", ".DS_Store"]
|
||||
let ignored = (_: string) => false
|
||||
if (instance.project.vcs === "git") {
|
||||
const ig = ignore()
|
||||
const gitignorePath = path.join(instance.project.worktree, ".gitignore")
|
||||
if (await Filesystem.exists(gitignorePath)) {
|
||||
ig.add(await Filesystem.readText(gitignorePath))
|
||||
const gitignore = path.join(instance.project.worktree, ".gitignore")
|
||||
if (await Filesystem.exists(gitignore)) {
|
||||
ig.add(await Filesystem.readText(gitignore))
|
||||
}
|
||||
const ignorePath = path.join(instance.project.worktree, ".ignore")
|
||||
if (await Filesystem.exists(ignorePath)) {
|
||||
ig.add(await Filesystem.readText(ignorePath))
|
||||
const ignoreFile = path.join(instance.project.worktree, ".ignore")
|
||||
if (await Filesystem.exists(ignoreFile)) {
|
||||
ig.add(await Filesystem.readText(ignoreFile))
|
||||
}
|
||||
ignored = ig.ignores.bind(ig)
|
||||
}
|
||||
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
|
||||
|
||||
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
|
||||
if (!Instance.containsPath(resolved)) {
|
||||
throw new Error(`Access denied: path escapes project directory`)
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
|
||||
const nodes: File.Node[] = []
|
||||
for (const entry of await fs.promises
|
||||
.readdir(resolved, {
|
||||
withFileTypes: true,
|
||||
})
|
||||
.catch(() => [])) {
|
||||
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
|
||||
if (exclude.includes(entry.name)) continue
|
||||
const fullPath = path.join(resolved, entry.name)
|
||||
const relativePath = path.relative(instance.directory, fullPath)
|
||||
const absolute = path.join(resolved, entry.name)
|
||||
const file = path.relative(instance.directory, absolute)
|
||||
const type = entry.isDirectory() ? "directory" : "file"
|
||||
nodes.push({
|
||||
name: entry.name,
|
||||
path: relativePath,
|
||||
absolute: fullPath,
|
||||
path: file,
|
||||
absolute,
|
||||
type,
|
||||
ignored: ignored(type === "directory" ? relativePath + "/" : relativePath),
|
||||
ignored: ignored(type === "directory" ? file + "/" : file),
|
||||
})
|
||||
}
|
||||
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1
|
||||
}
|
||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const search = Effect.fn("FileService.search")(function* (input: {
|
||||
const search = Effect.fn("File.search")(function* (input: {
|
||||
query: string
|
||||
limit?: number
|
||||
dirs?: boolean
|
||||
@@ -681,35 +668,20 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||
log.info("search", { query, kind })
|
||||
|
||||
const result = await getFiles()
|
||||
|
||||
const hidden = (item: string) => {
|
||||
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||
return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
|
||||
}
|
||||
const result = getFiles()
|
||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||
const sortHiddenLast = (items: string[]) => {
|
||||
if (preferHidden) return items
|
||||
const visible: string[] = []
|
||||
const hiddenItems: string[] = []
|
||||
for (const item of items) {
|
||||
const isHidden = hidden(item)
|
||||
if (isHidden) hiddenItems.push(item)
|
||||
if (!isHidden) visible.push(item)
|
||||
}
|
||||
return [...visible, ...hiddenItems]
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
if (kind === "file") return result.files.slice(0, limit)
|
||||
return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
|
||||
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
|
||||
}
|
||||
|
||||
const items =
|
||||
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
||||
|
||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target)
|
||||
const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted
|
||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
||||
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
||||
|
||||
log.info("search", { query, kind, results: output.length })
|
||||
return output
|
||||
@@ -717,8 +689,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
|
||||
})
|
||||
|
||||
log.info("init")
|
||||
|
||||
return FileService.of({ init, status, read, list, search })
|
||||
return Service.of({ init, status, read, list, search })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,115 +1,110 @@
|
||||
import { Log } from "../util/log"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Effect, Layer, ServiceMap, Semaphore } from "effect"
|
||||
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
const log = Log.create({ service: "file.time" })
|
||||
export namespace FileTime {
|
||||
const log = Log.create({ service: "file.time" })
|
||||
|
||||
export namespace FileTimeService {
|
||||
export interface Service {
|
||||
export type Stamp = {
|
||||
readonly read: Date
|
||||
readonly mtime: number | undefined
|
||||
readonly ctime: number | undefined
|
||||
readonly size: number | undefined
|
||||
}
|
||||
|
||||
const stamp = Effect.fnUntraced(function* (file: string) {
|
||||
const stat = Filesystem.stat(file)
|
||||
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
||||
return {
|
||||
read: yield* DateTime.nowAsDate,
|
||||
mtime: stat?.mtime?.getTime(),
|
||||
ctime: stat?.ctime?.getTime(),
|
||||
size,
|
||||
}
|
||||
})
|
||||
|
||||
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
||||
const value = reads.get(sessionID)
|
||||
if (value) return value
|
||||
|
||||
const next = new Map<string, Stamp>()
|
||||
reads.set(sessionID, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
||||
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
||||
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
||||
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
||||
}
|
||||
}
|
||||
|
||||
type Stamp = {
|
||||
readonly read: Date
|
||||
readonly mtime: number | undefined
|
||||
readonly ctime: number | undefined
|
||||
readonly size: number | undefined
|
||||
}
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
|
||||
|
||||
function stamp(file: string): Stamp {
|
||||
const stat = Filesystem.stat(file)
|
||||
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
||||
return {
|
||||
read: new Date(),
|
||||
mtime: stat?.mtime?.getTime(),
|
||||
ctime: stat?.ctime?.getTime(),
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
function session(reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) {
|
||||
let value = reads.get(sessionID)
|
||||
if (!value) {
|
||||
value = new Map<string, Stamp>()
|
||||
reads.set(sessionID, value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export class FileTimeService extends ServiceMap.Service<FileTimeService, FileTimeService.Service>()(
|
||||
"@opencode/FileTime",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
FileTimeService,
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||
const reads = new Map<SessionID, Map<string, Stamp>>()
|
||||
const locks = new Map<string, Semaphore.Semaphore>()
|
||||
|
||||
function getLock(filepath: string) {
|
||||
let lock = locks.get(filepath)
|
||||
if (!lock) {
|
||||
lock = Semaphore.makeUnsafe(1)
|
||||
locks.set(filepath, lock)
|
||||
}
|
||||
return lock
|
||||
const getLock = (filepath: string) => {
|
||||
const lock = locks.get(filepath)
|
||||
if (lock) return lock
|
||||
|
||||
const next = Semaphore.makeUnsafe(1)
|
||||
locks.set(filepath, next)
|
||||
return next
|
||||
}
|
||||
|
||||
return FileTimeService.of({
|
||||
read: Effect.fn("FileTimeService.read")(function* (sessionID: SessionID, file: string) {
|
||||
log.info("read", { sessionID, file })
|
||||
session(reads, sessionID).set(file, stamp(file))
|
||||
}),
|
||||
|
||||
get: Effect.fn("FileTimeService.get")(function* (sessionID: SessionID, file: string) {
|
||||
return reads.get(sessionID)?.get(file)?.read
|
||||
}),
|
||||
|
||||
assert: Effect.fn("FileTimeService.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||
if (disableCheck) return
|
||||
|
||||
const time = reads.get(sessionID)?.get(filepath)
|
||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||
const next = stamp(filepath)
|
||||
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
||||
|
||||
if (changed) {
|
||||
throw new Error(
|
||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
withLock: Effect.fn("FileTimeService.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||
const lock = getLock(filepath)
|
||||
return yield* Effect.promise(fn).pipe(lock.withPermits(1))
|
||||
}),
|
||||
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
|
||||
log.info("read", { sessionID, file })
|
||||
session(reads, sessionID).set(file, yield* stamp(file))
|
||||
})
|
||||
|
||||
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
|
||||
return reads.get(sessionID)?.get(file)?.read
|
||||
})
|
||||
|
||||
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
|
||||
if (disableCheck) return
|
||||
|
||||
const time = reads.get(sessionID)?.get(filepath)
|
||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||
|
||||
const next = yield* stamp(filepath)
|
||||
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
||||
if (!changed) return
|
||||
|
||||
throw new Error(
|
||||
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||
)
|
||||
})
|
||||
|
||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
|
||||
})
|
||||
|
||||
return Service.of({ read, get, assert, withLock })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export namespace FileTime {
|
||||
export function read(sessionID: SessionID, file: string) {
|
||||
return runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file)))
|
||||
return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
|
||||
}
|
||||
|
||||
export function get(sessionID: SessionID, file: string) {
|
||||
return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file)))
|
||||
return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
|
||||
}
|
||||
|
||||
export async function assert(sessionID: SessionID, filepath: string) {
|
||||
return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath)))
|
||||
return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath)))
|
||||
}
|
||||
|
||||
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
||||
return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn)))
|
||||
return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +1,83 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { Instance } from "@/project/instance"
|
||||
import z from "zod"
|
||||
import { Log } from "../util/log"
|
||||
import { FileIgnore } from "./ignore"
|
||||
import { Config } from "../config/config"
|
||||
import path from "path"
|
||||
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { readdir } from "fs/promises"
|
||||
import { git } from "@/util/git"
|
||||
import { Protected } from "./protected"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
||||
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
import { Instance } from "@/project/instance"
|
||||
import { git } from "@/util/git"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Config } from "../config/config"
|
||||
import { FileIgnore } from "./ignore"
|
||||
import { Protected } from "./protected"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
declare const OPENCODE_LIBC: string | undefined
|
||||
|
||||
const log = Log.create({ service: "file.watcher" })
|
||||
|
||||
const event = {
|
||||
Updated: BusEvent.define(
|
||||
"file.watcher.updated",
|
||||
z.object({
|
||||
file: z.string(),
|
||||
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
try {
|
||||
const binding = require(
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
||||
)
|
||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||
} catch (error) {
|
||||
log.error("failed to load watcher binding", { error })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
function getBackend() {
|
||||
if (process.platform === "win32") return "windows"
|
||||
if (process.platform === "darwin") return "fs-events"
|
||||
if (process.platform === "linux") return "inotify"
|
||||
}
|
||||
|
||||
export namespace FileWatcher {
|
||||
export const Event = event
|
||||
/** Whether the native @parcel/watcher binding is available on this platform. */
|
||||
export const hasNativeBinding = () => !!watcher()
|
||||
}
|
||||
const log = Log.create({ service: "file.watcher" })
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
|
||||
const init = Effect.fn("FileWatcherService.init")(function* () {})
|
||||
|
||||
export namespace FileWatcherService {
|
||||
export interface Service {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
export const Event = {
|
||||
Updated: BusEvent.define(
|
||||
"file.watcher.updated",
|
||||
z.object({
|
||||
file: z.string(),
|
||||
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export class FileWatcherService extends ServiceMap.Service<FileWatcherService, FileWatcherService.Service>()(
|
||||
"@opencode/FileWatcher",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
FileWatcherService,
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
try {
|
||||
const binding = require(
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
||||
)
|
||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||
} catch (error) {
|
||||
log.error("failed to load watcher binding", { error })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
function getBackend() {
|
||||
if (process.platform === "win32") return "windows"
|
||||
if (process.platform === "darwin") return "fs-events"
|
||||
if (process.platform === "linux") return "inotify"
|
||||
}
|
||||
|
||||
function protecteds(dir: string) {
|
||||
return Protected.paths().filter((item) => {
|
||||
const rel = path.relative(dir, item)
|
||||
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
|
||||
})
|
||||
}
|
||||
|
||||
export const hasNativeBinding = () => !!watcher()
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return FileWatcherService.of({ init })
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
|
||||
|
||||
log.info("init", { directory: instance.directory })
|
||||
|
||||
const backend = getBackend()
|
||||
if (!backend) {
|
||||
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
|
||||
return FileWatcherService.of({ init })
|
||||
return Service.of({})
|
||||
}
|
||||
|
||||
const w = watcher()
|
||||
if (!w) return FileWatcherService.of({ init })
|
||||
if (!w) return Service.of({})
|
||||
|
||||
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
|
||||
|
||||
@@ -93,9 +87,9 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
|
||||
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
if (evt.type === "create") Bus.publish(event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update") Bus.publish(event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") Bus.publish(event.Updated, { file: evt.path, event: "unlink" })
|
||||
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -108,7 +102,6 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
|
||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
||||
// Clean up a subscription that resolves after timeout
|
||||
pending.then((s) => s.unsubscribe()).catch(() => {})
|
||||
return Effect.void
|
||||
}),
|
||||
@@ -119,7 +112,7 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...Protected.paths()])
|
||||
yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(instance.directory)])
|
||||
}
|
||||
|
||||
if (instance.project.vcs === "git") {
|
||||
@@ -137,11 +130,11 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
|
||||
}
|
||||
}
|
||||
|
||||
return FileWatcherService.of({ init })
|
||||
return Service.of({})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||
return Effect.succeed(FileWatcherService.of({ init }))
|
||||
return Effect.succeed(Service.of({}))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
197
packages/opencode/src/filesystem/index.ts
Normal file
197
packages/opencode/src/filesystem/index.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||
import { realpathSync } from "fs"
|
||||
import { lookup } from "mime-types"
|
||||
import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { Glob } from "../util/glob"
|
||||
|
||||
export namespace AppFileSystem {
|
||||
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||
method: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export type Error = PlatformError | FileSystemError
|
||||
|
||||
export interface Interface extends FileSystem.FileSystem {
|
||||
readonly isDir: (path: string) => Effect.Effect<boolean, Error>
|
||||
readonly isFile: (path: string) => Effect.Effect<boolean, Error>
|
||||
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
|
||||
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
|
||||
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
|
||||
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
|
||||
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
|
||||
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||
readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
|
||||
readonly globMatch: (pattern: string, filepath: string) => boolean
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileSystem") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
|
||||
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
|
||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||
return info?.type === "Directory"
|
||||
})
|
||||
|
||||
const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
|
||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||
return info?.type === "File"
|
||||
})
|
||||
|
||||
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
|
||||
const text = yield* fs.readFileString(path)
|
||||
return JSON.parse(text)
|
||||
})
|
||||
|
||||
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
|
||||
const content = JSON.stringify(data, null, 2)
|
||||
yield* fs.writeFileString(path, content)
|
||||
if (mode) yield* fs.chmod(path, mode)
|
||||
})
|
||||
|
||||
const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
|
||||
yield* fs.makeDirectory(path, { recursive: true })
|
||||
})
|
||||
|
||||
const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
|
||||
path: string,
|
||||
content: string | Uint8Array,
|
||||
mode?: number,
|
||||
) {
|
||||
const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
|
||||
|
||||
yield* write.pipe(
|
||||
Effect.catchIf(
|
||||
(e) => e.reason._tag === "NotFound",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* fs.makeDirectory(dirname(path), { recursive: true })
|
||||
yield* write
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (mode) yield* fs.chmod(path, mode)
|
||||
})
|
||||
|
||||
const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => Glob.scan(pattern, options),
|
||||
catch: (cause) => new FileSystemError({ method: "glob", cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
|
||||
const result: string[] = []
|
||||
let current = start
|
||||
while (true) {
|
||||
const search = join(current, target)
|
||||
if (yield* fs.exists(search)) result.push(search)
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
|
||||
const result: string[] = []
|
||||
let current = options.start
|
||||
while (true) {
|
||||
for (const target of options.targets) {
|
||||
const search = join(current, target)
|
||||
if (yield* fs.exists(search)) result.push(search)
|
||||
}
|
||||
if (options.stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
|
||||
const result: string[] = []
|
||||
let current = start
|
||||
while (true) {
|
||||
const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
|
||||
Effect.catch(() => Effect.succeed([] as string[])),
|
||||
)
|
||||
result.push(...matches)
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
...fs,
|
||||
isDir,
|
||||
isFile,
|
||||
readJson,
|
||||
writeJson,
|
||||
ensureDir,
|
||||
writeWithDirs,
|
||||
findUp,
|
||||
up,
|
||||
globUp,
|
||||
glob,
|
||||
globMatch: Glob.match,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
||||
|
||||
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
||||
export function mimeType(p: string): string {
|
||||
return lookup(p) || "application/octet-stream"
|
||||
}
|
||||
|
||||
export function normalizePath(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
try {
|
||||
return realpathSync.native(p)
|
||||
} catch {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
export function resolve(p: string): string {
|
||||
const resolved = pathResolve(windowsPath(p))
|
||||
try {
|
||||
return normalizePath(realpathSync(resolved))
|
||||
} catch (e: any) {
|
||||
if (e?.code === "ENOENT") return normalizePath(resolved)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export function windowsPath(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
return p
|
||||
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
}
|
||||
|
||||
export function overlaps(a: string, b: string) {
|
||||
const relA = relative(a, b)
|
||||
const relB = relative(b, a)
|
||||
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
|
||||
}
|
||||
|
||||
export function contains(parent: string, child: string) {
|
||||
return !relative(parent, child).startsWith("..")
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,18 @@
|
||||
import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
|
||||
import * as Formatter from "./formatter"
|
||||
import { Config } from "../config/config"
|
||||
import { mergeDeep } from "remeda"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Process } from "../util/process"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
|
||||
const log = Log.create({ service: "format" })
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import path from "path"
|
||||
import { mergeDeep } from "remeda"
|
||||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Process } from "../util/process"
|
||||
import { Log } from "../util/log"
|
||||
import * as Formatter from "./formatter"
|
||||
|
||||
export namespace Format {
|
||||
const log = Log.create({ service: "format" })
|
||||
|
||||
export const Status = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
@@ -27,25 +24,15 @@ export namespace Format {
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
export async function init() {
|
||||
return runPromiseInstance(FormatService.use((s) => s.init()))
|
||||
export interface Interface {
|
||||
readonly run: (filepath: string) => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return runPromiseInstance(FormatService.use((s) => s.status()))
|
||||
}
|
||||
}
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
||||
|
||||
export namespace FormatService {
|
||||
export interface Service {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Format.Status[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") {
|
||||
static readonly layer = Layer.effect(
|
||||
FormatService,
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
|
||||
@@ -63,17 +50,19 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
|
||||
delete formatters[name]
|
||||
continue
|
||||
}
|
||||
const result = mergeDeep(formatters[name] ?? {}, {
|
||||
const info = mergeDeep(formatters[name] ?? {}, {
|
||||
command: [],
|
||||
extensions: [],
|
||||
...item,
|
||||
}) as Formatter.Info
|
||||
})
|
||||
|
||||
if (result.command.length === 0) continue
|
||||
if (info.command.length === 0) continue
|
||||
|
||||
result.enabled = async () => true
|
||||
result.name = name
|
||||
formatters[name] = result
|
||||
formatters[name] = {
|
||||
...info,
|
||||
name,
|
||||
enabled: async () => true,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.info("all formatters are disabled")
|
||||
@@ -100,18 +89,16 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
|
||||
return result
|
||||
}
|
||||
|
||||
const unsubscribe = Bus.subscribe(
|
||||
File.Event.Edited,
|
||||
Instance.bind(async (payload) => {
|
||||
const file = payload.properties.file
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
const run = Effect.fn("Format.run")(function* (filepath: string) {
|
||||
log.info("formatting", { file: filepath })
|
||||
const ext = path.extname(filepath)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
|
||||
log.info("running", { command: item.command })
|
||||
yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", file)),
|
||||
item.command.map((x) => x.replace("$FILE", filepath)),
|
||||
{
|
||||
cwd: instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
@@ -120,30 +107,30 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0)
|
||||
if (exit !== 0) {
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
} catch (error) {
|
||||
}
|
||||
},
|
||||
catch: (error) => {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file,
|
||||
file: filepath,
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
return error
|
||||
},
|
||||
}).pipe(Effect.ignore)
|
||||
}
|
||||
})
|
||||
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
||||
log.info("init")
|
||||
|
||||
const init = Effect.fn("FormatService.init")(function* () {})
|
||||
|
||||
const status = Effect.fn("FormatService.status")(function* () {
|
||||
const result: Format.Status[] = []
|
||||
const status = Effect.fn("Format.status")(function* () {
|
||||
const result: Status[] = []
|
||||
for (const formatter of Object.values(formatters)) {
|
||||
const isOn = yield* Effect.promise(() => isEnabled(formatter))
|
||||
result.push({
|
||||
@@ -155,7 +142,15 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
|
||||
return result
|
||||
})
|
||||
|
||||
return FormatService.of({ init, status })
|
||||
return Service.of({ run, status })
|
||||
}),
|
||||
)
|
||||
|
||||
export async function run(filepath: string) {
|
||||
return runPromiseInstance(Service.use((s) => s.run(filepath)))
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return runPromiseInstance(Service.use((s) => s.status()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { spawn } from "bun"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "@/util/process"
|
||||
|
||||
const SUPPORTED_IDES = [
|
||||
{ name: "Windsurf" as const, cmd: "windsurf" },
|
||||
@@ -52,13 +52,11 @@ export namespace Ide {
|
||||
const cmd = SUPPORTED_IDES.find((i) => i.name === ide)?.cmd
|
||||
if (!cmd) throw new Error(`Unknown IDE: ${ide}`)
|
||||
|
||||
const p = spawn([cmd, "--install-extension", "sst-dev.opencode"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
const p = await Process.run([cmd, "--install-extension", "sst-dev.opencode"], {
|
||||
nothrow: true,
|
||||
})
|
||||
await p.exited
|
||||
const stdout = await new Response(p.stdout).text()
|
||||
const stderr = await new Response(p.stderr).text()
|
||||
const stdout = p.stdout.toString()
|
||||
const stderr = p.stderr.toString()
|
||||
|
||||
log.info("installed", {
|
||||
ide,
|
||||
@@ -66,7 +64,7 @@ export namespace Ide {
|
||||
stderr,
|
||||
})
|
||||
|
||||
if (p.exitCode !== 0) {
|
||||
if (p.code !== 0) {
|
||||
throw new InstallFailedError({ stderr })
|
||||
}
|
||||
if (stdout.includes("already installed")) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
|
||||
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
|
||||
import { Log } from "../util/log"
|
||||
import { Process } from "../util/process"
|
||||
import { LANGUAGE_EXTENSIONS } from "./language"
|
||||
import z from "zod"
|
||||
import type { LSPServer } from "./server"
|
||||
@@ -239,7 +240,7 @@ export namespace LSPClient {
|
||||
l.info("shutting down")
|
||||
connection.end()
|
||||
connection.dispose()
|
||||
input.server.process.kill()
|
||||
await Process.stop(input.server.process)
|
||||
l.info("shutdown")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { LSPServer } from "./server"
|
||||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { spawn } from "child_process"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Process } from "../util/process"
|
||||
import { spawn as lspspawn } from "./launch"
|
||||
|
||||
export namespace LSP {
|
||||
const log = Log.create({ service: "lsp" })
|
||||
@@ -112,9 +113,8 @@ export namespace LSP {
|
||||
extensions: item.extensions ?? existing?.extensions ?? [],
|
||||
spawn: async (root) => {
|
||||
return {
|
||||
process: spawn(item.command[0], item.command.slice(1), {
|
||||
process: lspspawn(item.command[0], item.command.slice(1), {
|
||||
cwd: root,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...item.env,
|
||||
@@ -200,21 +200,20 @@ export namespace LSP {
|
||||
serverID: server.id,
|
||||
server: handle,
|
||||
root,
|
||||
}).catch((err) => {
|
||||
}).catch(async (err) => {
|
||||
s.broken.add(key)
|
||||
handle.process.kill()
|
||||
await Process.stop(handle.process)
|
||||
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!client) {
|
||||
handle.process.kill()
|
||||
return undefined
|
||||
}
|
||||
|
||||
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||
if (existing) {
|
||||
handle.process.kill()
|
||||
await Process.stop(handle.process)
|
||||
return existing
|
||||
}
|
||||
|
||||
|
||||
21
packages/opencode/src/lsp/launch.ts
Normal file
21
packages/opencode/src/lsp/launch.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "child_process"
|
||||
import { Process } from "../util/process"
|
||||
|
||||
type Child = Process.Child & ChildProcessWithoutNullStreams
|
||||
|
||||
export function spawn(cmd: string, args: string[], opts?: Process.Options): Child
|
||||
export function spawn(cmd: string, opts?: Process.Options): Child
|
||||
export function spawn(cmd: string, argsOrOpts?: string[] | Process.Options, opts?: Process.Options) {
|
||||
const args = Array.isArray(argsOrOpts) ? [...argsOrOpts] : []
|
||||
const cfg = Array.isArray(argsOrOpts) ? opts : argsOrOpts
|
||||
const proc = Process.spawn([cmd, ...args], {
|
||||
...(cfg ?? {}),
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
}) as Child
|
||||
|
||||
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
|
||||
|
||||
return proc
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn as launch, type ChildProcessWithoutNullStreams } from "child_process"
|
||||
import type { ChildProcessWithoutNullStreams } from "child_process"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Global } from "../global"
|
||||
@@ -13,11 +13,7 @@ import { Archive } from "../util/archive"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { Module } from "@opencode-ai/util/module"
|
||||
|
||||
const spawn = ((cmd, args, opts) => {
|
||||
if (Array.isArray(args)) return launch(cmd, [...args], { ...(opts ?? {}), windowsHide: true })
|
||||
return launch(cmd, { ...(args ?? {}), windowsHide: true })
|
||||
}) as typeof launch
|
||||
import { spawn } from "./launch"
|
||||
|
||||
export namespace LSPServer {
|
||||
const log = Log.create({ service: "lsp.server" })
|
||||
@@ -273,7 +269,7 @@ export namespace LSPServer {
|
||||
}
|
||||
|
||||
if (lintBin) {
|
||||
const proc = Process.spawn([lintBin, "--help"], { stdout: "pipe" })
|
||||
const proc = spawn(lintBin, ["--help"])
|
||||
await proc.exited
|
||||
if (proc.stdout) {
|
||||
const help = await text(proc.stdout)
|
||||
|
||||
295
packages/opencode/src/permission/index.ts
Normal file
295
packages/opencode/src/permission/index.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Log } from "@/util/log"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import { PermissionID } from "./schema"
|
||||
|
||||
export namespace PermissionNext {
|
||||
const log = Log.create({ service: "permission" })
|
||||
|
||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||
ref: "PermissionAction",
|
||||
})
|
||||
export type Action = z.infer<typeof Action>
|
||||
|
||||
export const Rule = z
|
||||
.object({
|
||||
permission: z.string(),
|
||||
pattern: z.string(),
|
||||
action: Action,
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRule",
|
||||
})
|
||||
export type Rule = z.infer<typeof Rule>
|
||||
|
||||
export const Ruleset = Rule.array().meta({
|
||||
ref: "PermissionRuleset",
|
||||
})
|
||||
export type Ruleset = z.infer<typeof Ruleset>
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: PermissionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
permission: z.string(),
|
||||
patterns: z.string().array(),
|
||||
metadata: z.record(z.string(), z.any()),
|
||||
always: z.string().array(),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRequest",
|
||||
})
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Reply = z.enum(["once", "always", "reject"])
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Approval = z.object({
|
||||
projectID: ProjectID.zod,
|
||||
patterns: z.string().array(),
|
||||
})
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("permission.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"permission.replied",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user rejected permission to use this specific tool call."
|
||||
}
|
||||
}
|
||||
|
||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||
feedback: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||
}
|
||||
}
|
||||
|
||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||
ruleset: Schema.Any,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
||||
}
|
||||
}
|
||||
|
||||
export type Error = DeniedError | RejectedError | CorrectedError
|
||||
|
||||
export const AskInput = Request.partial({ id: true }).extend({
|
||||
ruleset: Ruleset,
|
||||
})
|
||||
|
||||
export const ReplyInput = z.object({
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
message: z.string().optional(),
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
|
||||
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Request[]>
|
||||
}
|
||||
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
const rules = rulesets.flat()
|
||||
log.info("evaluate", { permission, pattern, ruleset: rules })
|
||||
const match = rules.findLast(
|
||||
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
||||
)
|
||||
return match ?? { action: "ask", permission, pattern: "*" }
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { project } = yield* InstanceContext
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
|
||||
)
|
||||
const pending = new Map<PermissionID, PendingEntry>()
|
||||
const approved: Ruleset = row?.data ?? []
|
||||
|
||||
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
|
||||
const { ruleset, ...request } = input
|
||||
let needsAsk = false
|
||||
|
||||
for (const pattern of request.patterns) {
|
||||
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
||||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||
if (rule.action === "deny") {
|
||||
return yield* new DeniedError({
|
||||
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
||||
})
|
||||
}
|
||||
if (rule.action === "allow") continue
|
||||
needsAsk = true
|
||||
}
|
||||
|
||||
if (!needsAsk) return
|
||||
|
||||
const id = request.id ?? PermissionID.ascending()
|
||||
const info: Request = {
|
||||
id,
|
||||
...request,
|
||||
}
|
||||
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
||||
|
||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||
pending.set(id, { info, deferred })
|
||||
void Bus.publish(Event.Asked, info)
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return
|
||||
|
||||
pending.delete(input.requestID)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
reply: input.reply,
|
||||
})
|
||||
|
||||
if (input.reply === "reject") {
|
||||
yield* Deferred.fail(
|
||||
existing.deferred,
|
||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||
)
|
||||
|
||||
for (const [id, item] of pending.entries()) {
|
||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||
pending.delete(id)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
reply: "reject",
|
||||
})
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
yield* Deferred.succeed(existing.deferred, undefined)
|
||||
if (input.reply === "once") return
|
||||
|
||||
for (const pattern of existing.info.always) {
|
||||
approved.push({
|
||||
permission: existing.info.permission,
|
||||
pattern,
|
||||
action: "allow",
|
||||
})
|
||||
}
|
||||
|
||||
for (const [id, item] of pending.entries()) {
|
||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||
const ok = item.info.patterns.every(
|
||||
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
||||
)
|
||||
if (!ok) continue
|
||||
pending.delete(id)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
reply: "always",
|
||||
})
|
||||
yield* Deferred.succeed(item.deferred, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
const list = Effect.fn("Permission.list")(function* () {
|
||||
return Array.from(pending.values(), (item) => item.info)
|
||||
})
|
||||
|
||||
return Service.of({ ask, reply, list })
|
||||
}),
|
||||
)
|
||||
|
||||
function expand(pattern: string): string {
|
||||
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
||||
if (pattern === "~") return os.homedir()
|
||||
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
|
||||
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
|
||||
return pattern
|
||||
}
|
||||
|
||||
export function fromConfig(permission: Config.Permission) {
|
||||
const ruleset: Ruleset = []
|
||||
for (const [key, value] of Object.entries(permission)) {
|
||||
if (typeof value === "string") {
|
||||
ruleset.push({ permission: key, action: value, pattern: "*" })
|
||||
continue
|
||||
}
|
||||
ruleset.push(
|
||||
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
|
||||
)
|
||||
}
|
||||
return ruleset
|
||||
}
|
||||
|
||||
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||
return rulesets.flat()
|
||||
}
|
||||
|
||||
export const ask = fn(AskInput, async (input) => runPromiseInstance(Service.use((svc) => svc.ask(input))))
|
||||
|
||||
export const reply = fn(ReplyInput, async (input) => runPromiseInstance(Service.use((svc) => svc.reply(input))))
|
||||
|
||||
export async function list() {
|
||||
return runPromiseInstance(Service.use((svc) => svc.list()))
|
||||
}
|
||||
|
||||
const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"]
|
||||
|
||||
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||
const result = new Set<string>()
|
||||
for (const tool of tools) {
|
||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
||||
if (!rule) continue
|
||||
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Config } from "@/config/config"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import os from "os"
|
||||
import * as S from "./service"
|
||||
|
||||
export namespace PermissionNext {
|
||||
function expand(pattern: string): string {
|
||||
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
|
||||
if (pattern === "~") return os.homedir()
|
||||
if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5)
|
||||
if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5)
|
||||
return pattern
|
||||
}
|
||||
|
||||
export const Action = S.Action
|
||||
export type Action = S.Action
|
||||
export const Rule = S.Rule
|
||||
export type Rule = S.Rule
|
||||
export const Ruleset = S.Ruleset
|
||||
export type Ruleset = S.Ruleset
|
||||
export const Request = S.Request
|
||||
export type Request = S.Request
|
||||
export const Reply = S.Reply
|
||||
export type Reply = S.Reply
|
||||
export const Approval = S.Approval
|
||||
export const Event = S.Event
|
||||
export const Service = S.PermissionService
|
||||
export const RejectedError = S.RejectedError
|
||||
export const CorrectedError = S.CorrectedError
|
||||
export const DeniedError = S.DeniedError
|
||||
|
||||
export function fromConfig(permission: Config.Permission) {
|
||||
const ruleset: Ruleset = []
|
||||
for (const [key, value] of Object.entries(permission)) {
|
||||
if (typeof value === "string") {
|
||||
ruleset.push({
|
||||
permission: key,
|
||||
action: value,
|
||||
pattern: "*",
|
||||
})
|
||||
continue
|
||||
}
|
||||
ruleset.push(
|
||||
...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })),
|
||||
)
|
||||
}
|
||||
return ruleset
|
||||
}
|
||||
|
||||
export function merge(...rulesets: Ruleset[]): Ruleset {
|
||||
return rulesets.flat()
|
||||
}
|
||||
|
||||
export const ask = fn(S.AskInput, async (input) =>
|
||||
runPromiseInstance(S.PermissionService.use((service) => service.ask(input))),
|
||||
)
|
||||
|
||||
export const reply = fn(S.ReplyInput, async (input) =>
|
||||
runPromiseInstance(S.PermissionService.use((service) => service.reply(input))),
|
||||
)
|
||||
|
||||
export async function list() {
|
||||
return runPromiseInstance(S.PermissionService.use((service) => service.list()))
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
return S.evaluate(permission, pattern, ...rulesets)
|
||||
}
|
||||
|
||||
const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
|
||||
|
||||
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||
const result = new Set<string>()
|
||||
for (const tool of tools) {
|
||||
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
|
||||
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
|
||||
if (!rule) continue
|
||||
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
import { Log } from "@/util/log"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import z from "zod"
|
||||
import { PermissionID } from "./schema"
|
||||
|
||||
const log = Log.create({ service: "permission" })
|
||||
|
||||
export const Action = z.enum(["allow", "deny", "ask"]).meta({
|
||||
ref: "PermissionAction",
|
||||
})
|
||||
export type Action = z.infer<typeof Action>
|
||||
|
||||
export const Rule = z
|
||||
.object({
|
||||
permission: z.string(),
|
||||
pattern: z.string(),
|
||||
action: Action,
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRule",
|
||||
})
|
||||
export type Rule = z.infer<typeof Rule>
|
||||
|
||||
export const Ruleset = Rule.array().meta({
|
||||
ref: "PermissionRuleset",
|
||||
})
|
||||
export type Ruleset = z.infer<typeof Ruleset>
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: PermissionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
permission: z.string(),
|
||||
patterns: z.string().array(),
|
||||
metadata: z.record(z.string(), z.any()),
|
||||
always: z.string().array(),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "PermissionRequest",
|
||||
})
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Reply = z.enum(["once", "always", "reject"])
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Approval = z.object({
|
||||
projectID: ProjectID.zod,
|
||||
patterns: z.string().array(),
|
||||
})
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("permission.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"permission.replied",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user rejected permission to use this specific tool call."
|
||||
}
|
||||
}
|
||||
|
||||
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
|
||||
feedback: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
|
||||
}
|
||||
}
|
||||
|
||||
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
|
||||
ruleset: Schema.Any,
|
||||
}) {
|
||||
override get message() {
|
||||
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionError = DeniedError | RejectedError | CorrectedError
|
||||
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
|
||||
}
|
||||
|
||||
export const AskInput = Request.partial({ id: true }).extend({
|
||||
ruleset: Ruleset,
|
||||
})
|
||||
|
||||
export const ReplyInput = z.object({
|
||||
requestID: PermissionID.zod,
|
||||
reply: Reply,
|
||||
message: z.string().optional(),
|
||||
})
|
||||
|
||||
export declare namespace PermissionService {
|
||||
export interface Api {
|
||||
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, PermissionError>
|
||||
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Request[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class PermissionService extends ServiceMap.Service<PermissionService, PermissionService.Api>()(
|
||||
"@opencode/PermissionNext",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
PermissionService,
|
||||
Effect.gen(function* () {
|
||||
const { project } = yield* InstanceContext
|
||||
const row = Database.use((db) =>
|
||||
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(),
|
||||
)
|
||||
const pending = new Map<PermissionID, PendingEntry>()
|
||||
const approved: Ruleset = row?.data ?? []
|
||||
|
||||
const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer<typeof AskInput>) {
|
||||
const { ruleset, ...request } = input
|
||||
let needsAsk = false
|
||||
|
||||
for (const pattern of request.patterns) {
|
||||
const rule = evaluate(request.permission, pattern, ruleset, approved)
|
||||
log.info("evaluated", { permission: request.permission, pattern, action: rule })
|
||||
if (rule.action === "deny") {
|
||||
return yield* new DeniedError({
|
||||
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
|
||||
})
|
||||
}
|
||||
if (rule.action === "allow") continue
|
||||
needsAsk = true
|
||||
}
|
||||
|
||||
if (!needsAsk) return
|
||||
|
||||
const id = request.id ?? PermissionID.ascending()
|
||||
const info: Request = {
|
||||
id,
|
||||
...request,
|
||||
}
|
||||
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
|
||||
|
||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||
pending.set(id, { info, deferred })
|
||||
void Bus.publish(Event.Asked, info)
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer<typeof ReplyInput>) {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return
|
||||
|
||||
pending.delete(input.requestID)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
reply: input.reply,
|
||||
})
|
||||
|
||||
if (input.reply === "reject") {
|
||||
yield* Deferred.fail(
|
||||
existing.deferred,
|
||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||
)
|
||||
|
||||
for (const [id, item] of pending.entries()) {
|
||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||
pending.delete(id)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
reply: "reject",
|
||||
})
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
yield* Deferred.succeed(existing.deferred, undefined)
|
||||
if (input.reply === "once") return
|
||||
|
||||
for (const pattern of existing.info.always) {
|
||||
approved.push({
|
||||
permission: existing.info.permission,
|
||||
pattern,
|
||||
action: "allow",
|
||||
})
|
||||
}
|
||||
|
||||
for (const [id, item] of pending.entries()) {
|
||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||
const ok = item.info.patterns.every(
|
||||
(pattern) => evaluate(item.info.permission, pattern, approved).action === "allow",
|
||||
)
|
||||
if (!ok) continue
|
||||
pending.delete(id)
|
||||
void Bus.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
reply: "always",
|
||||
})
|
||||
yield* Deferred.succeed(item.deferred, undefined)
|
||||
}
|
||||
|
||||
// TODO: we don't save the permission ruleset to disk yet until there's
|
||||
// UI to manage it
|
||||
// db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
|
||||
// .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
|
||||
})
|
||||
|
||||
const list = Effect.fn("PermissionService.list")(function* () {
|
||||
return Array.from(pending.values(), (item) => item.info)
|
||||
})
|
||||
|
||||
return PermissionService.of({ ask, reply, list })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
const merged = rulesets.flat()
|
||||
log.info("evaluate", { permission, pattern, ruleset: merged })
|
||||
const match = merged.findLast(
|
||||
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
||||
)
|
||||
return match ?? { action: "ask", permission, pattern: "*" }
|
||||
}
|
||||
@@ -360,13 +360,14 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
|
||||
// Filter models to only allowed Codex models for OAuth
|
||||
const allowedModels = new Set([
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.2",
|
||||
"gpt-5.4",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
])
|
||||
for (const modelId of Object.keys(provider.models)) {
|
||||
if (modelId.includes("codex")) continue
|
||||
|
||||
@@ -168,7 +168,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
key: "enterpriseUrl",
|
||||
message: "Enter your GitHub Enterprise URL or domain",
|
||||
placeholder: "company.ghe.com or https://company.ghe.com",
|
||||
condition: (inputs) => inputs.deploymentType === "enterprise",
|
||||
when: { key: "deploymentType", op: "eq", value: "enterprise" },
|
||||
validate: (value) => {
|
||||
if (!value) return "URL or domain is required"
|
||||
try {
|
||||
|
||||
@@ -1,34 +1,23 @@
|
||||
import { Plugin } from "../plugin"
|
||||
import { Format } from "../format"
|
||||
import { LSP } from "../lsp"
|
||||
import { FileWatcherService } from "../file/watcher"
|
||||
import { File } from "../file"
|
||||
import { Project } from "./project"
|
||||
import { Bus } from "../bus"
|
||||
import { Command } from "../command"
|
||||
import { Instance } from "./instance"
|
||||
import { VcsService } from "./vcs"
|
||||
import { Log } from "@/util/log"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Truncate } from "../tool/truncation"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
|
||||
export async function InstanceBootstrap() {
|
||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||
await Plugin.init()
|
||||
ShareNext.init()
|
||||
await Format.init()
|
||||
await LSP.init()
|
||||
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
|
||||
File.init()
|
||||
await runPromiseInstance(VcsService.use((s) => s.init()))
|
||||
Snapshot.init()
|
||||
Truncate.init()
|
||||
|
||||
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
||||
if (payload.properties.name === Command.Default.INIT) {
|
||||
await Project.setInitialized(Instance.project.id)
|
||||
Project.setInitialized(Instance.project.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { disposeInstance } from "@/effect/instance-registry"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Log } from "@/util/log"
|
||||
import { Context } from "../util/context"
|
||||
import { Project } from "./project"
|
||||
import { State } from "./state"
|
||||
import { iife } from "@/util/iife"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { disposeInstance } from "@/effect/instance-registry"
|
||||
|
||||
interface Context {
|
||||
directory: string
|
||||
@@ -80,6 +80,9 @@ export const Instance = {
|
||||
return input.fn()
|
||||
})
|
||||
},
|
||||
get current() {
|
||||
return context.use()
|
||||
},
|
||||
get directory() {
|
||||
return context.use().directory
|
||||
},
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import z from "zod"
|
||||
import { Log } from "@/util/log"
|
||||
import { Instance } from "./instance"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Log } from "@/util/log"
|
||||
import { git } from "@/util/git"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
|
||||
const log = Log.create({ service: "vcs" })
|
||||
import { Instance } from "./instance"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Vcs {
|
||||
const log = Log.create({ service: "vcs" })
|
||||
|
||||
export const Event = {
|
||||
BranchUpdated: BusEvent.define(
|
||||
"vcs.branch.updated",
|
||||
@@ -28,24 +28,21 @@ export namespace Vcs {
|
||||
ref: "VcsInfo",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
}
|
||||
|
||||
export namespace VcsService {
|
||||
export interface Service {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
export interface Interface {
|
||||
readonly branch: () => Effect.Effect<string | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
export class VcsService extends ServiceMap.Service<VcsService, VcsService.Service>()("@opencode/Vcs") {
|
||||
static readonly layer = Layer.effect(
|
||||
VcsService,
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
let current: string | undefined
|
||||
let currentBranch: string | undefined
|
||||
|
||||
if (instance.project.vcs === "git") {
|
||||
const currentBranch = async () => {
|
||||
const getCurrentBranch = async () => {
|
||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: instance.project.worktree,
|
||||
})
|
||||
@@ -54,29 +51,31 @@ export class VcsService extends ServiceMap.Service<VcsService, VcsService.Servic
|
||||
return text || undefined
|
||||
}
|
||||
|
||||
current = yield* Effect.promise(() => currentBranch())
|
||||
log.info("initialized", { branch: current })
|
||||
currentBranch = yield* Effect.promise(() => getCurrentBranch())
|
||||
log.info("initialized", { branch: currentBranch })
|
||||
|
||||
const unsubscribe = Bus.subscribe(
|
||||
FileWatcher.Event.Updated,
|
||||
Instance.bind(async (evt) => {
|
||||
if (!evt.properties.file.endsWith("HEAD")) return
|
||||
const next = await currentBranch()
|
||||
if (next !== current) {
|
||||
log.info("branch changed", { from: current, to: next })
|
||||
current = next
|
||||
Bus.publish(Vcs.Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribe(
|
||||
FileWatcher.Event.Updated,
|
||||
Instance.bind(async (evt) => {
|
||||
if (!evt.properties.file.endsWith("HEAD")) return
|
||||
const next = await getCurrentBranch()
|
||||
if (next !== currentBranch) {
|
||||
log.info("branch changed", { from: currentBranch, to: next })
|
||||
currentBranch = next
|
||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe),
|
||||
)
|
||||
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
||||
}
|
||||
|
||||
return VcsService.of({
|
||||
init: Effect.fn("VcsService.init")(function* () {}),
|
||||
branch: Effect.fn("VcsService.branch")(function* () {
|
||||
return current
|
||||
return Service.of({
|
||||
branch: Effect.fn("Vcs.branch")(function* () {
|
||||
return currentBranch
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import type { AuthOuathResult } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import * as Auth from "@/auth/service"
|
||||
import { ProviderID } from "./schema"
|
||||
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
|
||||
import { filter, fromEntries, map, pipe } from "remeda"
|
||||
import z from "zod"
|
||||
|
||||
export const Method = z
|
||||
.object({
|
||||
type: z.union([z.literal("oauth"), z.literal("api")]),
|
||||
label: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthMethod",
|
||||
})
|
||||
export type Method = z.infer<typeof Method>
|
||||
|
||||
export const Authorization = z
|
||||
.object({
|
||||
url: z.string(),
|
||||
method: z.union([z.literal("auto"), z.literal("code")]),
|
||||
instructions: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthAuthorization",
|
||||
})
|
||||
export type Authorization = z.infer<typeof Authorization>
|
||||
|
||||
export const OauthMissing = NamedError.create(
|
||||
"ProviderAuthOauthMissing",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
)
|
||||
|
||||
export const OauthCodeMissing = NamedError.create(
|
||||
"ProviderAuthOauthCodeMissing",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
)
|
||||
|
||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
||||
|
||||
export type ProviderAuthError =
|
||||
| Auth.AuthServiceError
|
||||
| InstanceType<typeof OauthMissing>
|
||||
| InstanceType<typeof OauthCodeMissing>
|
||||
| InstanceType<typeof OauthCallbackFailed>
|
||||
|
||||
export namespace ProviderAuthService {
|
||||
export interface Service {
|
||||
readonly methods: () => Effect.Effect<Record<string, Method[]>>
|
||||
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
|
||||
readonly callback: (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
code?: string
|
||||
}) => Effect.Effect<void, ProviderAuthError>
|
||||
}
|
||||
}
|
||||
|
||||
export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()(
|
||||
"@opencode/ProviderAuth",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
ProviderAuthService,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.AuthService
|
||||
const hooks = yield* Effect.promise(async () => {
|
||||
const mod = await import("../plugin")
|
||||
return pipe(
|
||||
await mod.Plugin.list(),
|
||||
filter((x) => x.auth?.provider !== undefined),
|
||||
map((x) => [x.auth!.provider, x.auth!] as const),
|
||||
fromEntries(),
|
||||
)
|
||||
})
|
||||
const pending = new Map<ProviderID, AuthOuathResult>()
|
||||
|
||||
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
|
||||
return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"])))
|
||||
})
|
||||
|
||||
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
}) {
|
||||
const method = hooks[input.providerID].methods[input.method]
|
||||
if (method.type !== "oauth") return
|
||||
const result = yield* Effect.promise(() => method.authorize())
|
||||
pending.set(input.providerID, result)
|
||||
return {
|
||||
url: result.url,
|
||||
method: result.method,
|
||||
instructions: result.instructions,
|
||||
}
|
||||
})
|
||||
|
||||
const callback = Effect.fn("ProviderAuthService.callback")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
code?: string
|
||||
}) {
|
||||
const match = pending.get(input.providerID)
|
||||
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
||||
if (match.method === "code" && !input.code)
|
||||
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
||||
)
|
||||
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
|
||||
|
||||
if ("key" in result) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
}
|
||||
|
||||
if ("refresh" in result) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "oauth",
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
...(result.accountId ? { accountId: result.accountId } : {}),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return ProviderAuthService.of({
|
||||
methods,
|
||||
authorize,
|
||||
callback,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer))
|
||||
}
|
||||
@@ -1,28 +1,231 @@
|
||||
import z from "zod"
|
||||
|
||||
import type { AuthOuathResult } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import * as Auth from "@/auth/effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { fn } from "@/util/fn"
|
||||
import * as S from "./auth-service"
|
||||
import { ProviderID } from "./schema"
|
||||
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
export namespace ProviderAuth {
|
||||
export const Method = S.Method
|
||||
export type Method = S.Method
|
||||
export const Method = z
|
||||
.object({
|
||||
type: z.union([z.literal("oauth"), z.literal("api")]),
|
||||
label: z.string(),
|
||||
prompts: z
|
||||
.array(
|
||||
z.union([
|
||||
z.object({
|
||||
type: z.literal("text"),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
placeholder: z.string().optional(),
|
||||
when: z
|
||||
.object({
|
||||
key: z.string(),
|
||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||
value: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("select"),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
hint: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
when: z
|
||||
.object({
|
||||
key: z.string(),
|
||||
op: z.union([z.literal("eq"), z.literal("neq")]),
|
||||
value: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthMethod",
|
||||
})
|
||||
export type Method = z.infer<typeof Method>
|
||||
|
||||
export async function methods() {
|
||||
return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods()))
|
||||
export const Authorization = z
|
||||
.object({
|
||||
url: z.string(),
|
||||
method: z.union([z.literal("auto"), z.literal("code")]),
|
||||
instructions: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProviderAuthAuthorization",
|
||||
})
|
||||
export type Authorization = z.infer<typeof Authorization>
|
||||
|
||||
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
|
||||
|
||||
export const OauthCodeMissing = NamedError.create(
|
||||
"ProviderAuthOauthCodeMissing",
|
||||
z.object({ providerID: ProviderID.zod }),
|
||||
)
|
||||
|
||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
||||
|
||||
export const ValidationFailed = NamedError.create(
|
||||
"ProviderAuthValidationFailed",
|
||||
z.object({
|
||||
field: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export type Error =
|
||||
| Auth.AuthError
|
||||
| InstanceType<typeof OauthMissing>
|
||||
| InstanceType<typeof OauthCodeMissing>
|
||||
| InstanceType<typeof OauthCallbackFailed>
|
||||
| InstanceType<typeof ValidationFailed>
|
||||
|
||||
export interface Interface {
|
||||
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
|
||||
readonly authorize: (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
inputs?: Record<string, string>
|
||||
}) => Effect.Effect<Authorization | undefined, Error>
|
||||
readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export const Authorization = S.Authorization
|
||||
export type Authorization = S.Authorization
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.AuthEffect.Service
|
||||
const hooks = yield* Effect.promise(async () => {
|
||||
const mod = await import("../plugin")
|
||||
const plugins = await mod.Plugin.list()
|
||||
return Record.fromEntries(
|
||||
Arr.filterMap(plugins, (x) =>
|
||||
x.auth?.provider !== undefined
|
||||
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
||||
: Result.failVoid,
|
||||
),
|
||||
)
|
||||
})
|
||||
const pending = new Map<ProviderID, AuthOuathResult>()
|
||||
|
||||
const methods = Effect.fn("ProviderAuth.methods")(function* () {
|
||||
return Record.map(hooks, (item) =>
|
||||
item.methods.map(
|
||||
(method): Method => ({
|
||||
type: method.type,
|
||||
label: method.label,
|
||||
prompts: method.prompts?.map((prompt) => {
|
||||
if (prompt.type === "select") {
|
||||
return {
|
||||
type: "select" as const,
|
||||
key: prompt.key,
|
||||
message: prompt.message,
|
||||
options: prompt.options,
|
||||
when: prompt.when,
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "text" as const,
|
||||
key: prompt.key,
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder,
|
||||
when: prompt.when,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
inputs?: Record<string, string>
|
||||
}) {
|
||||
const method = hooks[input.providerID].methods[input.method]
|
||||
if (method.type !== "oauth") return
|
||||
|
||||
if (method.prompts && input.inputs) {
|
||||
for (const prompt of method.prompts) {
|
||||
if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) {
|
||||
const error = prompt.validate(input.inputs[prompt.key])
|
||||
if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = yield* Effect.promise(() => method.authorize(input.inputs))
|
||||
pending.set(input.providerID, result)
|
||||
return {
|
||||
url: result.url,
|
||||
method: result.method,
|
||||
instructions: result.instructions,
|
||||
}
|
||||
})
|
||||
|
||||
const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
|
||||
providerID: ProviderID
|
||||
method: number
|
||||
code?: string
|
||||
}) {
|
||||
const match = pending.get(input.providerID)
|
||||
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
|
||||
if (match.method === "code" && !input.code) {
|
||||
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
|
||||
}
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
match.method === "code" ? match.callback(input.code!) : match.callback(),
|
||||
)
|
||||
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
|
||||
|
||||
if ("key" in result) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
}
|
||||
|
||||
if ("refresh" in result) {
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "oauth",
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
...(result.accountId ? { accountId: result.accountId } : {}),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ methods, authorize, callback })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer))
|
||||
|
||||
export async function methods() {
|
||||
return runPromiseInstance(Service.use((svc) => svc.methods()))
|
||||
}
|
||||
|
||||
export const authorize = fn(
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
method: z.number(),
|
||||
inputs: z.record(z.string(), z.string()).optional(),
|
||||
}),
|
||||
async (input): Promise<Authorization | undefined> =>
|
||||
runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))),
|
||||
async (input): Promise<Authorization | undefined> => runPromiseInstance(Service.use((svc) => svc.authorize(input))),
|
||||
)
|
||||
|
||||
export const callback = fn(
|
||||
@@ -31,10 +234,6 @@ export namespace ProviderAuth {
|
||||
method: z.number(),
|
||||
code: z.string().optional(),
|
||||
}),
|
||||
async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))),
|
||||
async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))),
|
||||
)
|
||||
|
||||
export import OauthMissing = S.OauthMissing
|
||||
export import OauthCodeMissing = S.OauthCodeMissing
|
||||
export import OauthCallbackFailed = S.OauthCallbackFailed
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export namespace ProviderError {
|
||||
/input token count.*exceeds the maximum/i, // Google (Gemini)
|
||||
/maximum prompt length is \d+/i, // xAI (Grok)
|
||||
/reduce the length of the messages/i, // Groq
|
||||
/maximum context length is \d+ tokens/i, // OpenRouter, DeepSeek
|
||||
/maximum context length is \d+ tokens/i, // OpenRouter, DeepSeek, vLLM
|
||||
/exceeds the limit of \d+/i, // GitHub Copilot
|
||||
/exceeds the available context size/i, // llama.cpp server
|
||||
/greater than the context length/i, // LM Studio
|
||||
@@ -21,6 +21,8 @@ export namespace ProviderError {
|
||||
/exceeded model token limit/i, // Kimi For Coding, Moonshot
|
||||
/context[_ ]length[_ ]exceeded/i, // Generic fallback
|
||||
/request entity too large/i, // HTTP 413
|
||||
/context length is only \d+ tokens/i, // vLLM
|
||||
/input length.*exceeds.*context length/i, // vLLM
|
||||
]
|
||||
|
||||
function isOpenAiErrorRetryable(e: APICallError) {
|
||||
|
||||
@@ -839,7 +839,7 @@ export namespace Provider {
|
||||
return true
|
||||
}
|
||||
|
||||
const providers: { [providerID: string]: Info } = {}
|
||||
const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
|
||||
const languages = new Map<string, LanguageModelV2>()
|
||||
const modelLoaders: {
|
||||
[providerID: string]: CustomModelLoader
|
||||
|
||||
@@ -22,6 +22,7 @@ export const ProviderID = providerIdSchema.pipe(
|
||||
azure: schema.makeUnsafe("azure"),
|
||||
openrouter: schema.makeUnsafe("openrouter"),
|
||||
mistral: schema.makeUnsafe("mistral"),
|
||||
gitlab: schema.makeUnsafe("gitlab"),
|
||||
})),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,39 +1,193 @@
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import * as S from "./service"
|
||||
import type { QuestionID } from "./schema"
|
||||
import type { SessionID, MessageID } from "@/session/schema"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Log } from "@/util/log"
|
||||
import z from "zod"
|
||||
import { QuestionID } from "./schema"
|
||||
|
||||
const log = Log.create({ service: "question" })
|
||||
|
||||
export namespace Question {
|
||||
export const Option = S.Option
|
||||
export type Option = S.Option
|
||||
export const Info = S.Info
|
||||
export type Info = S.Info
|
||||
export const Request = S.Request
|
||||
export type Request = S.Request
|
||||
export const Answer = S.Answer
|
||||
export type Answer = S.Answer
|
||||
export const Reply = S.Reply
|
||||
export type Reply = S.Reply
|
||||
export const Event = S.Event
|
||||
export const RejectedError = S.RejectedError
|
||||
// Schemas
|
||||
|
||||
export const Option = z
|
||||
.object({
|
||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||
description: z.string().describe("Explanation of choice"),
|
||||
})
|
||||
.meta({ ref: "QuestionOption" })
|
||||
export type Option = z.infer<typeof Option>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
question: z.string().describe("Complete question"),
|
||||
header: z.string().describe("Very short label (max 30 chars)"),
|
||||
options: z.array(Option).describe("Available choices"),
|
||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
||||
})
|
||||
.meta({ ref: "QuestionInfo" })
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: QuestionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
questions: z.array(Info).describe("Questions to ask"),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({ ref: "QuestionRequest" })
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
||||
export type Answer = z.infer<typeof Answer>
|
||||
|
||||
export const Reply = z.object({
|
||||
answers: z
|
||||
.array(Answer)
|
||||
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||
})
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("question.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"question.replied",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: QuestionID.zod,
|
||||
answers: z.array(Answer),
|
||||
}),
|
||||
),
|
||||
Rejected: BusEvent.define(
|
||||
"question.rejected",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: QuestionID.zod,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user dismissed this question"
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<Answer[], RejectedError>
|
||||
}
|
||||
|
||||
// Service
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) => Effect.Effect<Answer[], RejectedError>
|
||||
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
||||
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Request[]>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const pending = new Map<QuestionID, PendingEntry>()
|
||||
|
||||
const ask = Effect.fn("Question.ask")(function* (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) {
|
||||
const id = QuestionID.ascending()
|
||||
log.info("asking", { id, questions: input.questions.length })
|
||||
|
||||
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
||||
const info: Request = {
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
questions: input.questions,
|
||||
tool: input.tool,
|
||||
}
|
||||
pending.set(id, { info, deferred })
|
||||
Bus.publish(Event.Asked, info)
|
||||
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) {
|
||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||
return
|
||||
}
|
||||
pending.delete(input.requestID)
|
||||
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
||||
Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
answers: input.answers,
|
||||
})
|
||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||
})
|
||||
|
||||
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
|
||||
const existing = pending.get(requestID)
|
||||
if (!existing) {
|
||||
log.warn("reject for unknown request", { requestID })
|
||||
return
|
||||
}
|
||||
pending.delete(requestID)
|
||||
log.info("rejected", { requestID })
|
||||
Bus.publish(Event.Rejected, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
})
|
||||
yield* Deferred.fail(existing.deferred, new RejectedError())
|
||||
})
|
||||
|
||||
const list = Effect.fn("Question.list")(function* () {
|
||||
return Array.from(pending.values(), (x) => x.info)
|
||||
})
|
||||
|
||||
return Service.of({ ask, reply, reject, list })
|
||||
}),
|
||||
)
|
||||
|
||||
export async function ask(input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}): Promise<Answer[]> {
|
||||
return runPromiseInstance(S.QuestionService.use((service) => service.ask(input)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.ask(input)))
|
||||
}
|
||||
|
||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
|
||||
return runPromiseInstance(S.QuestionService.use((service) => service.reply(input)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.reply(input)))
|
||||
}
|
||||
|
||||
export async function reject(requestID: QuestionID): Promise<void> {
|
||||
return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID)))
|
||||
return runPromiseInstance(Service.use((svc) => svc.reject(requestID)))
|
||||
}
|
||||
|
||||
export async function list(): Promise<Request[]> {
|
||||
return runPromiseInstance(S.QuestionService.use((service) => service.list()))
|
||||
return runPromiseInstance(Service.use((svc) => svc.list()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Log } from "@/util/log"
|
||||
import z from "zod"
|
||||
import { QuestionID } from "./schema"
|
||||
|
||||
const log = Log.create({ service: "question" })
|
||||
|
||||
// --- Zod schemas (re-exported by facade) ---
|
||||
|
||||
export const Option = z
|
||||
.object({
|
||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||
description: z.string().describe("Explanation of choice"),
|
||||
})
|
||||
.meta({ ref: "QuestionOption" })
|
||||
export type Option = z.infer<typeof Option>
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
question: z.string().describe("Complete question"),
|
||||
header: z.string().describe("Very short label (max 30 chars)"),
|
||||
options: z.array(Option).describe("Available choices"),
|
||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
||||
})
|
||||
.meta({ ref: "QuestionInfo" })
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: QuestionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
questions: z.array(Info).describe("Questions to ask"),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({ ref: "QuestionRequest" })
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
||||
export type Answer = z.infer<typeof Answer>
|
||||
|
||||
export const Reply = z.object({
|
||||
answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||
})
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("question.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"question.replied",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: QuestionID.zod,
|
||||
answers: z.array(Answer),
|
||||
}),
|
||||
),
|
||||
Rejected: BusEvent.define(
|
||||
"question.rejected",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: QuestionID.zod,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user dismissed this question"
|
||||
}
|
||||
}
|
||||
|
||||
// --- Effect service ---
|
||||
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<Answer[], RejectedError>
|
||||
}
|
||||
|
||||
export namespace QuestionService {
|
||||
export interface Service {
|
||||
readonly ask: (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) => Effect.Effect<Answer[], RejectedError>
|
||||
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
||||
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Request[]>
|
||||
}
|
||||
}
|
||||
|
||||
export class QuestionService extends ServiceMap.Service<QuestionService, QuestionService.Service>()(
|
||||
"@opencode/Question",
|
||||
) {
|
||||
static readonly layer = Layer.effect(
|
||||
QuestionService,
|
||||
Effect.gen(function* () {
|
||||
const pending = new Map<QuestionID, PendingEntry>()
|
||||
|
||||
const ask = Effect.fn("QuestionService.ask")(function* (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) {
|
||||
const id = QuestionID.ascending()
|
||||
log.info("asking", { id, questions: input.questions.length })
|
||||
|
||||
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
||||
const info: Request = {
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
questions: input.questions,
|
||||
tool: input.tool,
|
||||
}
|
||||
pending.set(id, { info, deferred })
|
||||
Bus.publish(Event.Asked, info)
|
||||
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) {
|
||||
log.warn("reply for unknown request", { requestID: input.requestID })
|
||||
return
|
||||
}
|
||||
pending.delete(input.requestID)
|
||||
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
||||
Bus.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
answers: input.answers,
|
||||
})
|
||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||
})
|
||||
|
||||
const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) {
|
||||
const existing = pending.get(requestID)
|
||||
if (!existing) {
|
||||
log.warn("reject for unknown request", { requestID })
|
||||
return
|
||||
}
|
||||
pending.delete(requestID)
|
||||
log.info("rejected", { requestID })
|
||||
Bus.publish(Event.Rejected, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
})
|
||||
yield* Deferred.fail(existing.deferred, new RejectedError())
|
||||
})
|
||||
|
||||
const list = Effect.fn("QuestionService.list")(function* () {
|
||||
return Array.from(pending.values(), (x) => x.info)
|
||||
})
|
||||
|
||||
return QuestionService.of({ ask, reply, reject, list })
|
||||
}),
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user