mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-05 00:03:03 +08:00
Compare commits
3 Commits
beta
...
kit/fix-wo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54fa8d5a39 | ||
|
|
2851e632dc | ||
|
|
30d90204b2 |
@@ -158,13 +158,11 @@ export async function handler(
|
||||
Object.entries(obj).flatMap(([k, v]) => {
|
||||
if (Array.isArray(v)) return [[k, v]]
|
||||
if (typeof v === "object") return [[k, replacer(v)]]
|
||||
if (typeof v === "string") {
|
||||
if (v === "$ip") return [[k, ip]]
|
||||
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
|
||||
if (v.startsWith("$header.")) {
|
||||
const headerValue = input.request.headers.get(v.slice(8))
|
||||
return headerValue ? [[k, headerValue]] : []
|
||||
}
|
||||
if (v === "$ip") return [[k, ip]]
|
||||
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
|
||||
if (v.startsWith("$header.")) {
|
||||
const headerValue = input.request.headers.get(v.slice(8))
|
||||
return headerValue ? [[k, headerValue]] : []
|
||||
}
|
||||
return [[k, v]]
|
||||
}),
|
||||
|
||||
@@ -779,15 +779,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: kv.get("clear_prompt_save_history", false) ? "Don't include cleared prompts in history" : "Include cleared prompts in history",
|
||||
value: "app.toggle.clear_prompt_history",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("clear_prompt_save_history", !kv.get("clear_prompt_save_history", false))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
|
||||
@@ -82,7 +82,6 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
||||
return store.history.at(store.index)
|
||||
},
|
||||
append(item: PromptInfo) {
|
||||
if (store.history.at(-1)?.input === item.input) return
|
||||
const entry = structuredClone(unwrap(item))
|
||||
let trimmed = false
|
||||
setStore(
|
||||
|
||||
@@ -136,7 +136,6 @@ export function Prompt(props: PromptProps) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||
@@ -175,7 +174,7 @@ export function Prompt(props: PromptProps) {
|
||||
let lastSubmittedEditorSelectionKey: string | undefined
|
||||
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||
const hasRightContent = createMemo(() => Boolean(props.right) || autoaccept() === "edit")
|
||||
const hasRightContent = createMemo(() => Boolean(props.right))
|
||||
|
||||
function promptModelWarning() {
|
||||
toast.show({
|
||||
@@ -297,16 +296,6 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
command.register(() => {
|
||||
return [
|
||||
{
|
||||
title: autoaccept() === "none" ? "Enable auto-accept edits" : "Disable auto-accept edits",
|
||||
value: "permission.auto_accept.toggle",
|
||||
search: "toggle permissions",
|
||||
category: "Agent",
|
||||
onSelect: (dialog) => {
|
||||
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Clear prompt",
|
||||
value: "prompt.clear",
|
||||
@@ -1135,12 +1124,6 @@ export function Prompt(props: PromptProps) {
|
||||
// If no image, let the default paste behavior continue
|
||||
}
|
||||
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
|
||||
if (kv.get("clear_prompt_save_history", false)) {
|
||||
history.append({
|
||||
...store.prompt,
|
||||
mode: store.mode,
|
||||
})
|
||||
}
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
@@ -1335,11 +1318,6 @@ export function Prompt(props: PromptProps) {
|
||||
</box>
|
||||
<Show when={hasRightContent()}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<Show when={autoaccept() === "edit"}>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning }}>autoedit</span>
|
||||
</text>
|
||||
</Show>
|
||||
{props.right}
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -110,7 +110,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
let syncedWorkspace = project.workspace.current()
|
||||
@@ -153,13 +152,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
case "permission.asked": {
|
||||
const request = event.properties
|
||||
if (autoaccept() === "edit" && request.permission === "edit") {
|
||||
void sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: request.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
const requests = store.permission[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("permission", request.sessionID, [request])
|
||||
|
||||
@@ -8,7 +8,6 @@ import { UI } from "@/cli/ui"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
@@ -191,11 +190,7 @@ export const TuiThreadCommand = cmd({
|
||||
const prompt = await input(args.prompt)
|
||||
const config = await TuiConfig.get()
|
||||
|
||||
const network = await WithInstance.provide({
|
||||
directory: cwd,
|
||||
fn: () => resolveNetworkOptionsNoConfig(args),
|
||||
})
|
||||
|
||||
const network = resolveNetworkOptionsNoConfig(args)
|
||||
const external =
|
||||
process.argv.includes("--port") ||
|
||||
process.argv.includes("--hostname") ||
|
||||
|
||||
@@ -37,7 +37,6 @@ export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
search?: string
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
categoryView?: JSX.Element
|
||||
@@ -94,8 +93,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
// users typically search by the item name, and not its category.
|
||||
const result = fuzzysort
|
||||
.go(needle, options, {
|
||||
keys: ["title", "category", "search"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
|
||||
keys: ["title", "category"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score,
|
||||
})
|
||||
.map((x) => x.obj)
|
||||
|
||||
|
||||
@@ -48,37 +48,19 @@ import { Worktree } from "@/worktree"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { Installation } from "@/installation"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
|
||||
// Adjusts the default Config layer to ensure that plugins are always initialised before
|
||||
// any other layers read the current config
|
||||
const ConfigWithPluginPriority = Layer.effect(
|
||||
Config.Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
return {
|
||||
...config,
|
||||
get: () => Effect.andThen(plugin.init(), config.get),
|
||||
getGlobal: () => Effect.andThen(plugin.init(), config.getGlobal),
|
||||
getConsoleState: () => Effect.andThen(plugin.init(), config.getConsoleState),
|
||||
}
|
||||
}),
|
||||
).pipe(Layer.provide(Layer.merge(Plugin.defaultLayer, Config.defaultLayer)))
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
Npm.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
Account.defaultLayer,
|
||||
ConfigWithPluginPriority,
|
||||
Config.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
File.defaultLayer,
|
||||
|
||||
@@ -138,14 +138,6 @@ function useLanguageModel(sdk: any) {
|
||||
return sdk.responses === undefined && sdk.chat === undefined
|
||||
}
|
||||
|
||||
function selectAzureLanguageModel(sdk: any, modelID: string, useChat: boolean) {
|
||||
if (useChat && sdk.chat) return sdk.chat(modelID)
|
||||
if (sdk.responses) return sdk.responses(modelID)
|
||||
if (sdk.messages) return sdk.messages(modelID)
|
||||
if (sdk.chat) return sdk.chat(modelID)
|
||||
return sdk.languageModel(modelID)
|
||||
}
|
||||
|
||||
function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
return {
|
||||
anthropic: () =>
|
||||
@@ -230,7 +222,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"]))
|
||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||
if (options?.["useCompletionUrls"]) {
|
||||
return sdk.chat(modelID)
|
||||
} else {
|
||||
return sdk.responses(modelID)
|
||||
}
|
||||
},
|
||||
options: {
|
||||
resourceName: resource,
|
||||
@@ -250,7 +247,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"]))
|
||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||
if (options?.["useCompletionUrls"]) {
|
||||
return sdk.chat(modelID)
|
||||
} else {
|
||||
return sdk.responses(modelID)
|
||||
}
|
||||
},
|
||||
options: {
|
||||
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
|
||||
|
||||
@@ -339,8 +339,7 @@ export const Event = {
|
||||
sessionID: Schema.optional(SessionID),
|
||||
// Reuses MessageV2.Assistant.fields.error (already Schema.optional) so
|
||||
// the derived zod keeps the same discriminated-union shape on the bus.
|
||||
// Schema.suspend defers access to break circular init in compiled binaries.
|
||||
error: Schema.suspend(() => MessageV2.Assistant.fields.error),
|
||||
error: MessageV2.Assistant.fields.error,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -256,8 +256,6 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
return array(ast)
|
||||
case "Declaration":
|
||||
return decl(ast)
|
||||
case "Suspend":
|
||||
return z.lazy(() => walk(ast.thunk()))
|
||||
default:
|
||||
return fail(ast)
|
||||
}
|
||||
|
||||
@@ -291,16 +291,15 @@ export const layer: Layer.Layer<
|
||||
|
||||
const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
|
||||
yield* setup(info)
|
||||
yield* boot(info, startCommand)
|
||||
yield* boot(info, startCommand).pipe(
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
})
|
||||
|
||||
const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
|
||||
const info = yield* makeWorktreeInfo(input?.name)
|
||||
yield* setup(info)
|
||||
yield* boot(info, input?.startCommand).pipe(
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
yield* createFromInfo(info, input?.startCommand)
|
||||
return info
|
||||
})
|
||||
|
||||
|
||||
@@ -178,12 +178,13 @@ describe("Worktree", () => {
|
||||
})
|
||||
|
||||
describe("createFromInfo", () => {
|
||||
wintest("creates and bootstraps git worktree", () =>
|
||||
wintest("creates git worktree and boots asynchronously", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const info = yield* svc.makeWorktreeInfo("from-info-test")
|
||||
const ready = waitReady()
|
||||
yield* svc.createFromInfo(info)
|
||||
|
||||
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
|
||||
@@ -191,6 +192,7 @@ describe("Worktree", () => {
|
||||
const normalizedDir = info.directory.replace(/\\/g, "/")
|
||||
expect(normalizedList).toContain(normalizedDir)
|
||||
|
||||
yield* Effect.promise(() => ready)
|
||||
yield* svc.remove({ directory: info.directory })
|
||||
}),
|
||||
{ git: true },
|
||||
|
||||
148
packages/opencode/test/server/worktree-endpoint-repro.test.ts
Normal file
148
packages/opencode/test/server/worktree-endpoint-repro.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import { withTimeout } from "../../src/util/timeout"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const stateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const original = {
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
}
|
||||
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
await resetDatabase()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(stateLayer)
|
||||
type TestServer = ReturnType<typeof HttpRouter.toWebHandler>
|
||||
|
||||
function serverScoped() {
|
||||
return Effect.acquireRelease(
|
||||
Effect.sync(() => HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true })),
|
||||
(server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore),
|
||||
)
|
||||
}
|
||||
|
||||
function request(server: TestServer, input: string, init?: RequestInit) {
|
||||
return Effect.promise(() =>
|
||||
server.handler(new Request(new URL(input, "http://localhost"), init), ExperimentalHttpApiServer.context),
|
||||
)
|
||||
}
|
||||
|
||||
function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {
|
||||
return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label))
|
||||
}
|
||||
|
||||
function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`)
|
||||
expect(current.status).toBe(200)
|
||||
const project = (yield* Effect.promise(() => current.json())) as { id: string }
|
||||
const updated = yield* request(
|
||||
input.server,
|
||||
`/project/${project.id}?directory=${encodeURIComponent(input.directory)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ commands: { start: input.command } }),
|
||||
},
|
||||
)
|
||||
expect(updated.status).toBe(200)
|
||||
})
|
||||
}
|
||||
|
||||
describe("worktree endpoint reproduction", () => {
|
||||
it.instance(
|
||||
"direct HttpApi worktree create returns without waiting for boot",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const server = yield* serverScoped()
|
||||
|
||||
const response = yield* withRequestTimeout(
|
||||
request(server, `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
"direct worktree create",
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: expect.any(String) })
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"workspace worktree create does not hang",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const server = yield* serverScoped()
|
||||
|
||||
const response = yield* withRequestTimeout(
|
||||
request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "worktree", branch: null }),
|
||||
}),
|
||||
"workspace worktree create",
|
||||
8_000,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toMatchObject({
|
||||
type: "worktree",
|
||||
directory: expect.any(String),
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"workspace worktree create returns without waiting for project start command",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const server = yield* serverScoped()
|
||||
yield* setProjectStartCommand({
|
||||
server,
|
||||
directory: test.directory,
|
||||
command: 'bun -e "setTimeout(() => {}, 2000)"',
|
||||
})
|
||||
|
||||
const started = Date.now()
|
||||
const response = yield* withRequestTimeout(
|
||||
request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "worktree", branch: null }),
|
||||
}),
|
||||
"workspace worktree create with project start command",
|
||||
6_000,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(Date.now() - started).toBeLessThan(1_500)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DIFFS_TAG_NAME, FileDiff } from "@pierre/diffs"
|
||||
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
|
||||
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { Dynamic, isServer } from "solid-js/web"
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
notifyShadowReady,
|
||||
observeViewerScheme,
|
||||
} from "../pierre/file-runtime"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { File, type DiffFileProps, type FileProps } from "./file"
|
||||
|
||||
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
|
||||
@@ -25,6 +26,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
let container!: HTMLDivElement
|
||||
let fileDiffRef!: HTMLElement
|
||||
let fileDiffInstance: FileDiff<T> | undefined
|
||||
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||
|
||||
const ready = createReadyWatcher()
|
||||
const workerPool = useWorkerPool(props.diffStyle)
|
||||
@@ -49,6 +51,14 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
|
||||
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
|
||||
|
||||
const getVirtualizer = () => {
|
||||
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
|
||||
const result = acquireVirtualizer(container)
|
||||
if (!result) return
|
||||
sharedVirtualizer = result
|
||||
return result.virtualizer
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
@@ -82,15 +92,27 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
|
||||
onCleanup(observeViewerScheme(() => fileDiffRef))
|
||||
|
||||
const virtualizer = getVirtualizer()
|
||||
const annotations = local.annotations ?? local.preloadedDiff.annotations ?? []
|
||||
fileDiffInstance = new FileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...(local.preloadedDiff.options ?? {}),
|
||||
},
|
||||
workerPool,
|
||||
)
|
||||
fileDiffInstance = virtualizer
|
||||
? new VirtualizedFileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff.options,
|
||||
},
|
||||
virtualizer,
|
||||
virtualMetrics,
|
||||
workerPool,
|
||||
)
|
||||
: new FileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff.options,
|
||||
},
|
||||
workerPool,
|
||||
)
|
||||
|
||||
applyViewerScheme(fileDiffRef)
|
||||
|
||||
@@ -141,6 +163,8 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
onCleanup(() => {
|
||||
clearReadyWatcher(ready)
|
||||
fileDiffInstance?.cleanUp()
|
||||
sharedVirtualizer?.release()
|
||||
sharedVirtualizer = undefined
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sampledChecksum } from "@opencode-ai/core/util/encode"
|
||||
import {
|
||||
DEFAULT_VIRTUAL_FILE_METRICS,
|
||||
type DiffLineAnnotation,
|
||||
type FileContents,
|
||||
type FileDiffMetadata,
|
||||
@@ -9,6 +10,10 @@ import {
|
||||
type FileOptions,
|
||||
type LineAnnotation,
|
||||
type SelectedLineRange,
|
||||
type VirtualFileMetrics,
|
||||
VirtualizedFile,
|
||||
VirtualizedFileDiff,
|
||||
Virtualizer,
|
||||
} from "@pierre/diffs"
|
||||
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
@@ -35,10 +40,19 @@ import {
|
||||
readShadowLineSelection,
|
||||
} from "../pierre/file-selection"
|
||||
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { getWorkerPool } from "../pierre/worker"
|
||||
import { FileMedia, type FileMediaOptions } from "./file-media"
|
||||
import { FileSearchBar } from "./file-search"
|
||||
|
||||
const VIRTUALIZE_BYTES = 500_000
|
||||
|
||||
const codeMetrics = {
|
||||
...DEFAULT_VIRTUAL_FILE_METRICS,
|
||||
lineHeight: 24,
|
||||
fileGap: 0,
|
||||
} satisfies Partial<VirtualFileMetrics>
|
||||
|
||||
type SharedProps<T> = {
|
||||
annotations?: LineAnnotation<T>[] | DiffLineAnnotation<T>[]
|
||||
selectedLines?: SelectedLineRange | null
|
||||
@@ -372,6 +386,11 @@ type AnnotationTarget<A> = {
|
||||
rerender: () => void
|
||||
}
|
||||
|
||||
type VirtualStrategy = {
|
||||
get: () => Virtualizer | undefined
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
|
||||
return useFileViewer({
|
||||
enableLineSelection: config.enableLineSelection,
|
||||
@@ -513,6 +532,64 @@ function scrollParent(el: HTMLElement): HTMLElement | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
|
||||
let virtualizer: Virtualizer | undefined
|
||||
let root: Document | HTMLElement | undefined
|
||||
|
||||
const release = () => {
|
||||
virtualizer?.cleanUp()
|
||||
virtualizer = undefined
|
||||
root = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
get: () => {
|
||||
if (!enabled()) {
|
||||
release()
|
||||
return
|
||||
}
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const wrapper = host()
|
||||
if (!wrapper) return
|
||||
|
||||
const next = scrollParent(wrapper) ?? document
|
||||
if (virtualizer && root === next) return virtualizer
|
||||
|
||||
release()
|
||||
virtualizer = new Virtualizer()
|
||||
root = next
|
||||
virtualizer.setup(next, next instanceof Document ? undefined : wrapper)
|
||||
return virtualizer
|
||||
},
|
||||
cleanup: release,
|
||||
}
|
||||
}
|
||||
|
||||
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy {
|
||||
let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||
|
||||
const release = () => {
|
||||
shared?.release()
|
||||
shared = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
get: () => {
|
||||
if (shared) return shared.virtualizer
|
||||
|
||||
const container = host()
|
||||
if (!container) return
|
||||
|
||||
const result = acquireVirtualizer(container)
|
||||
if (!result) return
|
||||
shared = result
|
||||
return result.virtualizer
|
||||
},
|
||||
cleanup: release,
|
||||
}
|
||||
}
|
||||
|
||||
function parseLine(node: HTMLElement) {
|
||||
if (!node.dataset.line) return
|
||||
const value = parseInt(node.dataset.line, 10)
|
||||
@@ -611,7 +688,7 @@ function ViewerShell(props: {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TextViewer<T>(props: TextFileProps<T>) {
|
||||
let instance: PierreFile<T> | undefined
|
||||
let instance: PierreFile<T> | VirtualizedFile<T> | undefined
|
||||
let viewer!: Viewer
|
||||
|
||||
const [local, others] = splitProps(props, textKeys)
|
||||
@@ -631,12 +708,36 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
return Math.max(1, total)
|
||||
}
|
||||
|
||||
const bytes = createMemo(() => {
|
||||
const value = local.file.contents as unknown
|
||||
if (typeof value === "string") return value.length
|
||||
if (Array.isArray(value)) {
|
||||
return value.reduce(
|
||||
// oxlint-disable-next-line no-base-to-string -- array parts coerced intentionally
|
||||
(sum, part) => sum + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
|
||||
0,
|
||||
)
|
||||
}
|
||||
if (value == null) return 0
|
||||
// oxlint-disable-next-line no-base-to-string -- file contents cast to unknown, coercion is intentional
|
||||
return String(value).length
|
||||
})
|
||||
|
||||
const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
|
||||
|
||||
const virtuals = createLocalVirtualStrategy(() => viewer.wrapper, virtual)
|
||||
|
||||
const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, parseLine)
|
||||
|
||||
const applySelection = (range: SelectedLineRange | null) => {
|
||||
const current = instance
|
||||
if (!current) return false
|
||||
|
||||
if (virtual()) {
|
||||
current.setSelectedLines(range)
|
||||
return true
|
||||
}
|
||||
|
||||
const root = viewer.getRoot()
|
||||
if (!root) return false
|
||||
|
||||
@@ -735,7 +836,10 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
const notify = () => {
|
||||
notifyRendered({
|
||||
viewer,
|
||||
isReady: (root) => root.querySelectorAll("[data-line]").length >= lineCount(),
|
||||
isReady: (root) => {
|
||||
if (virtual()) return root.querySelector("[data-line]") != null
|
||||
return root.querySelectorAll("[data-line]").length >= lineCount()
|
||||
},
|
||||
onReady: () => {
|
||||
applySelection(viewer.lastSelection)
|
||||
viewer.find.refresh({ reset: true })
|
||||
@@ -754,11 +858,17 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
createEffect(() => {
|
||||
const opts = options()
|
||||
const workerPool = getWorkerPool("unified")
|
||||
const isVirtual = virtual()
|
||||
|
||||
const virtualizer = virtuals.get()
|
||||
|
||||
renderViewer({
|
||||
viewer,
|
||||
current: instance,
|
||||
create: () => new PierreFile<T>(opts, workerPool),
|
||||
create: () =>
|
||||
isVirtual && virtualizer
|
||||
? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
|
||||
: new PierreFile<T>(opts, workerPool),
|
||||
assign: (value) => {
|
||||
instance = value
|
||||
},
|
||||
@@ -785,6 +895,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
onCleanup(() => {
|
||||
instance?.cleanUp()
|
||||
instance = undefined
|
||||
virtuals.cleanup()
|
||||
})
|
||||
|
||||
return <ViewerShell mode="text" viewer={viewer} class={local.class} classList={local.classList} />
|
||||
@@ -880,6 +991,8 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
adapter,
|
||||
)
|
||||
|
||||
const virtuals = createSharedVirtualStrategy(() => viewer.container)
|
||||
|
||||
const large = createMemo(() => {
|
||||
if (local.fileDiff) {
|
||||
const before = local.fileDiff.deletionLines.join("")
|
||||
@@ -942,6 +1055,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
createEffect(() => {
|
||||
const opts = options()
|
||||
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
|
||||
const virtualizer = virtuals.get()
|
||||
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
|
||||
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
|
||||
const done = preserve(viewer)
|
||||
@@ -956,7 +1070,10 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
renderViewer({
|
||||
viewer,
|
||||
current: instance,
|
||||
create: () => new FileDiff<T>(opts, workerPool),
|
||||
create: () =>
|
||||
virtualizer
|
||||
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
|
||||
: new FileDiff<T>(opts, workerPool),
|
||||
assign: (value) => {
|
||||
instance = value
|
||||
},
|
||||
@@ -994,6 +1111,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
onCleanup(() => {
|
||||
instance?.cleanUp()
|
||||
instance = undefined
|
||||
virtuals.cleanup()
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { LineCommentEditorProps } from "./line-comment"
|
||||
import { normalize, text, type ViewDiff } from "./session-diff"
|
||||
|
||||
const MAX_DIFF_CHANGED_LINES = 500
|
||||
const REVIEW_MOUNT_MARGIN = 300
|
||||
|
||||
export type SessionReviewDiffStyle = "unified" | "split"
|
||||
|
||||
@@ -158,11 +159,14 @@ type SessionReviewSelection = {
|
||||
export const SessionReview = (props: SessionReviewProps) => {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let focusToken = 0
|
||||
let frame: number | undefined
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
const anchors = new Map<string, HTMLElement>()
|
||||
const nodes = new Map<string, HTMLDivElement>()
|
||||
const [store, setStore] = createStore({
|
||||
open: [] as string[],
|
||||
visible: {} as Record<string, boolean>,
|
||||
force: {} as Record<string, boolean>,
|
||||
selection: null as SessionReviewSelection | null,
|
||||
commenting: null as SessionReviewSelection | null,
|
||||
@@ -192,7 +196,44 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
||||
const hasDiffs = () => files().length > 0
|
||||
|
||||
const syncVisible = () => {
|
||||
frame = undefined
|
||||
if (!scroll) return
|
||||
|
||||
const root = scroll.getBoundingClientRect()
|
||||
const top = root.top - REVIEW_MOUNT_MARGIN
|
||||
const bottom = root.bottom + REVIEW_MOUNT_MARGIN
|
||||
const openSet = new Set(open())
|
||||
const next: Record<string, boolean> = {}
|
||||
|
||||
for (const [file, el] of nodes) {
|
||||
if (!openSet.has(file)) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.bottom < top || rect.top > bottom) continue
|
||||
next[file] = true
|
||||
}
|
||||
|
||||
const prev = untrack(() => store.visible)
|
||||
const prevKeys = Object.keys(prev)
|
||||
const nextKeys = Object.keys(next)
|
||||
if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return
|
||||
setStore("visible", next)
|
||||
}
|
||||
|
||||
const queue = () => {
|
||||
if (frame !== undefined) return
|
||||
frame = requestAnimationFrame(syncVisible)
|
||||
}
|
||||
|
||||
const pinned = (file: string) =>
|
||||
props.focusedComment?.file === file ||
|
||||
props.focusedFile === file ||
|
||||
selection()?.file === file ||
|
||||
commenting()?.file === file ||
|
||||
opened()?.file === file
|
||||
|
||||
const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
|
||||
queue()
|
||||
const next = props.onScroll
|
||||
if (!next) return
|
||||
if (Array.isArray(next)) {
|
||||
@@ -203,9 +244,21 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (frame === undefined) return
|
||||
cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.open
|
||||
files()
|
||||
queue()
|
||||
})
|
||||
|
||||
const handleChange = (next: string[]) => {
|
||||
props.onOpenChange?.(next)
|
||||
if (props.open === undefined) setStore("open", next)
|
||||
queue()
|
||||
}
|
||||
|
||||
const handleExpandOrCollapseAll = () => {
|
||||
@@ -319,6 +372,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
viewportRef={(el) => {
|
||||
scroll = el
|
||||
props.scrollRef?.(el)
|
||||
queue()
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
classList={{
|
||||
@@ -337,6 +391,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const diffCanRender = () => diff.additions !== 0 || diff.deletions !== 0
|
||||
|
||||
const expanded = createMemo(() => open().includes(file))
|
||||
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
|
||||
const force = () => !!store.force[file]
|
||||
|
||||
const comments = createMemo(() => grouped().get(file) ?? [])
|
||||
@@ -427,6 +482,8 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
|
||||
onCleanup(() => {
|
||||
anchors.delete(file)
|
||||
nodes.delete(file)
|
||||
queue()
|
||||
})
|
||||
|
||||
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||
@@ -512,10 +569,19 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
data-slot="session-review-diff-wrapper"
|
||||
ref={(el) => {
|
||||
anchors.set(file, el)
|
||||
nodes.set(file, el)
|
||||
queue()
|
||||
}}
|
||||
>
|
||||
<Show when={expanded()}>
|
||||
<Switch>
|
||||
<Match when={!mounted() && !tooLarge()}>
|
||||
<div
|
||||
data-slot="session-review-diff-placeholder"
|
||||
class="rounded-lg border border-border-weak-base bg-background-stronger/40"
|
||||
style={{ height: "160px" }}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={tooLarge()}>
|
||||
<div data-slot="session-review-large-diff">
|
||||
<div data-slot="session-review-large-diff-title">
|
||||
|
||||
100
packages/ui/src/pierre/virtualizer.ts
Normal file
100
packages/ui/src/pierre/virtualizer.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { type VirtualFileMetrics, Virtualizer } from "@pierre/diffs"
|
||||
|
||||
type Target = {
|
||||
key: Document | HTMLElement
|
||||
root: Document | HTMLElement
|
||||
content: HTMLElement | undefined
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
virtualizer: Virtualizer
|
||||
refs: number
|
||||
}
|
||||
|
||||
const cache = new WeakMap<Document | HTMLElement, Entry>()
|
||||
|
||||
export const virtualMetrics: Partial<VirtualFileMetrics> = {
|
||||
lineHeight: 24,
|
||||
hunkSeparatorHeight: 24,
|
||||
fileGap: 0,
|
||||
}
|
||||
|
||||
function scrollable(value: string) {
|
||||
return value === "auto" || value === "scroll" || value === "overlay"
|
||||
}
|
||||
|
||||
function scrollRoot(container: HTMLElement) {
|
||||
let node = container.parentElement
|
||||
while (node) {
|
||||
const style = getComputedStyle(node)
|
||||
if (scrollable(style.overflowY)) return node
|
||||
node = node.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
function target(container: HTMLElement): Target | undefined {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const review = container.closest("[data-component='session-review']")
|
||||
if (review instanceof HTMLElement) {
|
||||
const root = scrollRoot(container) ?? review
|
||||
const content = review.querySelector("[data-slot='session-review-container']")
|
||||
return {
|
||||
key: review,
|
||||
root,
|
||||
content: content instanceof HTMLElement ? content : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const root = scrollRoot(container)
|
||||
if (root) {
|
||||
const content = root.querySelector("[role='log']")
|
||||
return {
|
||||
key: root,
|
||||
root,
|
||||
content: content instanceof HTMLElement ? content : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: document,
|
||||
root: document,
|
||||
content: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function acquireVirtualizer(container: HTMLElement) {
|
||||
const resolved = target(container)
|
||||
if (!resolved) return
|
||||
|
||||
let entry = cache.get(resolved.key)
|
||||
if (!entry) {
|
||||
const virtualizer = new Virtualizer()
|
||||
virtualizer.setup(resolved.root, resolved.content)
|
||||
entry = {
|
||||
virtualizer,
|
||||
refs: 0,
|
||||
}
|
||||
cache.set(resolved.key, entry)
|
||||
}
|
||||
|
||||
entry.refs += 1
|
||||
let done = false
|
||||
|
||||
return {
|
||||
virtualizer: entry.virtualizer,
|
||||
release() {
|
||||
if (done) return
|
||||
done = true
|
||||
|
||||
const current = cache.get(resolved.key)
|
||||
if (!current) return
|
||||
|
||||
current.refs -= 1
|
||||
if (current.refs > 0) return
|
||||
|
||||
current.virtualizer.cleanUp()
|
||||
cache.delete(resolved.key)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -648,17 +648,17 @@ OpenCode Go هي خطة اشتراك منخفضة التكلفة توفّر وص
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. توجّه إلى [FrogBot dashboard](https://app.frogbot.ai/signup)، وأنشئ حسابا، ثم أنشئ مفتاح API.
|
||||
1. توجّه إلى [Firmware dashboard](https://app.firmware.ai/signup)، وأنشئ حسابا، ثم أنشئ مفتاح API.
|
||||
|
||||
2. شغّل الأمر `/connect` وابحث عن **FrogBot**.
|
||||
2. شغّل الأمر `/connect` وابحث عن **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. أدخل مفتاح API الخاص بـ FrogBot.
|
||||
3. أدخل مفتاح API الخاص بـ Firmware.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -653,17 +653,17 @@ Također možete dodati modele kroz svoju opencode konfiguraciju.
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Idite na [kontrolnu tablu firmvera](https://app.frogbot.ai/signup), kreirajte nalog i generišite API ključ.
|
||||
1. Idite na [kontrolnu tablu firmvera](https://app.firmware.ai/signup), kreirajte nalog i generišite API ključ.
|
||||
|
||||
2. Pokrenite naredbu `/connect` i potražite **FrogBot**.
|
||||
2. Pokrenite naredbu `/connect` i potražite **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Unesite svoj FrogBot API ključ.
|
||||
3. Unesite svoj Firmware API ključ.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -644,17 +644,17 @@ Cloudflare AI Gateway lader dig få adgang til modeller fra OpenAI, Anthropic, W
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Gå til [FrogBot dashboard](https://app.frogbot.ai/signup), opret en konto og generer en API-nøgle.
|
||||
1. Gå til [Firmware dashboard](https://app.firmware.ai/signup), opret en konto og generer en API-nøgle.
|
||||
|
||||
2. Kør kommandoen `/connect` og søg efter **FrogBot**.
|
||||
2. Kør kommandoen `/connect` og søg efter **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Indtast frogbot API-nøglen.
|
||||
3. Indtast firmware API-nøglen.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -650,17 +650,17 @@ Mit dem Cloudflare AI Gateway können Sie über einen einheitlichen Endpunkt auf
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Gehen Sie zu [FrogBot dashboard](https://app.frogbot.ai/signup), erstellen Sie ein Konto und generieren Sie einen API-Schlüssel.
|
||||
1. Gehen Sie zu [Firmware dashboard](https://app.firmware.ai/signup), erstellen Sie ein Konto und generieren Sie einen API-Schlüssel.
|
||||
|
||||
2. Führen Sie den Befehl `/connect` aus und suchen Sie nach **FrogBot**.
|
||||
2. Führen Sie den Befehl `/connect` aus und suchen Sie nach **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Geben Sie Ihren FrogBot API-Schlüssel ein.
|
||||
3. Geben Sie Ihren Firmware API-Schlüssel ein.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -651,17 +651,17 @@ Cloudflare AI Gateway le permite acceder a modelos de OpenAI, Anthropic, Workers
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Dirígete al [Panel de frogbot](https://app.frogbot.ai/signup), crea una cuenta y genera una clave API.
|
||||
1. Dirígete al [Panel de firmware](https://app.firmware.ai/signup), crea una cuenta y genera una clave API.
|
||||
|
||||
2. Ejecute el comando `/connect` y busque **FrogBot**.
|
||||
2. Ejecute el comando `/connect` y busque **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Ingrese su clave de frogbot API.
|
||||
3. Ingrese su clave de firmware API.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -654,11 +654,11 @@ Vous pouvez également ajouter des modèles via votre configuration opencode.
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Rendez-vous sur le [Tableau de bord du micrologiciel](https://app.frogbot.ai/signup), créez un compte et générez une clé API.
|
||||
1. Rendez-vous sur le [Tableau de bord du micrologiciel](https://app.firmware.ai/signup), créez un compte et générez une clé API.
|
||||
|
||||
2. Exécutez la commande `/connect` et recherchez **FrogBot**.
|
||||
2. Exécutez la commande `/connect` et recherchez **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
|
||||
@@ -628,17 +628,17 @@ Cloudflare AI Gateway ti permette di accedere a modelli di OpenAI, Anthropic, Wo
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Vai alla [dashboard di FrogBot](https://app.frogbot.ai/signup), crea un account e genera una chiave API.
|
||||
1. Vai alla [dashboard di Firmware](https://app.firmware.ai/signup), crea un account e genera una chiave API.
|
||||
|
||||
2. Esegui il comando `/connect` e cerca **FrogBot**.
|
||||
2. Esegui il comando `/connect` e cerca **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Inserisci la tua chiave API di FrogBot.
|
||||
3. Inserisci la tua chiave API di Firmware.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -658,9 +658,9 @@ OpenCode 設定を通じてモデルを追加することもできます。
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. [ファームウェアダッシュボード](https://app.frogbot.ai/signup) に移動し、アカウントを作成し、API キーを生成します。
|
||||
1. [ファームウェアダッシュボード](https://app.firmware.ai/signup) に移動し、アカウントを作成し、API キーを生成します。
|
||||
|
||||
2. `/connect` コマンドを実行し、**ファームウェア**を検索します。
|
||||
|
||||
|
||||
@@ -654,17 +654,17 @@ Cloudflare AI Gateway는 OpenAI, Anthropic, Workers AI 등의 모델에 액세
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. [FrogBot 대시보드](https://app.frogbot.ai/signup)로 이동하여 계정을 만들고 API 키를 생성합니다.
|
||||
1. [Firmware 대시보드](https://app.firmware.ai/signup)로 이동하여 계정을 만들고 API 키를 생성합니다.
|
||||
|
||||
2. `/connect` 명령을 실행하고 **FrogBot**를 검색하십시오.
|
||||
2. `/connect` 명령을 실행하고 **Firmware**를 검색하십시오.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. FrogBot API 키를 입력하십시오.
|
||||
3. Firmware API 키를 입력하십시오.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -652,17 +652,17 @@ Cloudflare AI Gateway lar deg få tilgang til modeller fra OpenAI, Anthropic, Wo
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Gå over til [FrogBot dashboard](https://app.frogbot.ai/signup), opprett en konto og generer en API nøkkel.
|
||||
1. Gå over til [Firmware dashboard](https://app.firmware.ai/signup), opprett en konto og generer en API nøkkel.
|
||||
|
||||
2. Kjør kommandoen `/connect` og søk etter **FrogBot**.
|
||||
2. Kjør kommandoen `/connect` og søk etter **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Skriv inn frogbot API nøkkelen.
|
||||
3. Skriv inn firmware API nøkkelen.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -650,17 +650,17 @@ Cloudflare AI Gateway umożliwia dostęp do modeli z OpenAI, Anthropic, Workers
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Przejdź do [FrogBot dashboard](https://app.frogbot.ai/signup), utwórz konto i wygeneruj klucz API.
|
||||
1. Przejdź do [Firmware dashboard](https://app.firmware.ai/signup), utwórz konto i wygeneruj klucz API.
|
||||
|
||||
2. Uruchom polecenie `/connect` i wyszukaj **FrogBot**.
|
||||
2. Uruchom polecenie `/connect` i wyszukaj **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Wprowadź klucz API FrogBot.
|
||||
3. Wprowadź klucz API Firmware.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -721,17 +721,17 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Head over to the [FrogBot dashboard](https://app.frogbot.ai/signup), create an account, and generate an API key.
|
||||
1. Head over to the [Firmware dashboard](https://app.firmware.ai/signup), create an account, and generate an API key.
|
||||
|
||||
2. Run the `/connect` command and search for **FrogBot**.
|
||||
2. Run the `/connect` command and search for **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Enter your FrogBot API key.
|
||||
3. Enter your Firmware API key.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -654,17 +654,17 @@ O Cloudflare AI Gateway permite que você acesse modelos do OpenAI, Anthropic, W
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Acesse o [painel FrogBot](https://app.frogbot.ai/signup), crie uma conta e gere uma chave da API.
|
||||
1. Acesse o [painel Firmware](https://app.firmware.ai/signup), crie uma conta e gere uma chave da API.
|
||||
|
||||
2. Execute o comando `/connect` e procure por **FrogBot**.
|
||||
2. Execute o comando `/connect` e procure por **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Insira sua chave da API FrogBot.
|
||||
3. Insira sua chave da API Firmware.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -650,17 +650,17 @@ Cloudflare AI Gateway позволяет вам получать доступ к
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. Перейдите на [панель FrogBot](https://app.frogbot.ai/signup), создайте учетную запись и сгенерируйте ключ API.
|
||||
1. Перейдите на [панель Firmware](https://app.firmware.ai/signup), создайте учетную запись и сгенерируйте ключ API.
|
||||
|
||||
2. Запустите команду `/connect` и найдите **FrogBot**.
|
||||
2. Запустите команду `/connect` и найдите **Firmware**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Введите ключ API FrogBot.
|
||||
3. Введите ключ API Firmware.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -650,17 +650,17 @@ Cloudflare AI Gateway ช่วยให้คุณเข้าถึงโม
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. ไปที่ [แดชบอร์ด FrogBot](https://app.frogbot.ai/signup) สร้างบัญชี และสร้างคีย์ API
|
||||
1. ไปที่ [แดชบอร์ด Firmware](https://app.firmware.ai/signup) สร้างบัญชี และสร้างคีย์ API
|
||||
|
||||
2. เรียกใช้คำสั่ง `/connect` และค้นหา **FrogBot**
|
||||
2. เรียกใช้คำสั่ง `/connect` และค้นหา **Firmware**
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. ป้อนคีย์ FrogBot API ของคุณ
|
||||
3. ป้อนคีย์ Firmware API ของคุณ
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -652,17 +652,17 @@ Cloudflare AI Gateway, OpenAI, Anthropic, Workers AI ve daha fazlasındaki model
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. [FrogBot dashboard](https://app.frogbot.ai/signup) adresine gidin, bir hesap oluşturun ve bir API anahtarı oluşturun.
|
||||
1. [Firmware dashboard](https://app.firmware.ai/signup) adresine gidin, bir hesap oluşturun ve bir API anahtarı oluşturun.
|
||||
|
||||
2. `/connect` komutunu çalıştırın ve **FrogBot**'i arayın.
|
||||
2. `/connect` komutunu çalıştırın ve **Firmware**'i arayın.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. FrogBot API anahtarınızı girin.
|
||||
3. Firmware API anahtarınızı girin.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -624,17 +624,17 @@ Cloudflare AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. 前往 [FrogBot 仪表盘](https://app.frogbot.ai/signup),创建账户并生成 API 密钥。
|
||||
1. 前往 [Firmware 仪表盘](https://app.firmware.ai/signup),创建账户并生成 API 密钥。
|
||||
|
||||
2. 执行 `/connect` 命令并搜索 **FrogBot**。
|
||||
2. 执行 `/connect` 命令并搜索 **Firmware**。
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. 输入你的 FrogBot API 密钥。
|
||||
3. 输入你的 Firmware API 密钥。
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -645,17 +645,17 @@ Cloudflare AI Gateway 允許您透過統一端點存取來自 OpenAI、Anthropic
|
||||
|
||||
---
|
||||
|
||||
### FrogBot
|
||||
### Firmware
|
||||
|
||||
1. 前往 [FrogBot 儀表板](https://app.frogbot.ai/signup),建立帳號並產生 API 金鑰。
|
||||
1. 前往 [Firmware 儀表板](https://app.firmware.ai/signup),建立帳號並產生 API 金鑰。
|
||||
|
||||
2. 執行 `/connect` 指令並搜尋 **FrogBot**。
|
||||
2. 執行 `/connect` 指令並搜尋 **Firmware**。
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. 輸入您的 FrogBot API 金鑰。
|
||||
3. 輸入您的 Firmware API 金鑰。
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
Reference in New Issue
Block a user