mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
feat(vscode): multi-provider selection with connect/disconnect/OAuth flows (#7295)
* tmp * tmp * tmp * tmp * tmp * feat(vscode): multi-provider selection with connect/disconnect/OAuth flows Reimplements the provider management UI in the VS Code extension settings: - Split Providers tab into Models (model selection) and Providers (connection management) - Kilo Gateway always shown at top with login state from profile data - Provider connect dialog with API key and OAuth (code + auto) flows - Custom provider dialog for OpenAI-compatible providers via base URL - Provider action request-response pairing via createProviderAction utility - Coalesced fetchAndSendProviders prevents request floods (single in-flight + one queued) - SSE event deduplication: server.instance.disposed filtered by workspace directory - Login guard prevents concurrent device auth flows - Model selection fallback chain: override > mode config > global config > recents > kilo-auto - Recent models persisted in extension globalState (last 5, deduplicated) - Shared validation in src/shared/ (provider-model.ts, custom-provider.ts) - 16 locale translations for all new UI strings - Unit tests for custom-provider validation, model selection, provider actions, visibility * Add tests * chore: update kilo-vscode visual regression baselines * fix(vscode): log swallowed auth.remove errors for configured providers When a configured provider has both a config entry and an auth store entry, and auth.remove fails transiently, the error was silently swallowed. Now logs a warning so the failure is visible in debug output. * fix(vscode): validate recentModels shape and enforce size limit on extension side Webview messages are an untrusted boundary. persistRecents and requestRecents now validate array shape (providerID/modelID must be strings) and enforce RECENT_LIMIT=5 on the extension side, not just in the webview. Malformed globalState entries are filtered out on read. * fix(vscode): normalize directory paths in server.instance.disposed filter Use path.resolve() when comparing the event directory against the workspace directory. Prevents false mismatches from trailing slashes or case differences on case-insensitive filesystems. * fix(vscode): remove duplicate requestRecents case branches (dead code) Three consecutive case "requestRecents" blocks existed from iterative refinement. Only the first executes in a switch. Removed the two dead duplicates, keeping the validated version using validateRecents(). * fix(vscode): don't navigate to profile after login Login can now be triggered from the settings tab (Kilo Gateway sign-in button). The forced navigation to the profile view after login left the user stuck on the profile tab with no way back to settings without closing and reopening. The profile data push is sufficient — the settings Kilo row updates reactively via server.profileData(). * fix(vscode): mask API key input in custom provider dialog Add type="password" to the API key TextField so it renders as dots instead of plain text. * refactor(vscode): extract inline styles from ProviderConnectDialog to CSS classes Move repeated inline styles to chat.css classes: - .provider-connect-body (text body styling) - .provider-connect-code-label (confirmation code label) - .provider-connect-code (monospace code block) - .provider-connect-status (spinner + status text row) * fix: revert unrelated opencode/package.json changes * chore: update kilo-vscode visual regression baselines * fix(vscode): send rollback configUpdated on failed config save When global.config.update fails, send configUpdated with the last known good config so the webview clears its saving flag and reverts optimistic state. Without this, a failed save leaves saving=true forever, causing subsequent configLoaded messages to be ignored. --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
@@ -31,5 +31,11 @@ export default [
|
||||
"max-lines": ["error", 3000],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/KiloProvider.ts"],
|
||||
rules: {
|
||||
"max-lines": ["error", 3200],
|
||||
},
|
||||
},
|
||||
eslintConfigPrettier,
|
||||
]
|
||||
|
||||
@@ -39,6 +39,18 @@ import { MarketplaceService } from "./services/marketplace"
|
||||
import { resolveProjectDirectory } from "./project-directory"
|
||||
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
|
||||
|
||||
import {
|
||||
buildActionContext,
|
||||
computeDefaultSelection,
|
||||
fetchProviderData,
|
||||
validateRecents,
|
||||
connectProvider as connectProviderAction,
|
||||
authorizeProviderOAuth as authorizeOAuthAction,
|
||||
completeProviderOAuth as completeOAuthAction,
|
||||
disconnectProvider as disconnectProviderAction,
|
||||
saveCustomProvider as saveCustomProviderAction,
|
||||
} from "./provider-actions"
|
||||
|
||||
type KiloProviderOptions = {
|
||||
projectDirectory?: string | null
|
||||
slimEditMetadata?: boolean
|
||||
@@ -56,6 +68,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
vscode.extensions.getExtension("kilocode.kilo-code")?.packageJSON?.version ?? "unknown"
|
||||
/** Cached providersLoaded payload so requestProviders can be served before client is ready */
|
||||
private cachedProvidersMessage: unknown = null
|
||||
/** Coalesce provider refreshes — at most one follow-up rerun when a request lands mid-flight. */
|
||||
private providersRefresh: Promise<void> | null = null
|
||||
private providersQueued = false
|
||||
private providersGeneration = 0
|
||||
/** Cached agentsLoaded payload so requestAgents can be served before client is ready */
|
||||
private cachedAgentsMessage: unknown = null
|
||||
/** Cached skillsLoaded payload so requestSkills can be served before client is ready */
|
||||
@@ -548,6 +564,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
case "requestProviders":
|
||||
this.fetchAndSendProviders().catch((e) => console.error("[Kilo New] fetchAndSendProviders failed:", e))
|
||||
break
|
||||
case "connectProvider":
|
||||
case "authorizeProviderOAuth":
|
||||
case "completeProviderOAuth":
|
||||
case "disconnectProvider":
|
||||
case "saveCustomProvider":
|
||||
await this.handleProviderAction(message)
|
||||
break
|
||||
case "compact":
|
||||
await this.handleCompact(message.sessionID, message.providerID, message.modelID)
|
||||
break
|
||||
@@ -718,6 +741,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.postMessage({ type: "variantsLoaded", variants })
|
||||
break
|
||||
}
|
||||
case "persistRecents":
|
||||
await this.extensionContext?.globalState.update("recentModels", validateRecents(message.recents))
|
||||
break
|
||||
case "requestRecents": {
|
||||
const recents = validateRecents(this.extensionContext?.globalState.get("recentModels"))
|
||||
this.postMessage({ type: "recentsLoaded", recents })
|
||||
break
|
||||
}
|
||||
// legacy-migration start
|
||||
case "requestLegacyMigrationData":
|
||||
void this.handleRequestLegacyMigrationData()
|
||||
@@ -1225,45 +1256,106 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch providers from the backend and send to webview.
|
||||
*
|
||||
* The backend `/provider` endpoint returns `all` as an array-like object with
|
||||
* numeric keys ("0", "1", …). The webview and sendMessage both need providers
|
||||
* keyed by their real `provider.id` (e.g. "anthropic", "openai"). We re-key
|
||||
* the map here so the rest of the code can use provider.id everywhere.
|
||||
*/
|
||||
/** Fetch providers and send to webview. Coalesced: at most one in-flight + one queued. */
|
||||
private async fetchAndSendProviders(): Promise<void> {
|
||||
if (!this.client) {
|
||||
// client not ready — serve from cache if available
|
||||
if (this.cachedProvidersMessage) {
|
||||
this.postMessage(this.cachedProvidersMessage)
|
||||
}
|
||||
const next = ++this.providersGeneration
|
||||
if (this.providersRefresh) {
|
||||
this.providersQueued = true
|
||||
await this.providersRefresh
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const workspaceDir = this.getWorkspaceDirectory()
|
||||
const { data: response } = await this.client.provider.list({ directory: workspaceDir }, { throwOnError: true })
|
||||
|
||||
const normalized = indexProvidersById(response.all)
|
||||
|
||||
const config = vscode.workspace.getConfiguration("kilo-code.new.model")
|
||||
const providerID = config.get<string>("providerID", "kilo")
|
||||
const modelID = config.get<string>("modelID", "kilo-auto/free")
|
||||
|
||||
const message = {
|
||||
type: "providersLoaded",
|
||||
providers: normalized,
|
||||
connected: response.connected,
|
||||
defaults: response.default,
|
||||
defaultSelection: { providerID, modelID },
|
||||
const task = (async () => {
|
||||
let generation = next
|
||||
while (true) {
|
||||
this.providersQueued = false
|
||||
const client = this.client
|
||||
if (!client) {
|
||||
if (this.cachedProvidersMessage && generation === this.providersGeneration)
|
||||
this.postMessage(this.cachedProvidersMessage)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { response, authMethods, authStates } = await fetchProviderData(client, this.getWorkspaceDirectory())
|
||||
if (generation !== this.providersGeneration || client !== this.client) {
|
||||
if (!this.providersQueued) return
|
||||
generation = this.providersGeneration
|
||||
continue
|
||||
}
|
||||
const settings = vscode.workspace.getConfiguration("kilo-code.new.model")
|
||||
const message = {
|
||||
type: "providersLoaded",
|
||||
providers: indexProvidersById(response.all),
|
||||
connected: response.connected,
|
||||
defaults: response.default,
|
||||
defaultSelection: computeDefaultSelection(
|
||||
this.cachedConfigMessage as { config?: { model?: string } } | null,
|
||||
settings.get<string>("providerID", ""),
|
||||
settings.get<string>("modelID", ""),
|
||||
),
|
||||
authMethods,
|
||||
authStates,
|
||||
}
|
||||
this.cachedProvidersMessage = message
|
||||
this.postMessage(message)
|
||||
} catch (error) {
|
||||
if (generation !== this.providersGeneration) {
|
||||
if (!this.providersQueued) return
|
||||
generation = this.providersGeneration
|
||||
continue
|
||||
}
|
||||
console.error("[Kilo New] KiloProvider: Failed to fetch providers:", error)
|
||||
}
|
||||
if (!this.providersQueued) return
|
||||
generation = this.providersGeneration
|
||||
}
|
||||
this.cachedProvidersMessage = message
|
||||
this.postMessage(message)
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to fetch providers:", error)
|
||||
})()
|
||||
const done = task.finally(() => {
|
||||
if (this.providersRefresh === done) this.providersRefresh = null
|
||||
})
|
||||
this.providersRefresh = done
|
||||
await done
|
||||
}
|
||||
|
||||
private async handleProviderAction(msg: Record<string, unknown>): Promise<void> {
|
||||
const rid = typeof msg.requestId === "string" ? msg.requestId : ""
|
||||
const pid = typeof msg.providerID === "string" ? msg.providerID : ""
|
||||
if (!rid || !pid) return
|
||||
if (!this.client) {
|
||||
const action =
|
||||
msg.type === "disconnectProvider"
|
||||
? "disconnect"
|
||||
: msg.type === "authorizeProviderOAuth"
|
||||
? "authorize"
|
||||
: "connect"
|
||||
this.postMessage({
|
||||
type: "providerActionError",
|
||||
requestId: rid,
|
||||
providerID: pid,
|
||||
action,
|
||||
message: "Not connected to CLI backend",
|
||||
})
|
||||
return
|
||||
}
|
||||
const ctx = buildActionContext(
|
||||
this.client,
|
||||
(m) => this.postMessage(m),
|
||||
getErrorMessage,
|
||||
this.getWorkspaceDirectory(),
|
||||
() => this.fetchAndSendProviders(),
|
||||
)
|
||||
const set = (m: unknown) => {
|
||||
this.cachedConfigMessage = m
|
||||
}
|
||||
const method = typeof msg.method === "number" ? msg.method : 0
|
||||
const key = typeof msg.apiKey === "string" ? msg.apiKey : undefined
|
||||
const code = typeof msg.code === "string" ? msg.code : undefined
|
||||
const config = msg.config && typeof msg.config === "object" ? (msg.config as Record<string, unknown>) : undefined
|
||||
if (msg.type === "connectProvider" && key) return connectProviderAction(ctx, rid, pid, key)
|
||||
if (msg.type === "authorizeProviderOAuth") return authorizeOAuthAction(ctx, rid, pid, method)
|
||||
if (msg.type === "completeProviderOAuth") return completeOAuthAction(ctx, rid, pid, method, code)
|
||||
if (msg.type === "disconnectProvider") return disconnectProviderAction(ctx, rid, pid, this.cachedConfigMessage, set)
|
||||
if (msg.type === "saveCustomProvider" && config)
|
||||
return saveCustomProviderAction(ctx, rid, pid, config, key, this.cachedConfigMessage, set)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1884,6 +1976,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
return
|
||||
}
|
||||
|
||||
const refreshProviders =
|
||||
partial.provider !== undefined ||
|
||||
partial.disabled_providers !== undefined ||
|
||||
partial.enabled_providers !== undefined
|
||||
|
||||
// Belt-and-suspenders guard: prevent fetchAndSendConfig from sending a
|
||||
// stale configLoaded while this write is in flight (the SSE-triggered reload
|
||||
// races with the async config.update() write on the CLI backend).
|
||||
@@ -1900,12 +1997,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
this.cachedConfigMessage = { type: "configLoaded", config: merged }
|
||||
this.postMessage({ type: "configUpdated", config: merged })
|
||||
|
||||
if (refreshProviders) {
|
||||
await this.fetchAndSendProviders()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to update config:", error)
|
||||
this.postMessage({
|
||||
type: "error",
|
||||
message: getErrorMessage(error) || "Failed to update config",
|
||||
})
|
||||
// Send configUpdated with the last known good config so the webview
|
||||
// clears its saving flag and reverts optimistic state.
|
||||
if (this.cachedConfigMessage) {
|
||||
this.postMessage({ type: "configUpdated", config: (this.cachedConfigMessage as { config: unknown }).config })
|
||||
}
|
||||
} finally {
|
||||
this.pending--
|
||||
}
|
||||
@@ -2340,11 +2446,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
const { data: profileData } = await this.client.kilo.profile(undefined, { throwOnError: true })
|
||||
this.postMessage({ type: "profileData", data: profileData })
|
||||
this.postMessage({ type: "deviceAuthComplete" })
|
||||
|
||||
// Step 5: If user has organizations, navigate to profile view so they can pick one
|
||||
if (profileData?.profile?.organizations && profileData.profile.organizations.length > 0) {
|
||||
this.postMessage({ type: "navigate", view: "profile" })
|
||||
}
|
||||
} catch (error) {
|
||||
if (attempt !== this.loginAttempt) {
|
||||
return
|
||||
@@ -2481,6 +2582,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
await this.client.global
|
||||
.dispose()
|
||||
.catch((e: unknown) => console.warn("[Kilo New] KiloProvider: global.dispose() after logout failed:", e))
|
||||
|
||||
await this.fetchAndSendProviders()
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: ❌ Logout failed:", error)
|
||||
this.postMessage({
|
||||
@@ -2566,20 +2669,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sessionID from an SSE event, if applicable.
|
||||
* Returns undefined for global events (server.connected, server.heartbeat).
|
||||
*/
|
||||
private extractSessionID(event: Event): string | undefined {
|
||||
return this.connectionService.resolveEventSessionId(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch all server-side state after an auth change (login/logout/org switch).
|
||||
* After instance.dispose() clears the server cache, the next request to each
|
||||
* endpoint will re-initialize with the current auth state.
|
||||
* This mirrors the TUI's sync.bootstrap() pattern.
|
||||
*/
|
||||
/** Re-fetch all server-side state after an auth change. */
|
||||
private async reloadAfterAuthChange(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.fetchAndSendProviders(),
|
||||
@@ -2615,7 +2705,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
|
||||
// Extract sessionID from the event
|
||||
const sessionID = this.extractSessionID(event)
|
||||
const sessionID = this.connectionService.resolveEventSessionId(event)
|
||||
|
||||
// Events without sessionID (server.connected, server.heartbeat) → always forward
|
||||
// Events with sessionID → only forward if this webview tracks that session
|
||||
@@ -2628,7 +2718,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
|
||||
// Refresh provider and agent lists when the server signals a state disposal
|
||||
if (event.type === "server.instance.disposed" || event.type === "global.disposed") {
|
||||
if (event.type === "global.disposed") {
|
||||
void this.reloadAfterAuthChange()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "server.instance.disposed") {
|
||||
const props = event.properties as Record<string, unknown> | null
|
||||
const dir = typeof props?.directory === "string" ? props.directory : undefined
|
||||
if (dir && path.resolve(dir) !== path.resolve(this.getWorkspaceDirectory())) return
|
||||
void this.reloadAfterAuthChange()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -21,12 +21,25 @@ export function getErrorMessage(error: unknown): string {
|
||||
const obj = error as Record<string, unknown>
|
||||
// Direct .message field
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
// Direct .error field
|
||||
// Direct .error field (string)
|
||||
if (typeof obj.error === "string") return obj.error
|
||||
// SDK throwOnError shape: { error: { message: "..." } } or { error: { ... } }
|
||||
if (obj.error && typeof obj.error === "object") {
|
||||
const nested = obj.error as Record<string, unknown>
|
||||
if (typeof nested.message === "string") return nested.message
|
||||
}
|
||||
// NotFoundError shape: { data: { message: "..." } }
|
||||
if (obj.data && typeof obj.data === "object") {
|
||||
const data = obj.data as Record<string, unknown>
|
||||
if (typeof data.message === "string") return data.message
|
||||
// Hono validator shape: { data: ..., error: [...], success: false }
|
||||
if (Array.isArray(data.error) && data.error.length > 0) {
|
||||
const first = data.error[0]
|
||||
if (typeof first === "string") return first
|
||||
if (first && typeof first === "object" && typeof (first as Record<string, unknown>).message === "string") {
|
||||
return (first as Record<string, unknown>).message as string
|
||||
}
|
||||
}
|
||||
}
|
||||
// BadRequestError shape: { errors: [{ message: "..." }] }
|
||||
if (Array.isArray(obj.errors) && obj.errors.length > 0) {
|
||||
@@ -34,6 +47,13 @@ export function getErrorMessage(error: unknown): string {
|
||||
if (typeof first === "string") return first
|
||||
if (first && typeof first.message === "string") return first.message
|
||||
}
|
||||
// Last resort: try JSON.stringify for debuggability
|
||||
try {
|
||||
const json = JSON.stringify(error)
|
||||
if (json !== "{}" && json.length < 500) return json
|
||||
} catch (err) {
|
||||
console.warn("[Kilo New] getErrorMessage: JSON.stringify failed", err)
|
||||
}
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Provider action handlers extracted from KiloProvider to stay under max-lines.
|
||||
* These are pure async functions that operate on the SDK client — no vscode dependency.
|
||||
*/
|
||||
import type { KiloClient } from "@kilocode/sdk/v2"
|
||||
import { validateProviderID as validateProviderIDShared } from "./shared/custom-provider"
|
||||
import { sanitizeCustomProviderConfig } from "./shared/custom-provider"
|
||||
import { KILO_AUTO, parseModelString } from "./shared/provider-model"
|
||||
|
||||
/**
|
||||
* Compute the default model selection from CLI config, VS Code settings, or hardcoded fallback.
|
||||
* Pure function — takes cachedConfig and vscode settings as parameters.
|
||||
*/
|
||||
type AuthState = "api" | "oauth" | "wellknown"
|
||||
|
||||
/** Fetch auth methods alongside the provider list. Auth states default to empty (endpoint not yet available). */
|
||||
export async function fetchProviderData(client: KiloClient, dir: string) {
|
||||
const authRequest =
|
||||
typeof client.provider.auth === "function"
|
||||
? client.provider
|
||||
.auth({ directory: dir }, { throwOnError: true })
|
||||
.then((r) => r.data ?? {})
|
||||
.catch(() => ({}))
|
||||
: Promise.resolve({})
|
||||
|
||||
const [{ data: response }, authMethods] = await Promise.all([
|
||||
client.provider.list({ directory: dir }, { throwOnError: true }),
|
||||
authRequest,
|
||||
])
|
||||
const authStates: Record<string, AuthState> = {}
|
||||
return { response, authMethods, authStates }
|
||||
}
|
||||
|
||||
export function buildActionContext(
|
||||
client: KiloClient,
|
||||
post: (msg: unknown) => void,
|
||||
errFn: (err: unknown) => string,
|
||||
dir: string,
|
||||
refresh: () => Promise<void>,
|
||||
): ActionContext {
|
||||
return {
|
||||
client,
|
||||
postMessage: post,
|
||||
getErrorMessage: errFn,
|
||||
workspaceDir: dir,
|
||||
disposeGlobal: async (reason: string) => {
|
||||
await client.global.dispose().catch((error: unknown) => {
|
||||
console.warn(`[Kilo New] KiloProvider: global.dispose() after ${reason} failed:`, error)
|
||||
})
|
||||
},
|
||||
fetchAndSendProviders: refresh,
|
||||
}
|
||||
}
|
||||
|
||||
function isModelSelection(r: unknown): r is { providerID: string; modelID: string } {
|
||||
return (
|
||||
!!r &&
|
||||
typeof r === "object" &&
|
||||
typeof (r as Record<string, unknown>).providerID === "string" &&
|
||||
typeof (r as Record<string, unknown>).modelID === "string"
|
||||
)
|
||||
}
|
||||
|
||||
/** Validate and sanitize recent model selections from untrusted sources. */
|
||||
export function validateRecents(raw: unknown): Array<{ providerID: string; modelID: string }> {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.filter(isModelSelection)
|
||||
.slice(0, 5)
|
||||
.map((r) => ({ providerID: r.providerID, modelID: r.modelID }))
|
||||
}
|
||||
|
||||
export function computeDefaultSelection(
|
||||
cachedConfig: { config?: { model?: string } } | null,
|
||||
vscodePID: string,
|
||||
vscodeMID: string,
|
||||
): { providerID: string; modelID: string } {
|
||||
const configured = parseModelString(cachedConfig?.config?.model)
|
||||
if (configured) return configured
|
||||
if (vscodePID && vscodeMID) return { providerID: vscodePID, modelID: vscodeMID }
|
||||
return { ...KILO_AUTO }
|
||||
}
|
||||
|
||||
type PostMessage = (message: unknown) => void
|
||||
type GetErrorMessage = (error: unknown) => string
|
||||
|
||||
interface ActionContext {
|
||||
client: KiloClient
|
||||
postMessage: PostMessage
|
||||
getErrorMessage: GetErrorMessage
|
||||
workspaceDir: string
|
||||
disposeGlobal: (reason: string) => Promise<void>
|
||||
fetchAndSendProviders: () => Promise<void>
|
||||
}
|
||||
|
||||
function postError(
|
||||
ctx: ActionContext,
|
||||
requestId: string,
|
||||
providerID: string,
|
||||
action: "connect" | "disconnect" | "authorize",
|
||||
message: string,
|
||||
) {
|
||||
ctx.postMessage({ type: "providerActionError", requestId, providerID, action, message })
|
||||
}
|
||||
|
||||
function validateID(
|
||||
ctx: ActionContext,
|
||||
requestId: string,
|
||||
providerID: string,
|
||||
action: "connect" | "disconnect" | "authorize",
|
||||
): string | null {
|
||||
const result = validateProviderIDShared(providerID)
|
||||
if ("value" in result) return result.value
|
||||
postError(ctx, requestId, providerID, action, result.error)
|
||||
return null
|
||||
}
|
||||
|
||||
export async function connectProvider(ctx: ActionContext, requestId: string, providerID: string, apiKey: string) {
|
||||
const id = validateID(ctx, requestId, providerID, "connect")
|
||||
if (!id) return
|
||||
try {
|
||||
await ctx.client.auth.set({ providerID: id, auth: { type: "api", key: apiKey } }, { throwOnError: true })
|
||||
await ctx.disposeGlobal(`provider connect (${id})`)
|
||||
await ctx.fetchAndSendProviders()
|
||||
ctx.postMessage({ type: "providerConnected", requestId, providerID: id })
|
||||
} catch (error) {
|
||||
postError(ctx, requestId, providerID, "connect", ctx.getErrorMessage(error) || "Failed to connect provider")
|
||||
}
|
||||
}
|
||||
|
||||
export async function authorizeProviderOAuth(
|
||||
ctx: ActionContext,
|
||||
requestId: string,
|
||||
providerID: string,
|
||||
method: number,
|
||||
) {
|
||||
const id = validateID(ctx, requestId, providerID, "authorize")
|
||||
if (!id) return
|
||||
try {
|
||||
const { data: authorization } = await ctx.client.provider.oauth.authorize(
|
||||
{ providerID: id, method, directory: ctx.workspaceDir },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
if (!authorization) {
|
||||
postError(ctx, requestId, providerID, "authorize", "Failed to start provider authorization")
|
||||
return
|
||||
}
|
||||
ctx.postMessage({ type: "providerOAuthReady", requestId, providerID: id, authorization })
|
||||
} catch (error) {
|
||||
postError(
|
||||
ctx,
|
||||
requestId,
|
||||
providerID,
|
||||
"authorize",
|
||||
ctx.getErrorMessage(error) || "Failed to start provider authorization",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeProviderOAuth(
|
||||
ctx: ActionContext,
|
||||
requestId: string,
|
||||
providerID: string,
|
||||
method: number,
|
||||
code?: string,
|
||||
) {
|
||||
const id = validateID(ctx, requestId, providerID, "connect")
|
||||
if (!id) return
|
||||
try {
|
||||
await ctx.client.provider.oauth.callback(
|
||||
{ providerID: id, method, code, directory: ctx.workspaceDir },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
await ctx.disposeGlobal(`provider oauth (${id})`)
|
||||
await ctx.fetchAndSendProviders()
|
||||
ctx.postMessage({ type: "providerConnected", requestId, providerID: id })
|
||||
} catch (error) {
|
||||
postError(
|
||||
ctx,
|
||||
requestId,
|
||||
providerID,
|
||||
"connect",
|
||||
ctx.getErrorMessage(error) || "Failed to complete provider authorization",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectProvider(
|
||||
ctx: ActionContext,
|
||||
requestId: string,
|
||||
providerID: string,
|
||||
cachedConfigMessage: unknown,
|
||||
setCachedConfig: (msg: unknown) => void,
|
||||
) {
|
||||
const id = validateID(ctx, requestId, providerID, "disconnect")
|
||||
if (!id) return
|
||||
try {
|
||||
const globalConfig = (await ctx.client.global.config.get({ throwOnError: true })).data ?? {}
|
||||
const configured = !!globalConfig.provider?.[id]
|
||||
|
||||
// Remove auth store entry. Config-sourced providers may not have an auth
|
||||
// store entry (credentials come from config or env), so failure is non-fatal.
|
||||
// For auth-only providers, failure means disconnect failed.
|
||||
try {
|
||||
await ctx.client.auth.remove({ providerID: id }, { throwOnError: true })
|
||||
} catch (err) {
|
||||
if (!configured) throw err
|
||||
console.warn(`[Kilo New] auth.remove failed for configured provider ${id} (non-fatal):`, err)
|
||||
}
|
||||
|
||||
if (id === "kilo") {
|
||||
ctx.postMessage({ type: "profileData", data: null })
|
||||
}
|
||||
|
||||
// Config-sourced providers stay "connected" after auth.remove because the
|
||||
// server rebuilds state from config. Add to disabled_providers so the server
|
||||
// excludes them. The config entry is preserved (user may re-enable later).
|
||||
// This matches the desktop app's disableProvider() pattern.
|
||||
if (configured) {
|
||||
const disabled = globalConfig.disabled_providers ?? []
|
||||
if (!disabled.includes(id)) {
|
||||
const merged = (
|
||||
await ctx.client.global.config.update(
|
||||
{ config: { disabled_providers: [...disabled, id] } },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
).data
|
||||
if (merged) {
|
||||
setCachedConfig({ type: "configLoaded", config: merged })
|
||||
ctx.postMessage({ type: "configUpdated", config: merged })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.disposeGlobal(`provider disconnect (${id})`)
|
||||
await ctx.fetchAndSendProviders()
|
||||
ctx.postMessage({ type: "providerDisconnected", requestId, providerID: id })
|
||||
} catch (error) {
|
||||
postError(ctx, requestId, providerID, "disconnect", ctx.getErrorMessage(error) || "Failed to disconnect provider")
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCustomProvider(
|
||||
ctx: ActionContext,
|
||||
requestId: string,
|
||||
providerID: string,
|
||||
provider: Record<string, unknown>,
|
||||
apiKey: string | undefined,
|
||||
cachedConfigMessage: unknown,
|
||||
setCachedConfig: (msg: unknown) => void,
|
||||
) {
|
||||
const id = validateID(ctx, requestId, providerID, "connect")
|
||||
if (!id) return
|
||||
|
||||
const sanitized = sanitizeCustomProviderConfig(provider)
|
||||
if ("error" in sanitized) {
|
||||
postError(ctx, requestId, providerID, "connect", sanitized.error)
|
||||
return
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
await ctx.disposeGlobal(`custom provider save (${id})`)
|
||||
await ctx.fetchAndSendProviders()
|
||||
}
|
||||
|
||||
try {
|
||||
const globalConfig = (await ctx.client.global.config.get({ throwOnError: true })).data ?? {}
|
||||
const disabled = globalConfig.disabled_providers ?? []
|
||||
const nextDisabled = disabled.filter((item: string) => item !== id)
|
||||
const { data: updated } = await ctx.client.global.config.update(
|
||||
{
|
||||
config: {
|
||||
provider: { [id]: sanitized.value },
|
||||
disabled_providers: nextDisabled,
|
||||
},
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
|
||||
const msg = { type: "configLoaded", config: updated }
|
||||
setCachedConfig(msg)
|
||||
ctx.postMessage({ type: "configUpdated", config: updated })
|
||||
|
||||
try {
|
||||
if (apiKey) {
|
||||
await ctx.client.auth.set({ providerID: id, auth: { type: "api", key: apiKey } }, { throwOnError: true })
|
||||
} else {
|
||||
await ctx.client.auth.remove({ providerID: id }, { throwOnError: true })
|
||||
}
|
||||
} catch (error) {
|
||||
await refresh()
|
||||
postError(ctx, requestId, providerID, "connect", ctx.getErrorMessage(error) || "Failed to save custom provider")
|
||||
return
|
||||
}
|
||||
|
||||
await refresh()
|
||||
ctx.postMessage({ type: "providerConnected", requestId, providerID: id })
|
||||
} catch (error) {
|
||||
postError(ctx, requestId, providerID, "connect", ctx.getErrorMessage(error) || "Failed to save custom provider")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { z } from "zod"
|
||||
import { CUSTOM_PROVIDER_PACKAGE, PROVIDER_ID_PATTERN } from "./provider-model"
|
||||
|
||||
const INVALID_PROVIDER_ID = "Invalid provider ID"
|
||||
const INVALID_ENV = "Invalid environment variable name"
|
||||
const INVALID_BASE_URL = "Base URL must start with http:// or https://"
|
||||
|
||||
export const ProviderIDSchema = z.string().trim().regex(PROVIDER_ID_PATTERN, INVALID_PROVIDER_ID)
|
||||
export const EnvSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[A-Z_][A-Z0-9_]*$/, INVALID_ENV)
|
||||
export const CustomProviderConfigSchema = z
|
||||
.object({
|
||||
npm: z.string().optional(),
|
||||
name: z.string().trim().min(1).max(200),
|
||||
env: z.array(EnvSchema).max(1).optional(),
|
||||
options: z
|
||||
.object({
|
||||
baseURL: z
|
||||
.string()
|
||||
.trim()
|
||||
.url()
|
||||
.refine((value) => value.startsWith("http://") || value.startsWith("https://"), {
|
||||
message: INVALID_BASE_URL,
|
||||
}),
|
||||
headers: z.record(z.string().trim().min(1), z.string().trim().min(1)).optional(),
|
||||
})
|
||||
.strict(),
|
||||
models: z
|
||||
.record(
|
||||
z.string().trim().min(1),
|
||||
z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.refine((value) => Object.keys(value).length > 0, "At least one model is required"),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type SanitizedProviderConfig = {
|
||||
npm: typeof CUSTOM_PROVIDER_PACKAGE
|
||||
name: string
|
||||
env?: string[]
|
||||
options: {
|
||||
baseURL: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
models: Record<string, { name: string }>
|
||||
}
|
||||
|
||||
type Issue = { error: string; issue?: z.ZodIssue }
|
||||
|
||||
function fail(error: string, issue?: z.ZodIssue): Issue {
|
||||
return issue ? { error, issue } : { error }
|
||||
}
|
||||
|
||||
export function validateProviderID(providerID: string): { value: string } | Issue {
|
||||
const result = ProviderIDSchema.safeParse(providerID)
|
||||
if (result.success) return { value: result.data }
|
||||
const issue = result.error.issues[0]
|
||||
return fail(issue?.message ?? INVALID_PROVIDER_ID, issue)
|
||||
}
|
||||
|
||||
export function parseCustomProviderSecret(raw: string): { value: { apiKey?: string; env?: string } } | Issue {
|
||||
const value = raw.trim()
|
||||
if (!value) return { value: {} }
|
||||
|
||||
const match = value.match(/^\{env:([^}]+)\}$/)
|
||||
if (!match) return { value: { apiKey: value } }
|
||||
|
||||
const env = match[1]?.trim() ?? ""
|
||||
const result = EnvSchema.safeParse(env)
|
||||
if (result.success) return { value: { env: result.data } }
|
||||
const issue = result.error.issues[0]
|
||||
return fail(issue?.message ?? INVALID_ENV, issue)
|
||||
}
|
||||
|
||||
export function normalizeCustomProviderConfig(
|
||||
config: z.output<typeof CustomProviderConfigSchema>,
|
||||
): SanitizedProviderConfig {
|
||||
const headers = config.options.headers
|
||||
? Object.fromEntries(
|
||||
Object.entries(config.options.headers)
|
||||
.map(([key, value]) => [key.trim(), value.trim()] as const)
|
||||
.filter(([key, value]) => key.length > 0 && value.length > 0),
|
||||
)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
npm: CUSTOM_PROVIDER_PACKAGE,
|
||||
name: config.name.trim(),
|
||||
...(config.env ? { env: config.env.map((item) => item.trim()) } : {}),
|
||||
options: {
|
||||
baseURL: config.options.baseURL.trim(),
|
||||
...(headers && Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
},
|
||||
models: Object.fromEntries(
|
||||
Object.entries(config.models).map(([id, model]) => [id.trim(), { name: model.name.trim() }]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeCustomProviderConfig(provider: unknown): { value: SanitizedProviderConfig } | Issue {
|
||||
const result = CustomProviderConfigSchema.safeParse(provider)
|
||||
if (!result.success) {
|
||||
const issue = result.error.issues[0]
|
||||
return fail(issue?.message ?? "Invalid custom provider config", issue)
|
||||
}
|
||||
|
||||
return { value: normalizeCustomProviderConfig(result.data) }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const KILO_PROVIDER_ID = "kilo"
|
||||
export const KILO_AUTO = { providerID: KILO_PROVIDER_ID, modelID: "kilo-auto/free" } as const
|
||||
export const CUSTOM_PROVIDER_PACKAGE = "@ai-sdk/openai-compatible"
|
||||
export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/
|
||||
|
||||
export const PROVIDER_PRIORITY = [
|
||||
KILO_PROVIDER_ID,
|
||||
"anthropic",
|
||||
"github-copilot",
|
||||
"openai",
|
||||
"google",
|
||||
"openrouter",
|
||||
"vercel",
|
||||
] as const
|
||||
|
||||
export function parseModelString(raw: string | undefined | null) {
|
||||
if (!raw) return null
|
||||
const slash = raw.indexOf("/")
|
||||
if (slash <= 0 || slash >= raw.length - 1) return null
|
||||
return { providerID: raw.slice(0, slash), modelID: raw.slice(slash + 1) }
|
||||
}
|
||||
|
||||
export function providerOrderIndex(providerID: string, order = PROVIDER_PRIORITY) {
|
||||
const index = order.indexOf(providerID.toLowerCase() as (typeof PROVIDER_PRIORITY)[number])
|
||||
return index >= 0 ? index : order.length
|
||||
}
|
||||
|
||||
export function createKiloFallbackProvider() {
|
||||
return {
|
||||
id: KILO_PROVIDER_ID,
|
||||
name: "Kilo Gateway",
|
||||
source: "custom" as const,
|
||||
env: ["KILO_API_KEY"],
|
||||
models: {},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
parseCustomProviderSecret,
|
||||
sanitizeCustomProviderConfig,
|
||||
validateProviderID,
|
||||
} from "../../src/shared/custom-provider"
|
||||
|
||||
describe("validateProviderID", () => {
|
||||
it("accepts valid provider ids", () => {
|
||||
expect(validateProviderID(" my-provider_1 ")).toEqual({ value: "my-provider_1" })
|
||||
})
|
||||
|
||||
it("rejects invalid provider ids", () => {
|
||||
const result = validateProviderID("bad/id")
|
||||
expect("error" in result ? result.error : "").toBe("Invalid provider ID")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseCustomProviderSecret", () => {
|
||||
it("treats plain values as api keys", () => {
|
||||
expect(parseCustomProviderSecret(" sk-test ")).toEqual({ value: { apiKey: "sk-test" } })
|
||||
})
|
||||
|
||||
it("parses env references", () => {
|
||||
expect(parseCustomProviderSecret(" {env:MY_PROVIDER_KEY} ")).toEqual({ value: { env: "MY_PROVIDER_KEY" } })
|
||||
})
|
||||
|
||||
it("rejects invalid env references", () => {
|
||||
const result = parseCustomProviderSecret("{env:bad-name}")
|
||||
expect("error" in result ? result.error : "").toBe("Invalid environment variable name")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sanitizeCustomProviderConfig", () => {
|
||||
it("normalizes config and forces the approved package", () => {
|
||||
const result = sanitizeCustomProviderConfig({
|
||||
npm: "malicious-package",
|
||||
name: " My Provider ",
|
||||
env: [" MY_PROVIDER_KEY "],
|
||||
options: {
|
||||
baseURL: "https://example.com/v1 ",
|
||||
headers: {
|
||||
Authorization: " Bearer test ",
|
||||
" X-Test ": " 123 ",
|
||||
},
|
||||
},
|
||||
models: {
|
||||
" model-1 ": { name: " Model One " },
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
value: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "My Provider",
|
||||
env: ["MY_PROVIDER_KEY"],
|
||||
options: {
|
||||
baseURL: "https://example.com/v1",
|
||||
headers: {
|
||||
Authorization: "Bearer test",
|
||||
"X-Test": "123",
|
||||
},
|
||||
},
|
||||
models: {
|
||||
"model-1": { name: "Model One" },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects unknown fields", () => {
|
||||
const result = sanitizeCustomProviderConfig({
|
||||
name: "Bad Provider",
|
||||
options: {
|
||||
baseURL: "https://example.com/v1",
|
||||
mcpServer: "https://malicious.example",
|
||||
},
|
||||
models: { "model-1": { name: "Model One" } },
|
||||
})
|
||||
|
||||
expect("error" in result ? result.error : "").toContain("mcpServer")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { resolveModelSelection } from "../../webview-ui/src/context/model-selection"
|
||||
import { KILO_AUTO, parseModelString } from "../../src/shared/provider-model"
|
||||
import type { Provider } from "../../webview-ui/src/types/messages"
|
||||
|
||||
function makeProvider(id: string, name: string, modelIds: string[]): Provider {
|
||||
const models: Provider["models"] = {}
|
||||
for (const modelID of modelIds) {
|
||||
models[modelID] = { id: modelID, name: modelID }
|
||||
}
|
||||
return { id, name, models }
|
||||
}
|
||||
|
||||
const providers = {
|
||||
kilo: makeProvider("kilo", "Kilo Gateway", ["kilo-auto/free"]),
|
||||
anthropic: makeProvider("anthropic", "Anthropic", ["claude-sonnet-4"]),
|
||||
openai: makeProvider("openai", "OpenAI", ["gpt-4.1"]),
|
||||
}
|
||||
|
||||
describe("parseModelString", () => {
|
||||
it("parses provider/model pairs", () => {
|
||||
expect(parseModelString("anthropic/claude-sonnet-4")).toEqual({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4",
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps slashes inside kilo model ids", () => {
|
||||
expect(parseModelString("kilo/kilo-auto/free")).toEqual({
|
||||
providerID: "kilo",
|
||||
modelID: "kilo-auto/free",
|
||||
})
|
||||
})
|
||||
|
||||
it("returns null for invalid values", () => {
|
||||
expect(parseModelString(undefined)).toBeNull()
|
||||
expect(parseModelString("claude-sonnet-4")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveModelSelection", () => {
|
||||
it("prefers a valid override", () => {
|
||||
const result = resolveModelSelection({
|
||||
providers,
|
||||
connected: ["anthropic", "openai"],
|
||||
override: { providerID: "openai", modelID: "gpt-4.1" },
|
||||
mode: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
fallback: KILO_AUTO,
|
||||
})
|
||||
expect(result).toEqual({ providerID: "openai", modelID: "gpt-4.1" })
|
||||
})
|
||||
|
||||
it("falls back from an invalid override to the mode model", () => {
|
||||
const result = resolveModelSelection({
|
||||
providers,
|
||||
connected: ["anthropic"],
|
||||
override: { providerID: "openai", modelID: "gpt-4.1" },
|
||||
mode: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
fallback: KILO_AUTO,
|
||||
})
|
||||
expect(result).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4" })
|
||||
})
|
||||
|
||||
it("falls back from invalid config to the first valid recent model", () => {
|
||||
const result = resolveModelSelection({
|
||||
providers,
|
||||
connected: ["openai"],
|
||||
mode: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
recent: [
|
||||
{ providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
{ providerID: "openai", modelID: "gpt-4.1" },
|
||||
],
|
||||
fallback: KILO_AUTO,
|
||||
})
|
||||
expect(result).toEqual({ providerID: "openai", modelID: "gpt-4.1" })
|
||||
})
|
||||
|
||||
it("uses kilo auto as the explicit final fallback", () => {
|
||||
const result = resolveModelSelection({
|
||||
providers,
|
||||
connected: [],
|
||||
fallback: KILO_AUTO,
|
||||
})
|
||||
expect(result).toEqual(KILO_AUTO)
|
||||
})
|
||||
|
||||
it("keeps the explicit fallback even when kilo is missing from the loaded catalog", () => {
|
||||
const result = resolveModelSelection({
|
||||
providers: { openai: providers.openai },
|
||||
connected: [],
|
||||
fallback: KILO_AUTO,
|
||||
})
|
||||
expect(result).toEqual(KILO_AUTO)
|
||||
})
|
||||
|
||||
it("keeps the raw preference order before providers load", () => {
|
||||
const result = resolveModelSelection({
|
||||
providers: {},
|
||||
connected: [],
|
||||
override: { providerID: "openai", modelID: "gpt-4.1" },
|
||||
mode: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
fallback: KILO_AUTO,
|
||||
})
|
||||
expect(result).toEqual({ providerID: "openai", modelID: "gpt-4.1" })
|
||||
})
|
||||
})
|
||||
@@ -16,8 +16,8 @@ describe("providerSortKey", () => {
|
||||
|
||||
it("returns correct index for known providers", () => {
|
||||
expect(providerSortKey("anthropic")).toBe(1)
|
||||
expect(providerSortKey("openai")).toBe(2)
|
||||
expect(providerSortKey("google")).toBe(3)
|
||||
expect(providerSortKey("openai")).toBe(3)
|
||||
expect(providerSortKey("google")).toBe(4)
|
||||
})
|
||||
|
||||
it("returns order length for unknown provider", () => {
|
||||
@@ -37,9 +37,9 @@ describe("providerSortKey", () => {
|
||||
})
|
||||
|
||||
it("sorts providers correctly when used with sort", () => {
|
||||
const ids = ["google", "anthropic", "kilo", "openai"]
|
||||
const ids = ["google", "anthropic", "kilo", "openai", "github-copilot"]
|
||||
const sorted = ids.slice().sort((a, b) => providerSortKey(a) - providerSortKey(b))
|
||||
expect(sorted).toEqual(["kilo", "anthropic", "openai", "google"])
|
||||
expect(sorted).toEqual(["kilo", "anthropic", "github-copilot", "openai", "google"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,63 +62,69 @@ describe("stripSubProviderPrefix", () => {
|
||||
|
||||
describe("buildTriggerLabel", () => {
|
||||
it("returns resolved model name for non-kilo provider unchanged", () => {
|
||||
expect(buildTriggerLabel("GPT-4o", "openai", null, false, "", true, labels)).toBe("GPT-4o")
|
||||
expect(buildTriggerLabel("GPT-4o", "openai", undefined, null, false, "", true, labels)).toBe("GPT-4o")
|
||||
})
|
||||
|
||||
it("strips sub-provider prefix from resolved name for kilo gateway models", () => {
|
||||
expect(buildTriggerLabel("Anthropic: Claude Sonnet", KILO_GATEWAY_ID, null, false, "", true, labels)).toBe(
|
||||
"Claude Sonnet",
|
||||
)
|
||||
expect(
|
||||
buildTriggerLabel("Anthropic: Claude Sonnet", KILO_GATEWAY_ID, undefined, null, false, "", true, labels),
|
||||
).toBe("Claude Sonnet")
|
||||
})
|
||||
|
||||
it("does not strip prefix for non-kilo provider even if name contains ': '", () => {
|
||||
expect(buildTriggerLabel("Anthropic: Claude Sonnet", "anthropic", null, false, "", true, labels)).toBe(
|
||||
expect(buildTriggerLabel("Anthropic: Claude Sonnet", "anthropic", undefined, null, false, "", true, labels)).toBe(
|
||||
"Anthropic: Claude Sonnet",
|
||||
)
|
||||
})
|
||||
|
||||
it("returns resolved name as-is when providerID is undefined", () => {
|
||||
expect(buildTriggerLabel("GPT-4o", undefined, null, false, "", true, labels)).toBe("GPT-4o")
|
||||
expect(buildTriggerLabel("GPT-4o", undefined, undefined, null, false, "", true, labels)).toBe("GPT-4o")
|
||||
})
|
||||
|
||||
it("returns providerName / resolvedName for non-kilo provider with providerName", () => {
|
||||
expect(buildTriggerLabel("GPT-4o", "openai", "OpenAI", null, false, "", true, labels)).toBe("OpenAI / GPT-4o")
|
||||
})
|
||||
|
||||
it("returns modelID for kilo gateway raw selection", () => {
|
||||
const raw = { providerID: "kilo", modelID: "kilo-auto/frontier" }
|
||||
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("kilo-auto/frontier")
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe("kilo-auto/frontier")
|
||||
})
|
||||
|
||||
it("returns providerID / modelID for non-kilo raw selection", () => {
|
||||
const raw = { providerID: "anthropic", modelID: "claude-3-5-sonnet" }
|
||||
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("anthropic / claude-3-5-sonnet")
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe(
|
||||
"anthropic / claude-3-5-sonnet",
|
||||
)
|
||||
})
|
||||
|
||||
it("returns clearLabel when allowClear and no selection", () => {
|
||||
expect(buildTriggerLabel(undefined, undefined, null, true, "None", true, labels)).toBe("None")
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, null, true, "None", true, labels)).toBe("None")
|
||||
})
|
||||
|
||||
it("falls back to labels.notSet when allowClear and clearLabel is empty", () => {
|
||||
expect(buildTriggerLabel(undefined, undefined, null, true, "", true, labels)).toBe("Not set")
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, null, true, "", true, labels)).toBe("Not set")
|
||||
})
|
||||
|
||||
it("returns labels.select when providers exist and no selection", () => {
|
||||
expect(buildTriggerLabel(undefined, undefined, null, false, "", true, labels)).toBe("Select model")
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, null, false, "", true, labels)).toBe("Select model")
|
||||
})
|
||||
|
||||
it("returns labels.noProviders when no providers available", () => {
|
||||
expect(buildTriggerLabel(undefined, undefined, null, false, "", false, labels)).toBe("No providers")
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, null, false, "", false, labels)).toBe("No providers")
|
||||
})
|
||||
|
||||
it("prefers resolvedName over raw selection", () => {
|
||||
const raw = { providerID: "anthropic", modelID: "claude-3-5-sonnet" }
|
||||
expect(buildTriggerLabel("Claude Sonnet", undefined, raw, false, "", true, labels)).toBe("Claude Sonnet")
|
||||
expect(buildTriggerLabel("Claude Sonnet", undefined, undefined, raw, false, "", true, labels)).toBe("Claude Sonnet")
|
||||
})
|
||||
|
||||
it("ignores partial raw selection (only providerID)", () => {
|
||||
const raw = { providerID: "anthropic", modelID: "" }
|
||||
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
|
||||
})
|
||||
|
||||
it("ignores partial raw selection (only modelID)", () => {
|
||||
const raw = { providerID: "", modelID: "claude-3-5-sonnet" }
|
||||
expect(buildTriggerLabel(undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
|
||||
expect(buildTriggerLabel(undefined, undefined, undefined, raw, false, "", true, labels)).toBe("Select model")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createProviderAction } from "../../webview-ui/src/utils/provider-action"
|
||||
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"
|
||||
|
||||
function createTransport() {
|
||||
const sent: WebviewMessage[] = []
|
||||
let handler: ((message: ExtensionMessage) => void) | undefined
|
||||
|
||||
return {
|
||||
sent,
|
||||
receive(message: ExtensionMessage) {
|
||||
handler?.(message)
|
||||
},
|
||||
postMessage(message: WebviewMessage) {
|
||||
sent.push(message)
|
||||
},
|
||||
onMessage(next: (message: ExtensionMessage) => void) {
|
||||
handler = next
|
||||
return () => {
|
||||
if (handler === next) {
|
||||
handler = undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("createProviderAction", () => {
|
||||
it("routes terminal provider messages by request id", () => {
|
||||
const transport = createTransport()
|
||||
const action = createProviderAction(transport)
|
||||
const seen: string[] = []
|
||||
|
||||
action.send(
|
||||
{
|
||||
type: "connectProvider",
|
||||
providerID: "openai",
|
||||
apiKey: "sk-test",
|
||||
},
|
||||
{
|
||||
onConnected: (message) => seen.push(`connected:${message.providerID}`),
|
||||
},
|
||||
)
|
||||
|
||||
const sent = transport.sent[0]
|
||||
expect(sent?.type).toBe("connectProvider")
|
||||
expect("requestId" in (sent ?? {}) ? sent.requestId : "").toBeString()
|
||||
|
||||
const requestId = "requestId" in (sent ?? {}) ? sent.requestId : ""
|
||||
transport.receive({
|
||||
type: "providerConnected",
|
||||
requestId,
|
||||
providerID: "openai",
|
||||
})
|
||||
transport.receive({
|
||||
type: "providerConnected",
|
||||
requestId,
|
||||
providerID: "openai",
|
||||
})
|
||||
|
||||
expect(seen).toEqual(["connected:openai"])
|
||||
action.dispose()
|
||||
})
|
||||
|
||||
it("keeps concurrent requests isolated", () => {
|
||||
const transport = createTransport()
|
||||
const action = createProviderAction(transport)
|
||||
const seen: string[] = []
|
||||
|
||||
action.send(
|
||||
{
|
||||
type: "authorizeProviderOAuth",
|
||||
providerID: "anthropic",
|
||||
method: 0,
|
||||
},
|
||||
{
|
||||
onOAuthReady: (message) => seen.push(`oauth:${message.authorization.method}`),
|
||||
},
|
||||
)
|
||||
action.send(
|
||||
{
|
||||
type: "disconnectProvider",
|
||||
providerID: "openai",
|
||||
},
|
||||
{
|
||||
onDisconnected: (message) => seen.push(`disconnect:${message.providerID}`),
|
||||
},
|
||||
)
|
||||
|
||||
const oauth = transport.sent[0]
|
||||
const disconnect = transport.sent[1]
|
||||
const oauthId = "requestId" in (oauth ?? {}) ? oauth.requestId : ""
|
||||
const disconnectId = "requestId" in (disconnect ?? {}) ? disconnect.requestId : ""
|
||||
|
||||
transport.receive({
|
||||
type: "providerDisconnected",
|
||||
requestId: disconnectId,
|
||||
providerID: "openai",
|
||||
})
|
||||
transport.receive({
|
||||
type: "providerOAuthReady",
|
||||
requestId: oauthId,
|
||||
providerID: "anthropic",
|
||||
authorization: { url: "https://example.com", method: "code", instructions: "Code: 1234" },
|
||||
})
|
||||
|
||||
expect(seen).toEqual(["disconnect:openai", "oauth:code"])
|
||||
action.dispose()
|
||||
})
|
||||
|
||||
it("can drop stale requests", () => {
|
||||
const transport = createTransport()
|
||||
const action = createProviderAction(transport)
|
||||
const seen: string[] = []
|
||||
|
||||
const requestId = action.send(
|
||||
{
|
||||
type: "saveCustomProvider",
|
||||
providerID: "myprovider",
|
||||
config: {
|
||||
name: "My Provider",
|
||||
options: { baseURL: "https://example.com/v1" },
|
||||
models: { "model-1": { name: "Model One" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
onError: (message) => seen.push(message.message),
|
||||
},
|
||||
)
|
||||
|
||||
action.clear(requestId)
|
||||
transport.receive({
|
||||
type: "providerActionError",
|
||||
requestId,
|
||||
providerID: "myprovider",
|
||||
action: "connect",
|
||||
message: "boom",
|
||||
})
|
||||
|
||||
expect(seen).toEqual([])
|
||||
action.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { flattenModels, findModel } from "../../webview-ui/src/context/provider-utils"
|
||||
import { flattenModels, findModel, isModelValid } from "../../webview-ui/src/context/provider-utils"
|
||||
import type { Provider } from "../../webview-ui/src/types/messages"
|
||||
|
||||
function makeProvider(id: string, name: string, modelIds: string[]): Provider {
|
||||
@@ -78,3 +78,26 @@ describe("findModel", () => {
|
||||
expect(findModel([], { providerID: "openai", modelID: "gpt-4" })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("isModelValid", () => {
|
||||
const providers = {
|
||||
kilo: makeProvider("kilo", "Kilo Gateway", ["kilo-auto/free"]),
|
||||
openai: makeProvider("openai", "OpenAI", ["gpt-4o"]),
|
||||
}
|
||||
|
||||
it("accepts a connected provider model", () => {
|
||||
expect(isModelValid(providers, ["openai"], { providerID: "openai", modelID: "gpt-4o" })).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects a disconnected non-kilo provider", () => {
|
||||
expect(isModelValid(providers, [], { providerID: "openai", modelID: "gpt-4o" })).toBe(false)
|
||||
})
|
||||
|
||||
it("accepts kilo models when present in the catalog", () => {
|
||||
expect(isModelValid(providers, [], { providerID: "kilo", modelID: "kilo-auto/free" })).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects unknown models", () => {
|
||||
expect(isModelValid(providers, ["openai"], { providerID: "openai", modelID: "missing" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
|
||||
import { visibleConnectedIds } from "../../webview-ui/src/components/settings/provider-visibility"
|
||||
|
||||
describe("visibleConnectedIds", () => {
|
||||
it("hides Kilo from the connected list when auth is missing", () => {
|
||||
const ids = visibleConnectedIds(["kilo", "openrouter"], { openrouter: "api" })
|
||||
|
||||
expect(ids).toEqual(["openrouter"])
|
||||
})
|
||||
|
||||
it("keeps Kilo in the connected list when auth exists", () => {
|
||||
const ids = visibleConnectedIds(["kilo", "openrouter"], { kilo: "oauth", openrouter: "api" })
|
||||
|
||||
expect(ids).toEqual(["kilo", "openrouter"])
|
||||
})
|
||||
|
||||
it("leaves non-Kilo providers untouched", () => {
|
||||
const ids = visibleConnectedIds(["anthropic"], {})
|
||||
|
||||
expect(ids).toEqual(["anthropic"])
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b7fe365d1a0ffdd713d3146c66112fad2207c2030132ab117543a6a430197aca
|
||||
size 36247
|
||||
oid sha256:19d68a55c4a80d83ba70770744764ab33b6a5291ff02a34106a12d0935203ffa
|
||||
size 20185
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b83a16d340c4af266d4dd0134a840c7c07bed50837a58460d3cebab61ca2bd06
|
||||
size 45545
|
||||
oid sha256:3ad2458e2fe4e735c09fe89292c238f4b3113d2fa7dab5353889f0fc885400a7
|
||||
size 30038
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Card } from "@kilocode/kilo-ui/card"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { showToast } from "@kilocode/kilo-ui/toast"
|
||||
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
|
||||
import { Dialog } from "@kilocode/kilo-ui/dialog"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { generateQRCode } from "../../utils/qrcode"
|
||||
@@ -18,15 +20,41 @@ interface DeviceAuthCardProps {
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
const ERROR_LIMIT = 180
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function compactError(error: string | undefined, fallback: string) {
|
||||
if (!error) return fallback
|
||||
|
||||
const text = error.replace(/\s+/g, " ").trim()
|
||||
const html = text.search(/<!doctype html|<html|<head|<body/i)
|
||||
const head =
|
||||
html >= 0
|
||||
? text
|
||||
.slice(0, html)
|
||||
.trim()
|
||||
.replace(/[\s:,-]+$/, "")
|
||||
: text
|
||||
|
||||
if (head.length > 0) {
|
||||
if (head.length <= ERROR_LIMIT) return head
|
||||
return `${head.slice(0, ERROR_LIMIT).trimEnd()}...`
|
||||
}
|
||||
|
||||
const status = text.match(/\b([45]\d{2})\b/)?.[1]
|
||||
if (status) return `${fallback} (${status})`
|
||||
return fallback
|
||||
}
|
||||
|
||||
const DeviceAuthCard: Component<DeviceAuthCardProps> = (props) => {
|
||||
const vscode = useVSCode()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const [timeRemaining, setTimeRemaining] = createSignal(props.expiresIn ?? 900)
|
||||
const [qrDataUrl, setQrDataUrl] = createSignal("")
|
||||
|
||||
@@ -70,6 +98,56 @@ const DeviceAuthCard: Component<DeviceAuthCardProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const errorSummary = () => compactError(props.error, language.t("deviceAuth.status.failed"))
|
||||
|
||||
const hasErrorDetails = () => {
|
||||
if (!props.error) return false
|
||||
return errorSummary() !== props.error.replace(/\s+/g, " ").trim()
|
||||
}
|
||||
|
||||
const handleCopyError = () => {
|
||||
if (!props.error) return
|
||||
navigator.clipboard.writeText(props.error)
|
||||
showToast({ variant: "success", title: language.t("deviceAuth.toast.errorCopied") })
|
||||
}
|
||||
|
||||
const handleShowError = () => {
|
||||
if (!props.error) return
|
||||
|
||||
dialog.show(() => (
|
||||
<Dialog title={language.t("deviceAuth.error.detailsTitle")} fit>
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "12px", width: "min(720px, 80vw)" }}>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "12px",
|
||||
"max-height": "50vh",
|
||||
overflow: "auto",
|
||||
background: "var(--vscode-textCodeBlock-background, var(--vscode-editorWidget-background))",
|
||||
color: "var(--vscode-foreground)",
|
||||
border: "1px solid var(--border-weak-base, var(--vscode-panel-border))",
|
||||
"border-radius": "6px",
|
||||
"font-size": "12px",
|
||||
"line-height": "1.5",
|
||||
"white-space": "pre-wrap",
|
||||
"word-break": "break-word",
|
||||
}}
|
||||
>
|
||||
{props.error}
|
||||
</pre>
|
||||
<div class="dialog-confirm-actions">
|
||||
<Button variant="secondary" size="large" onClick={handleCopyError}>
|
||||
{language.t("deviceAuth.action.copyError")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
{/* Initiating state */}
|
||||
@@ -281,11 +359,23 @@ const DeviceAuthCard: Component<DeviceAuthCardProps> = (props) => {
|
||||
margin: "8px 0 12px 0",
|
||||
}}
|
||||
>
|
||||
{props.error || language.t("deviceAuth.status.failed")}
|
||||
{errorSummary()}
|
||||
</p>
|
||||
<Button variant="primary" onClick={props.onRetry}>
|
||||
{language.t("common.retry")}
|
||||
</Button>
|
||||
<div style={{ display: "flex", gap: "8px", "justify-content": "center", "flex-wrap": "wrap" }}>
|
||||
<Show when={props.error}>
|
||||
<Button variant="secondary" onClick={handleCopyError}>
|
||||
{language.t("deviceAuth.action.copyError")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={hasErrorDetails()}>
|
||||
<Button variant="ghost" onClick={handleShowError}>
|
||||
{language.t("deviceAuth.action.showDetails")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button variant="primary" onClick={props.onRetry}>
|
||||
{language.t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Match>
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
|
||||
import { Dialog } from "@kilocode/kilo-ui/dialog"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { ProviderIcon } from "@kilocode/kilo-ui/provider-icon"
|
||||
import { TextField } from "@kilocode/kilo-ui/text-field"
|
||||
import { showToast } from "@kilocode/kilo-ui/toast"
|
||||
import { For, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useConfig } from "../../context/config"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useProvider } from "../../context/provider"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import { createProviderAction } from "../../utils/provider-action"
|
||||
|
||||
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||
|
||||
type Translator = ReturnType<typeof useLanguage>["t"]
|
||||
|
||||
type ModelRow = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type HeaderRow = {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
providerID: string
|
||||
name: string
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
models: ModelRow[]
|
||||
headers: HeaderRow[]
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
type FormErrors = {
|
||||
providerID: string | undefined
|
||||
name: string | undefined
|
||||
baseURL: string | undefined
|
||||
models: Array<{ id?: string; name?: string }>
|
||||
headers: Array<{ key?: string; value?: string }>
|
||||
}
|
||||
|
||||
type ValidateArgs = {
|
||||
form: FormState
|
||||
t: Translator
|
||||
disabledProviders: string[]
|
||||
existingProviderIDs: Set<string>
|
||||
}
|
||||
|
||||
function validateCustomProvider(input: ValidateArgs) {
|
||||
const providerID = input.form.providerID.trim()
|
||||
const name = input.form.name.trim()
|
||||
const baseURL = input.form.baseURL.trim()
|
||||
const apiKey = input.form.apiKey.trim()
|
||||
|
||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||
const key = apiKey && !env ? apiKey : undefined
|
||||
|
||||
const idError = !providerID
|
||||
? input.t("provider.custom.error.providerID.required")
|
||||
: !PROVIDER_ID.test(providerID)
|
||||
? input.t("provider.custom.error.providerID.format")
|
||||
: undefined
|
||||
|
||||
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
||||
const urlError = !baseURL
|
||||
? input.t("provider.custom.error.baseURL.required")
|
||||
: !/^https?:\/\//.test(baseURL)
|
||||
? input.t("provider.custom.error.baseURL.format")
|
||||
: undefined
|
||||
|
||||
const disabled = input.disabledProviders.includes(providerID)
|
||||
const existsError = idError
|
||||
? undefined
|
||||
: input.existingProviderIDs.has(providerID) && !disabled
|
||||
? input.t("provider.custom.error.providerID.exists")
|
||||
: undefined
|
||||
|
||||
const seenModels = new Set<string>()
|
||||
const modelErrors = input.form.models.map((m) => {
|
||||
const id = m.id.trim()
|
||||
const modelIdError = !id
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenModels.has(id)
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenModels.add(id)
|
||||
return undefined
|
||||
})()
|
||||
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
||||
return { id: modelIdError, name: modelNameError }
|
||||
})
|
||||
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
||||
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||
|
||||
const seenHeaders = new Set<string>()
|
||||
const headerErrors = input.form.headers.map((h) => {
|
||||
const key = h.key.trim()
|
||||
const value = h.value.trim()
|
||||
|
||||
if (!key && !value) return {}
|
||||
const keyError = !key
|
||||
? input.t("provider.custom.error.required")
|
||||
: seenHeaders.has(key.toLowerCase())
|
||||
? input.t("provider.custom.error.duplicate")
|
||||
: (() => {
|
||||
seenHeaders.add(key.toLowerCase())
|
||||
return undefined
|
||||
})()
|
||||
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
||||
return { key: keyError, value: valueError }
|
||||
})
|
||||
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
||||
const headers = Object.fromEntries(
|
||||
input.form.headers
|
||||
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||
.filter((h) => !!h.key && !!h.value)
|
||||
.map((h) => [h.key, h.value]),
|
||||
)
|
||||
|
||||
const errors: FormErrors = {
|
||||
providerID: idError ?? existsError,
|
||||
name: nameError,
|
||||
baseURL: urlError,
|
||||
models: modelErrors,
|
||||
headers: headerErrors,
|
||||
}
|
||||
|
||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||
if (!ok) return { errors }
|
||||
|
||||
const options = {
|
||||
baseURL,
|
||||
...(Object.keys(headers).length ? { headers } : {}),
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
result: {
|
||||
providerID,
|
||||
name,
|
||||
key,
|
||||
config: {
|
||||
npm: OPENAI_COMPATIBLE,
|
||||
name,
|
||||
...(env ? { env: [env] } : {}),
|
||||
options,
|
||||
models,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface CustomProviderDialogProps {
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
const CustomProviderDialog = (props: CustomProviderDialogProps) => {
|
||||
const dialog = useDialog()
|
||||
const { config } = useConfig()
|
||||
const provider = useProvider()
|
||||
const language = useLanguage()
|
||||
const vscode = useVSCode()
|
||||
const action = createProviderAction(vscode)
|
||||
onCleanup(action.dispose)
|
||||
|
||||
const [form, setForm] = createStore<FormState>({
|
||||
providerID: "",
|
||||
name: "",
|
||||
baseURL: "",
|
||||
apiKey: "",
|
||||
models: [{ id: "", name: "" }],
|
||||
headers: [{ key: "", value: "" }],
|
||||
saving: false,
|
||||
})
|
||||
|
||||
const [errors, setErrors] = createStore<FormErrors>({
|
||||
providerID: undefined,
|
||||
name: undefined,
|
||||
baseURL: undefined,
|
||||
models: [{}],
|
||||
headers: [{}],
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
if (props.onBack) {
|
||||
props.onBack()
|
||||
return
|
||||
}
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
function addModel() {
|
||||
setForm("models", (v) => [...v, { id: "", name: "" }])
|
||||
setErrors("models", (v) => [...v, {}])
|
||||
}
|
||||
|
||||
function removeModel(index: number) {
|
||||
if (form.models.length <= 1) return
|
||||
setForm("models", (v) => v.filter((_, i) => i !== index))
|
||||
setErrors("models", (v) => v.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function addHeader() {
|
||||
setForm("headers", (v) => [...v, { key: "", value: "" }])
|
||||
setErrors("headers", (v) => [...v, {}])
|
||||
}
|
||||
|
||||
function removeHeader(index: number) {
|
||||
if (form.headers.length <= 1) return
|
||||
setForm("headers", (v) => v.filter((_, i) => i !== index))
|
||||
setErrors("headers", (v) => v.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function validate() {
|
||||
const output = validateCustomProvider({
|
||||
form,
|
||||
t: language.t,
|
||||
disabledProviders: config().disabled_providers ?? [],
|
||||
existingProviderIDs: new Set(Object.keys(provider.providers())),
|
||||
})
|
||||
setErrors(output.errors)
|
||||
return output.result
|
||||
}
|
||||
|
||||
function save(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
if (form.saving) return
|
||||
|
||||
const result = validate()
|
||||
if (!result) return
|
||||
|
||||
setForm("saving", true)
|
||||
|
||||
action.send(
|
||||
{
|
||||
type: "saveCustomProvider",
|
||||
providerID: result.providerID,
|
||||
config: result.config,
|
||||
apiKey: result.key,
|
||||
},
|
||||
{
|
||||
onConnected: () => {
|
||||
setForm("saving", false)
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
||||
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
||||
})
|
||||
},
|
||||
onError: (message) => {
|
||||
setForm("saving", false)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message.message })
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={goBack}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
}
|
||||
transition
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"flex-direction": "column",
|
||||
gap: "24px",
|
||||
padding: "0 10px 12px 10px",
|
||||
"overflow-y": "auto",
|
||||
"max-height": "60vh",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "0 10px", display: "flex", gap: "16px", "align-items": "center" }}>
|
||||
<ProviderIcon id="synthetic" width={20} height={20} />
|
||||
<div style={{ "font-size": "16px", "font-weight": "500", color: "var(--vscode-foreground)" }}>
|
||||
{language.t("provider.custom.title")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={save}
|
||||
style={{ padding: "0 10px 24px 10px", display: "flex", "flex-direction": "column", gap: "24px" }}
|
||||
>
|
||||
<div style={{ "font-size": "14px", color: "var(--text-base)" }}>
|
||||
{language.t("provider.custom.description.prefix")}
|
||||
<a
|
||||
href="https://kilo.ai/docs/providers/#custom-provider"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
vscode.postMessage({
|
||||
type: "openExternal",
|
||||
url: "https://kilo.ai/docs/providers/#custom-provider",
|
||||
})
|
||||
}}
|
||||
>
|
||||
{language.t("provider.custom.description.link")}
|
||||
</a>
|
||||
{language.t("provider.custom.description.suffix")}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "16px" }}>
|
||||
<TextField
|
||||
autofocus
|
||||
label={language.t("provider.custom.field.providerID.label")}
|
||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||
description={language.t("provider.custom.field.providerID.description")}
|
||||
value={form.providerID}
|
||||
onChange={(v) => setForm("providerID", v)}
|
||||
validationState={errors.providerID ? "invalid" : undefined}
|
||||
error={errors.providerID}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.name.label")}
|
||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||
value={form.name}
|
||||
onChange={(v) => setForm("name", v)}
|
||||
validationState={errors.name ? "invalid" : undefined}
|
||||
error={errors.name}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.baseURL.label")}
|
||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||
value={form.baseURL}
|
||||
onChange={(v) => setForm("baseURL", v)}
|
||||
validationState={errors.baseURL ? "invalid" : undefined}
|
||||
error={errors.baseURL}
|
||||
/>
|
||||
<TextField
|
||||
type="password"
|
||||
label={language.t("provider.custom.field.apiKey.label")}
|
||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||
description={language.t("provider.custom.field.apiKey.description")}
|
||||
value={form.apiKey}
|
||||
onChange={(v) => setForm("apiKey", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "12px" }}>
|
||||
<label style={{ "font-size": "12px", "font-weight": "500", color: "var(--text-weak-base)" }}>
|
||||
{language.t("provider.custom.models.label")}
|
||||
</label>
|
||||
<For each={form.models}>
|
||||
{(m, i) => (
|
||||
<div style={{ display: "flex", gap: "8px", "align-items": "start" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<TextField
|
||||
label={language.t("provider.custom.models.id.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.id.placeholder")}
|
||||
value={m.id}
|
||||
onChange={(v) => setForm("models", i(), "id", v)}
|
||||
validationState={errors.models[i()]?.id ? "invalid" : undefined}
|
||||
error={errors.models[i()]?.id}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<TextField
|
||||
label={language.t("provider.custom.models.name.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.name.placeholder")}
|
||||
value={m.name}
|
||||
onChange={(v) => setForm("models", i(), "name", v)}
|
||||
validationState={errors.models[i()]?.name ? "invalid" : undefined}
|
||||
error={errors.models[i()]?.name}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="trash"
|
||||
variant="ghost"
|
||||
onClick={() => removeModel(i())}
|
||||
disabled={form.models.length <= 1}
|
||||
aria-label={language.t("provider.custom.models.remove")}
|
||||
style={{ "margin-top": "6px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel}>
|
||||
{language.t("provider.custom.models.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "12px" }}>
|
||||
<label style={{ "font-size": "12px", "font-weight": "500", color: "var(--text-weak-base)" }}>
|
||||
{language.t("provider.custom.headers.label")}
|
||||
</label>
|
||||
<For each={form.headers}>
|
||||
{(h, i) => (
|
||||
<div style={{ display: "flex", gap: "8px", "align-items": "start" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<TextField
|
||||
label={language.t("provider.custom.headers.key.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
||||
value={h.key}
|
||||
onChange={(v) => setForm("headers", i(), "key", v)}
|
||||
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
|
||||
error={errors.headers[i()]?.key}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<TextField
|
||||
label={language.t("provider.custom.headers.value.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
||||
value={h.value}
|
||||
onChange={(v) => setForm("headers", i(), "value", v)}
|
||||
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
|
||||
error={errors.headers[i()]?.value}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="trash"
|
||||
variant="ghost"
|
||||
onClick={() => removeHeader(i())}
|
||||
disabled={form.headers.length <= 1}
|
||||
aria-label={language.t("provider.custom.headers.remove")}
|
||||
style={{ "margin-top": "6px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader}>
|
||||
{language.t("provider.custom.headers.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="large" variant="primary" disabled={form.saving}>
|
||||
{form.saving ? language.t("common.saving") : language.t("common.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomProviderDialog
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Component, For, createMemo } from "solid-js"
|
||||
import { Card } from "@kilocode/kilo-ui/card"
|
||||
import { useConfig } from "../../context/config"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useSession } from "../../context/session"
|
||||
import { parseModelString } from "../../../../src/shared/provider-model"
|
||||
import { ModelSelectorBase } from "../shared/ModelSelector"
|
||||
import SettingsRow from "./SettingsRow"
|
||||
|
||||
const ModelsTab: Component = () => {
|
||||
const { config, updateConfig } = useConfig()
|
||||
const language = useLanguage()
|
||||
const session = useSession()
|
||||
|
||||
function handleModelSelect(configKey: "model" | "small_model") {
|
||||
return (providerID: string, modelID: string) => {
|
||||
if (!providerID || !modelID) {
|
||||
updateConfig({ [configKey]: null })
|
||||
return
|
||||
}
|
||||
updateConfig({ [configKey]: `${providerID}/${modelID}` })
|
||||
}
|
||||
}
|
||||
|
||||
const allAgents = createMemo(() => session.agents())
|
||||
|
||||
function handleModeModelSelect(agentName: string) {
|
||||
return (providerID: string, modelID: string) => {
|
||||
if (!providerID || !modelID) {
|
||||
updateConfig({ agent: { [agentName]: { model: null } } })
|
||||
return
|
||||
}
|
||||
updateConfig({ agent: { [agentName]: { model: `${providerID}/${modelID}` } } })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<SettingsRow
|
||||
title={language.t("settings.providers.defaultModel.title")}
|
||||
description={language.t("settings.providers.defaultModel.description")}
|
||||
>
|
||||
<ModelSelectorBase
|
||||
value={parseModelString(config().model ?? undefined)}
|
||||
onSelect={handleModelSelect("model")}
|
||||
placement="bottom-start"
|
||||
allowClear
|
||||
clearLabel={language.t("settings.providers.notSet")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={language.t("settings.providers.smallModel.title")}
|
||||
description={language.t("settings.providers.smallModel.description")}
|
||||
last
|
||||
>
|
||||
<ModelSelectorBase
|
||||
value={parseModelString(config().small_model ?? undefined)}
|
||||
onSelect={handleModelSelect("small_model")}
|
||||
placement="bottom-start"
|
||||
allowClear
|
||||
clearLabel={language.t("settings.providers.notSet")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</Card>
|
||||
|
||||
<h4 style={{ "margin-top": "24px", "margin-bottom": "8px" }}>{language.t("settings.providers.modeModels")}</h4>
|
||||
<Card>
|
||||
<For each={allAgents()}>
|
||||
{(agent, index) => (
|
||||
<SettingsRow
|
||||
title={agent.name.charAt(0).toUpperCase() + agent.name.slice(1)}
|
||||
last={index() === allAgents().length - 1}
|
||||
>
|
||||
<ModelSelectorBase
|
||||
value={parseModelString(config().agent?.[agent.name]?.model ?? undefined)}
|
||||
onSelect={handleModeModelSelect(agent.name)}
|
||||
placement="bottom-start"
|
||||
allowClear
|
||||
clearLabel={language.t("settings.providers.notSet")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</For>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModelsTab
|
||||
@@ -0,0 +1,406 @@
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
|
||||
import { Dialog } from "@kilocode/kilo-ui/dialog"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { TextField } from "@kilocode/kilo-ui/text-field"
|
||||
import { showToast } from "@kilocode/kilo-ui/toast"
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@kilocode/sdk/v2/client"
|
||||
import { Component, For, Match, Show, Switch, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useProvider } from "../../context/provider"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import { createProviderAction } from "../../utils/provider-action"
|
||||
|
||||
interface ProviderConnectDialogProps {
|
||||
providerID: string
|
||||
}
|
||||
|
||||
interface ViewState {
|
||||
methodIndex?: number
|
||||
authorization?: ProviderAuthAuthorization
|
||||
phase?: "authorizing" | "connecting"
|
||||
error?: string
|
||||
failed?: string
|
||||
}
|
||||
|
||||
function fallbackMethods(label: string): ProviderAuthMethod[] {
|
||||
return [{ type: "api", label }]
|
||||
}
|
||||
|
||||
function formatError(value: unknown, fallback: string): string {
|
||||
if (value && typeof value === "object" && "message" in value) {
|
||||
const message = (value as { message?: unknown }).message
|
||||
if (typeof message === "string" && message) return message
|
||||
}
|
||||
if (typeof value === "string" && value) return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
const ProviderConnectDialog: Component<ProviderConnectDialogProps> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const provider = useProvider()
|
||||
const vscode = useVSCode()
|
||||
const action = createProviderAction(vscode)
|
||||
|
||||
const [state, setState] = createStore<ViewState>({})
|
||||
|
||||
const item = createMemo(() => provider.providers()[props.providerID])
|
||||
const name = () => item()?.name ?? props.providerID
|
||||
const methods = createMemo<ProviderAuthMethod[]>(() => {
|
||||
return provider.authMethods()[props.providerID] ?? fallbackMethods(language.t("provider.connect.method.apiKey"))
|
||||
})
|
||||
const method = createMemo(() => {
|
||||
const index = state.methodIndex
|
||||
return index === undefined ? undefined : methods()[index]
|
||||
})
|
||||
|
||||
onCleanup(action.dispose)
|
||||
|
||||
onMount(() => {
|
||||
if (methods().length !== 1) return
|
||||
selectMethod(0)
|
||||
})
|
||||
|
||||
function openExternal(url: string) {
|
||||
vscode.postMessage({ type: "openExternal", url })
|
||||
}
|
||||
|
||||
function reset() {
|
||||
action.clear()
|
||||
setState({
|
||||
methodIndex: undefined,
|
||||
authorization: undefined,
|
||||
phase: undefined,
|
||||
error: undefined,
|
||||
failed: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function fail(message: string) {
|
||||
const failed = state.authorization?.method === "auto" || state.phase === "authorizing"
|
||||
setState({
|
||||
...state,
|
||||
phase: undefined,
|
||||
error: failed ? undefined : message,
|
||||
failed: failed ? message : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function succeed() {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.connect.toast.connected.title", { provider: name() }),
|
||||
description: language.t("provider.connect.toast.connected.description", { provider: name() }),
|
||||
})
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
function selectMethod(index: number) {
|
||||
const current = methods()[index]
|
||||
action.clear()
|
||||
setState({
|
||||
methodIndex: index,
|
||||
authorization: undefined,
|
||||
phase: current?.type === "oauth" ? "authorizing" : undefined,
|
||||
error: undefined,
|
||||
failed: undefined,
|
||||
})
|
||||
if (current?.type !== "oauth") return
|
||||
|
||||
action.send(
|
||||
{
|
||||
type: "authorizeProviderOAuth",
|
||||
providerID: props.providerID,
|
||||
method: index,
|
||||
},
|
||||
{
|
||||
onOAuthReady: (message) => {
|
||||
setState({
|
||||
...state,
|
||||
authorization: message.authorization,
|
||||
phase: undefined,
|
||||
error: undefined,
|
||||
failed: undefined,
|
||||
})
|
||||
},
|
||||
onError: (message) => fail(message.message),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function connect(apiKey: string) {
|
||||
setState({
|
||||
...state,
|
||||
phase: "connecting",
|
||||
error: undefined,
|
||||
failed: undefined,
|
||||
})
|
||||
action.send(
|
||||
{
|
||||
type: "connectProvider",
|
||||
providerID: props.providerID,
|
||||
apiKey,
|
||||
},
|
||||
{
|
||||
onConnected: succeed,
|
||||
onError: (message) => fail(message.message),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function complete(code?: string) {
|
||||
const index = state.methodIndex
|
||||
if (index === undefined) return
|
||||
|
||||
setState({
|
||||
...state,
|
||||
phase: "connecting",
|
||||
error: undefined,
|
||||
failed: undefined,
|
||||
})
|
||||
action.send(
|
||||
{
|
||||
type: "completeProviderOAuth",
|
||||
providerID: props.providerID,
|
||||
method: index,
|
||||
code,
|
||||
},
|
||||
{
|
||||
onConnected: succeed,
|
||||
onError: (message) => fail(message.message),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const title = () => language.t("provider.connect.title", { provider: name() })
|
||||
|
||||
const MethodSelection: Component = () => {
|
||||
return (
|
||||
<div class="dialog-confirm-body" style={{ display: "flex", "flex-direction": "column", gap: "12px" }}>
|
||||
<div class="provider-connect-body">{language.t("provider.connect.selectMethod", { provider: name() })}</div>
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "8px" }}>
|
||||
<For each={methods()}>
|
||||
{(item, index) => (
|
||||
<Button variant="secondary" size="large" onClick={() => selectMethod(index())}>
|
||||
{item.type === "api" ? language.t("provider.connect.method.apiKey") : item.label}
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="dialog-confirm-actions">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ApiView: Component = () => {
|
||||
const [value, setValue] = createSignal("")
|
||||
|
||||
function submit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
const apiKey = value().trim()
|
||||
if (!apiKey) {
|
||||
setState({ ...state, error: language.t("provider.connect.apiKey.required") })
|
||||
return
|
||||
}
|
||||
connect(apiKey)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
class="dialog-confirm-body"
|
||||
style={{ display: "flex", "flex-direction": "column", gap: "16px" }}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<div class="provider-connect-body">
|
||||
{language.t("provider.connect.apiKey.description", { provider: name() })}
|
||||
</div>
|
||||
<TextField
|
||||
autofocus
|
||||
type="password"
|
||||
label={language.t("provider.connect.apiKey.label", { provider: name() })}
|
||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||
value={value()}
|
||||
onChange={setValue}
|
||||
validationState={state.error ? "invalid" : undefined}
|
||||
error={state.error}
|
||||
/>
|
||||
<div class="dialog-confirm-actions">
|
||||
<Button variant="ghost" size="large" type="button" onClick={reset}>
|
||||
{language.t("common.goBack")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" type="submit" disabled={state.phase === "connecting"}>
|
||||
{language.t("common.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const OAuthCodeView: Component = () => {
|
||||
const [value, setValue] = createSignal("")
|
||||
|
||||
onMount(() => {
|
||||
if (!state.authorization?.url) return
|
||||
openExternal(state.authorization.url)
|
||||
})
|
||||
|
||||
function submit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
const code = value().trim()
|
||||
if (!code) {
|
||||
setState({ ...state, error: language.t("provider.connect.oauth.code.required") })
|
||||
return
|
||||
}
|
||||
complete(code)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
class="dialog-confirm-body"
|
||||
style={{ display: "flex", "flex-direction": "column", gap: "16px" }}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<div class="provider-connect-body">
|
||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||
<a
|
||||
href={state.authorization?.url ?? "#"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (!state.authorization?.url) return
|
||||
openExternal(state.authorization.url)
|
||||
}}
|
||||
>
|
||||
{language.t("provider.connect.oauth.code.visit.link")}
|
||||
</a>
|
||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: name() })}
|
||||
</div>
|
||||
<TextField
|
||||
autofocus
|
||||
type="text"
|
||||
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||
value={value()}
|
||||
onChange={setValue}
|
||||
validationState={state.error ? "invalid" : undefined}
|
||||
error={state.error}
|
||||
/>
|
||||
<div class="dialog-confirm-actions">
|
||||
<Button variant="ghost" size="large" type="button" onClick={reset}>
|
||||
{language.t("common.goBack")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" type="submit" disabled={state.phase === "connecting"}>
|
||||
{language.t("common.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const OAuthAutoView: Component = () => {
|
||||
const code = createMemo(() => {
|
||||
const instructions = state.authorization?.instructions
|
||||
if (!instructions) return ""
|
||||
if (!instructions.includes(":")) return instructions
|
||||
return instructions.split(":")[1]?.trim() ?? instructions
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (state.authorization?.url) openExternal(state.authorization.url)
|
||||
complete()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="dialog-confirm-body" style={{ display: "flex", "flex-direction": "column", gap: "16px" }}>
|
||||
<div class="provider-connect-body">
|
||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||
<a
|
||||
href={state.authorization?.url ?? "#"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (!state.authorization?.url) return
|
||||
openExternal(state.authorization.url)
|
||||
}}
|
||||
>
|
||||
{language.t("provider.connect.oauth.auto.visit.link")}
|
||||
</a>
|
||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: name() })}
|
||||
</div>
|
||||
<Show when={code()}>
|
||||
<div>
|
||||
<div class="provider-connect-code-label">{language.t("provider.connect.oauth.auto.confirmationCode")}</div>
|
||||
<div class="provider-connect-code">{code()}</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="provider-connect-status">
|
||||
<Spinner />
|
||||
<span>
|
||||
{state.error
|
||||
? language.t("provider.connect.status.failed", { error: state.error })
|
||||
: language.t("provider.connect.status.waiting")}
|
||||
</span>
|
||||
</div>
|
||||
<div class="dialog-confirm-actions">
|
||||
<Button variant="ghost" size="large" type="button" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={title()} fit>
|
||||
<Switch>
|
||||
<Match when={state.methodIndex === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
<Match when={state.phase === "authorizing"}>
|
||||
<div class="dialog-confirm-body">
|
||||
<div class="provider-connect-status">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={state.failed}>
|
||||
<div class="dialog-confirm-body" style={{ display: "flex", "flex-direction": "column", gap: "16px" }}>
|
||||
<div>{formatError(state.failed, language.t("common.requestFailed"))}</div>
|
||||
<div class="dialog-confirm-actions">
|
||||
<Button variant="ghost" size="large" onClick={reset}>
|
||||
{language.t("common.goBack")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={method()?.type === "api"}>
|
||||
<ApiView />
|
||||
</Match>
|
||||
<Match when={state.authorization?.method === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={state.authorization?.method === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="dialog-confirm-body" style={{ display: "flex", "flex-direction": "column", gap: "16px" }}>
|
||||
<div>{formatError(state.error ?? state.failed, language.t("common.requestFailed"))}</div>
|
||||
<div class="dialog-confirm-actions">
|
||||
<Button variant="ghost" size="large" onClick={reset}>
|
||||
{language.t("common.goBack")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProviderConnectDialog
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
|
||||
import { Dialog } from "@kilocode/kilo-ui/dialog"
|
||||
import { List } from "@kilocode/kilo-ui/list"
|
||||
import { ProviderIcon } from "@kilocode/kilo-ui/provider-icon"
|
||||
import { Tag } from "@kilocode/kilo-ui/tag"
|
||||
import { Show, createMemo } from "solid-js"
|
||||
import { useConfig } from "../../context/config"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useProvider } from "../../context/provider"
|
||||
import { useServer } from "../../context/server"
|
||||
import type { Provider } from "../../types/messages"
|
||||
import ProviderConnectDialog from "./ProviderConnectDialog"
|
||||
import {
|
||||
CUSTOM_PROVIDER_ID,
|
||||
isPopularProvider,
|
||||
kiloFallbackProvider,
|
||||
popularProviderIndex,
|
||||
providerIcon,
|
||||
} from "./provider-catalog"
|
||||
import CustomProviderDialog from "./CustomProviderDialog"
|
||||
import { KILO_PROVIDER_ID } from "../../../../src/shared/provider-model"
|
||||
|
||||
type ProviderItem = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const ProviderSelectDialog = () => {
|
||||
const dialog = useDialog()
|
||||
const { config } = useConfig()
|
||||
const provider = useProvider()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
|
||||
const items = createMemo<ProviderItem[]>(() => {
|
||||
language.locale()
|
||||
|
||||
const disabled = new Set(config().disabled_providers ?? [])
|
||||
const connected = new Set(provider.connected())
|
||||
const all = Object.values(provider.providers())
|
||||
const withKilo = all.some((item) => item.id === KILO_PROVIDER_ID) ? all : [kiloFallbackProvider(), ...all]
|
||||
const available = withKilo.filter((item) => !disabled.has(item.id) && !connected.has(item.id))
|
||||
|
||||
return [
|
||||
{
|
||||
id: CUSTOM_PROVIDER_ID,
|
||||
name: language.t("settings.providers.tag.customProvider"),
|
||||
},
|
||||
...available.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
})),
|
||||
]
|
||||
})
|
||||
|
||||
function open(item: ProviderItem) {
|
||||
if (item.id === CUSTOM_PROVIDER_ID) {
|
||||
dialog.show(() => <CustomProviderDialog onBack={() => dialog.show(() => <ProviderSelectDialog />)} />)
|
||||
return
|
||||
}
|
||||
|
||||
if (item.id === KILO_PROVIDER_ID) {
|
||||
dialog.close()
|
||||
server.startLogin()
|
||||
return
|
||||
}
|
||||
|
||||
dialog.show(() => <ProviderConnectDialog providerID={item.id} />)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("command.provider.connect")} size="large" transition>
|
||||
<List<ProviderItem>
|
||||
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
|
||||
emptyMessage={language.t("dialog.provider.empty")}
|
||||
activeIcon="plus-small"
|
||||
key={(item) => item.id}
|
||||
items={items()}
|
||||
filterKeys={["id", "name"]}
|
||||
groupBy={(item) =>
|
||||
item.id !== CUSTOM_PROVIDER_ID && isPopularProvider(item.id)
|
||||
? language.t("dialog.provider.group.recommended")
|
||||
: language.t("dialog.provider.group.other")
|
||||
}
|
||||
sortBy={(a, b) => {
|
||||
if (a.id === CUSTOM_PROVIDER_ID) return -1
|
||||
if (b.id === CUSTOM_PROVIDER_ID) return 1
|
||||
|
||||
const rank = popularProviderIndex(a.id) - popularProviderIndex(b.id)
|
||||
if (rank !== 0) return rank
|
||||
return a.name.localeCompare(b.name)
|
||||
}}
|
||||
sortGroupsBy={(a, b) => {
|
||||
const recommended = language.t("dialog.provider.group.recommended")
|
||||
if (a.category === recommended && b.category !== recommended) return -1
|
||||
if (b.category === recommended && a.category !== recommended) return 1
|
||||
return 0
|
||||
}}
|
||||
onSelect={(item) => {
|
||||
if (!item) return
|
||||
open(item)
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<div style={{ display: "flex", gap: "10px", "align-items": "center", width: "100%", "min-width": 0 }}>
|
||||
<ProviderIcon id={providerIcon(item.id)} width={18} height={18} data-slot="list-item-extra-icon" />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
"align-items": "center",
|
||||
"min-width": 0,
|
||||
flex: 1,
|
||||
"flex-wrap": "wrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ "font-size": "14px", "line-height": "20px", color: "var(--vscode-foreground)" }}>
|
||||
{item.name}
|
||||
</span>
|
||||
<Show when={item.id === KILO_PROVIDER_ID}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={item.id === CUSTOM_PROVIDER_ID}>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProviderSelectDialog
|
||||
@@ -1,218 +1,315 @@
|
||||
import { Component, For, createSignal, createMemo } from "solid-js"
|
||||
import { Select } from "@kilocode/kilo-ui/select"
|
||||
import { Card } from "@kilocode/kilo-ui/card"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Card } from "@kilocode/kilo-ui/card"
|
||||
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { ProviderIcon } from "@kilocode/kilo-ui/provider-icon"
|
||||
import { Tag } from "@kilocode/kilo-ui/tag"
|
||||
import { showToast } from "@kilocode/kilo-ui/toast"
|
||||
import { Component, For, Show, createMemo, onCleanup } from "solid-js"
|
||||
import { useConfig } from "../../context/config"
|
||||
import { useProvider } from "../../context/provider"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useSession } from "../../context/session"
|
||||
import { ModelSelectorBase } from "../shared/ModelSelector"
|
||||
import type { ModelSelection } from "../../types/messages"
|
||||
import SettingsRow from "./SettingsRow"
|
||||
import { useProvider } from "../../context/provider"
|
||||
import { useServer } from "../../context/server"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import type { Provider } from "../../types/messages"
|
||||
import CustomProviderDialog from "./CustomProviderDialog"
|
||||
import ProviderConnectDialog from "./ProviderConnectDialog"
|
||||
import ProviderSelectDialog from "./ProviderSelectDialog"
|
||||
import { CUSTOM_PROVIDER_ID, isPopularProvider, providerIcon, providerNoteKey, sortProviders } from "./provider-catalog"
|
||||
import { visibleConnectedIds } from "./provider-visibility"
|
||||
import { KILO_PROVIDER_ID } from "../../../../src/shared/provider-model"
|
||||
import { createProviderAction } from "../../utils/provider-action"
|
||||
|
||||
interface ProviderOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Parse a "provider/model" config string into a ModelSelection (or null). */
|
||||
function parseModelConfig(raw: string | undefined): ModelSelection | null {
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
const slash = raw.indexOf("/")
|
||||
if (slash <= 0) {
|
||||
return null
|
||||
}
|
||||
return { providerID: raw.slice(0, slash), modelID: raw.slice(slash + 1) }
|
||||
}
|
||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||
|
||||
const ProvidersTab: Component = () => {
|
||||
const { config, updateConfig } = useConfig()
|
||||
const dialog = useDialog()
|
||||
const { config } = useConfig()
|
||||
const provider = useProvider()
|
||||
const language = useLanguage()
|
||||
const session = useSession()
|
||||
const server = useServer()
|
||||
const vscode = useVSCode()
|
||||
const action = createProviderAction(vscode)
|
||||
|
||||
const providerOptions = createMemo<ProviderOption[]>(() =>
|
||||
Object.keys(provider.providers())
|
||||
.sort()
|
||||
.map((id) => ({ value: id, label: id })),
|
||||
)
|
||||
onCleanup(action.dispose)
|
||||
|
||||
const [newDisabled, setNewDisabled] = createSignal<ProviderOption | undefined>()
|
||||
const kiloLoggedIn = createMemo(() => !!server.profileData())
|
||||
|
||||
const disabledProviders = () => config().disabled_providers ?? []
|
||||
const connectedProviders = createMemo(() => {
|
||||
const ids = visibleConnectedIds(provider.connected(), provider.authStates())
|
||||
const all = provider.providers()
|
||||
return ids
|
||||
.filter((id) => id !== KILO_PROVIDER_ID)
|
||||
.map((id) => all[id])
|
||||
.filter((item): item is Provider => !!item)
|
||||
})
|
||||
|
||||
const addDisabled = (value: string) => {
|
||||
const current = [...disabledProviders()]
|
||||
if (value && !current.includes(value)) {
|
||||
current.push(value)
|
||||
updateConfig({ disabled_providers: current })
|
||||
}
|
||||
const popularProviders = createMemo(() => {
|
||||
const connected = new Set(provider.connected())
|
||||
const disabled = new Set(config().disabled_providers ?? [])
|
||||
const all = Object.values(provider.providers())
|
||||
return sortProviders(
|
||||
all.filter(
|
||||
(item) =>
|
||||
item.id !== KILO_PROVIDER_ID &&
|
||||
isPopularProvider(item.id) &&
|
||||
!connected.has(item.id) &&
|
||||
!disabled.has(item.id),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function source(item: Provider): ProviderSource | undefined {
|
||||
if (!("source" in item)) return
|
||||
const value = (item as Provider & { source?: string }).source
|
||||
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
|
||||
return
|
||||
}
|
||||
|
||||
const removeDisabled = (index: number) => {
|
||||
const current = [...disabledProviders()]
|
||||
current.splice(index, 1)
|
||||
updateConfig({ disabled_providers: current })
|
||||
function sourceTag(item: Provider) {
|
||||
const current = source(item)
|
||||
if (current === "env") return language.t("settings.providers.tag.environment")
|
||||
if (current === "api") return language.t("provider.connect.method.apiKey")
|
||||
if (current === "config") {
|
||||
const cfg = config().provider?.[item.id]
|
||||
if (cfg?.npm === "@ai-sdk/openai-compatible") return language.t("settings.providers.tag.custom")
|
||||
return language.t("settings.providers.tag.config")
|
||||
}
|
||||
if (current === "custom") return language.t("settings.providers.tag.custom")
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
function handleModelSelect(configKey: "model" | "small_model") {
|
||||
return (providerID: string, modelID: string) => {
|
||||
if (!providerID || !modelID) {
|
||||
updateConfig({ [configKey]: null })
|
||||
} else {
|
||||
updateConfig({ [configKey]: `${providerID}/${modelID}` })
|
||||
}
|
||||
}
|
||||
function canDisconnect(item: Provider) {
|
||||
return source(item) !== "env"
|
||||
}
|
||||
|
||||
const allAgents = createMemo(() => session.agents())
|
||||
function disconnect(providerID: string, name: string) {
|
||||
action.send(
|
||||
{ type: "disconnectProvider", providerID },
|
||||
{
|
||||
onDisconnected: () => {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
|
||||
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
|
||||
})
|
||||
},
|
||||
onError: (message) => {
|
||||
showToast({ title: language.t("common.requestFailed"), description: message.message })
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function handleModeModelSelect(agentName: string) {
|
||||
return (providerID: string, modelID: string) => {
|
||||
if (!providerID || !modelID) {
|
||||
updateConfig({ agent: { [agentName]: { model: null } } })
|
||||
} else {
|
||||
updateConfig({ agent: { [agentName]: { model: `${providerID}/${modelID}` } } })
|
||||
}
|
||||
function connectProvider(item: Provider) {
|
||||
if (item.id === KILO_PROVIDER_ID) {
|
||||
server.startLogin()
|
||||
return
|
||||
}
|
||||
dialog.show(() => <ProviderConnectDialog providerID={item.id} />)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Model selection */}
|
||||
{/* Kilo Gateway — always at the top, not editable */}
|
||||
<Card>
|
||||
<SettingsRow
|
||||
title={language.t("settings.providers.defaultModel.title")}
|
||||
description={language.t("settings.providers.defaultModel.description")}
|
||||
>
|
||||
<ModelSelectorBase
|
||||
value={parseModelConfig(config().model ?? undefined)}
|
||||
onSelect={handleModelSelect("model")}
|
||||
placement="bottom-start"
|
||||
allowClear
|
||||
clearLabel={language.t("settings.providers.notSet")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={language.t("settings.providers.smallModel.title")}
|
||||
description={language.t("settings.providers.smallModel.description")}
|
||||
last
|
||||
>
|
||||
<ModelSelectorBase
|
||||
value={parseModelConfig(config().small_model ?? undefined)}
|
||||
onSelect={handleModelSelect("small_model")}
|
||||
placement="bottom-start"
|
||||
allowClear
|
||||
clearLabel={language.t("settings.providers.notSet")}
|
||||
includeAutoSmall
|
||||
/>
|
||||
</SettingsRow>
|
||||
</Card>
|
||||
|
||||
{/* Model per Mode */}
|
||||
<h4 style={{ "margin-top": "24px", "margin-bottom": "8px" }}>{language.t("settings.providers.modeModels")}</h4>
|
||||
<Card>
|
||||
<For each={allAgents()}>
|
||||
{(agent, index) => (
|
||||
<SettingsRow
|
||||
title={agent.name.charAt(0).toUpperCase() + agent.name.slice(1)}
|
||||
last={index() === allAgents().length - 1}
|
||||
>
|
||||
<ModelSelectorBase
|
||||
value={parseModelConfig(config().agent?.[agent.name]?.model ?? undefined)}
|
||||
onSelect={handleModeModelSelect(agent.name)}
|
||||
placement="bottom-start"
|
||||
allowClear
|
||||
clearLabel={language.t("settings.providers.notSet")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</For>
|
||||
</Card>
|
||||
|
||||
{/* Beta notice */}
|
||||
<Card
|
||||
variant="warning"
|
||||
style={{
|
||||
"margin-top": "16px",
|
||||
display: "flex",
|
||||
"flex-direction": "row",
|
||||
"align-items": "flex-start",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<Icon name="warning" style={{ "flex-shrink": "0", "margin-top": "2px" }} />
|
||||
<p style={{ margin: 0, "line-height": "1.5" }}>{language.t("settings.providers.betaNotice")}</p>
|
||||
</Card>
|
||||
|
||||
{/* Disabled providers */}
|
||||
<h4 style={{ "margin-top": "16px", "margin-bottom": "8px" }}>{language.t("settings.providers.disabled")}</h4>
|
||||
<Card>
|
||||
<div
|
||||
style={{
|
||||
"font-size": "12px",
|
||||
color: "var(--text-weak-base, var(--vscode-descriptionForeground))",
|
||||
"padding-bottom": "8px",
|
||||
"border-bottom": "1px solid var(--border-weak-base)",
|
||||
}}
|
||||
>
|
||||
{language.t("settings.providers.disabled.description")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
"align-items": "center",
|
||||
padding: "8px 0",
|
||||
"border-bottom": disabledProviders().length > 0 ? "1px solid var(--border-weak-base)" : "none",
|
||||
gap: "12px",
|
||||
"min-height": "56px",
|
||||
padding: "12px 0",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Select
|
||||
options={providerOptions().filter((o) => !disabledProviders().includes(o.value))}
|
||||
current={newDisabled()}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(o) => setNewDisabled(o)}
|
||||
variant="secondary"
|
||||
triggerVariant="settings"
|
||||
placeholder="Select provider…"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (newDisabled()) {
|
||||
addDisabled(newDisabled()!.value)
|
||||
setNewDisabled(undefined)
|
||||
}
|
||||
}}
|
||||
<ProviderIcon id="synthetic" width={20} height={20} />
|
||||
<span style={{ "font-size": "14px", "font-weight": "500", color: "var(--vscode-foreground)" }}>
|
||||
Kilo Gateway
|
||||
</span>
|
||||
<Show
|
||||
when={kiloLoggedIn()}
|
||||
fallback={
|
||||
<Button size="small" variant="secondary" onClick={() => server.startLogin()}>
|
||||
{language.t("common.signIn")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{language.t("common.add")}
|
||||
</Button>
|
||||
<Tag>{language.t("settings.providers.tag.gateway")}</Tag>
|
||||
</Show>
|
||||
</div>
|
||||
<For each={disabledProviders()}>
|
||||
{(id, index) => (
|
||||
</Card>
|
||||
|
||||
{/* Connected providers (excluding Kilo) */}
|
||||
<h4 style={{ "margin-top": "16px", "margin-bottom": "8px" }}>
|
||||
{language.t("settings.providers.section.connected")}
|
||||
</h4>
|
||||
<Card>
|
||||
<Show
|
||||
when={connectedProviders().length > 0}
|
||||
fallback={
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "space-between",
|
||||
padding: "6px 0",
|
||||
"border-bottom":
|
||||
index() < disabledProviders().length - 1 ? "1px solid var(--border-weak-base)" : "none",
|
||||
padding: "16px 0",
|
||||
"font-size": "14px",
|
||||
color: "var(--text-weak-base, var(--vscode-descriptionForeground))",
|
||||
}}
|
||||
>
|
||||
<span style={{ "font-size": "12px" }}>{id}</span>
|
||||
<IconButton variant="ghost" icon="close" onClick={() => removeDisabled(index())} />
|
||||
{language.t("settings.providers.connected.empty")}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<For each={connectedProviders()}>
|
||||
{(item) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"flex-wrap": "wrap",
|
||||
"align-items": "center",
|
||||
"justify-content": "space-between",
|
||||
gap: "16px",
|
||||
"min-height": "56px",
|
||||
padding: "12px 0",
|
||||
"border-bottom": "1px solid var(--border-weak-base)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px", "min-width": 0 }}>
|
||||
<ProviderIcon id={providerIcon(item.id)} width={20} height={20} />
|
||||
<span
|
||||
style={{
|
||||
"font-size": "14px",
|
||||
"font-weight": "500",
|
||||
color: "var(--vscode-foreground)",
|
||||
overflow: "hidden",
|
||||
"text-overflow": "ellipsis",
|
||||
"white-space": "nowrap",
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
<Tag>{sourceTag(item)}</Tag>
|
||||
</div>
|
||||
<Show
|
||||
when={canDisconnect(item)}
|
||||
fallback={
|
||||
<span
|
||||
style={{
|
||||
"font-size": "14px",
|
||||
color: "var(--text-base, var(--vscode-descriptionForeground))",
|
||||
"padding-right": "12px",
|
||||
}}
|
||||
>
|
||||
{language.t("settings.providers.connected.environmentDescription")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Button size="large" variant="ghost" onClick={() => disconnect(item.id, item.name)}>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Card>
|
||||
|
||||
{/* Popular providers */}
|
||||
<h4 style={{ "margin-top": "24px", "margin-bottom": "8px" }}>
|
||||
{language.t("settings.providers.section.popular")}
|
||||
</h4>
|
||||
<Card>
|
||||
<For each={popularProviders()}>
|
||||
{(item) => {
|
||||
const noteKey = providerNoteKey(item.id)
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"flex-wrap": "wrap",
|
||||
"align-items": "center",
|
||||
"justify-content": "space-between",
|
||||
gap: "16px",
|
||||
"min-height": "56px",
|
||||
padding: "12px 0",
|
||||
"border-bottom": "1px solid var(--border-weak-base)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", "flex-direction": "column", "min-width": 0 }}>
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<ProviderIcon id={providerIcon(item.id)} width={20} height={20} />
|
||||
<span style={{ "font-size": "14px", "font-weight": "500", color: "var(--vscode-foreground)" }}>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={noteKey}>
|
||||
{(key) => (
|
||||
<span
|
||||
style={{
|
||||
"font-size": "12px",
|
||||
color: "var(--text-weak-base, var(--vscode-descriptionForeground))",
|
||||
"padding-left": "32px",
|
||||
}}
|
||||
>
|
||||
{language.t(key())}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Button size="large" variant="secondary" icon="plus-small" onClick={() => connectProvider(item)}>
|
||||
{language.t("common.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
{/* Custom provider entry */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"flex-wrap": "wrap",
|
||||
"align-items": "center",
|
||||
"justify-content": "space-between",
|
||||
gap: "16px",
|
||||
"min-height": "56px",
|
||||
padding: "12px 0",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", "flex-direction": "column", "min-width": 0 }}>
|
||||
<div style={{ display: "flex", "flex-wrap": "wrap", "align-items": "center", gap: "12px" }}>
|
||||
<ProviderIcon id="synthetic" width={20} height={20} />
|
||||
<span style={{ "font-size": "14px", "font-weight": "500", color: "var(--vscode-foreground)" }}>
|
||||
{language.t("provider.custom.title")}
|
||||
</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
"font-size": "12px",
|
||||
color: "var(--text-weak-base, var(--vscode-descriptionForeground))",
|
||||
"padding-left": "32px",
|
||||
}}
|
||||
>
|
||||
{language.t("settings.providers.custom.description")}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
onClick={() => dialog.show(() => <CustomProviderDialog />)}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* View all providers link */}
|
||||
<div style={{ "margin-top": "16px" }}>
|
||||
<Button variant="ghost" onClick={() => dialog.show(() => <ProviderSelectDialog />)} style={{ padding: "0" }}>
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useVSCode } from "../../context/vscode"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useConfig } from "../../context/config"
|
||||
import { useSession } from "../../context/session"
|
||||
import ModelsTab from "./ModelsTab"
|
||||
import ProvidersTab from "./ProvidersTab"
|
||||
import AgentBehaviourTab from "./AgentBehaviourTab"
|
||||
import AutoApproveTab from "./AutoApproveTab"
|
||||
@@ -97,6 +98,10 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
style={{ flex: 1, overflow: "hidden" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="models">
|
||||
<Icon name="models" />
|
||||
<span class="label">{language.t("settings.models.title")}</span>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="providers">
|
||||
<Icon name="providers" />
|
||||
<span class="label">{language.t("settings.providers.title")}</span>
|
||||
@@ -155,6 +160,10 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="models">
|
||||
<h3>{language.t("settings.models.title")}</h3>
|
||||
<ModelsTab />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="providers">
|
||||
<h3>{language.t("settings.providers.title")}</h3>
|
||||
<ProvidersTab />
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import type { Provider } from "../../types/messages"
|
||||
import {
|
||||
KILO_PROVIDER_ID,
|
||||
PROVIDER_PRIORITY as POPULAR_PROVIDER_IDS,
|
||||
createKiloFallbackProvider,
|
||||
providerOrderIndex,
|
||||
} from "../../../../src/shared/provider-model"
|
||||
|
||||
export const CUSTOM_PROVIDER_ID = "_custom"
|
||||
export { POPULAR_PROVIDER_IDS }
|
||||
|
||||
const POPULAR_PROVIDER_SET = new Set<string>(POPULAR_PROVIDER_IDS)
|
||||
|
||||
export function isPopularProvider(providerID: string) {
|
||||
return POPULAR_PROVIDER_SET.has(providerID)
|
||||
}
|
||||
|
||||
export function popularProviderIndex(providerID: string) {
|
||||
return providerOrderIndex(providerID, POPULAR_PROVIDER_IDS)
|
||||
}
|
||||
|
||||
export function providerIcon(providerID: string): IconName {
|
||||
if (providerID === KILO_PROVIDER_ID) return "synthetic"
|
||||
if (iconNames.includes(providerID as IconName)) return providerID as IconName
|
||||
return "synthetic"
|
||||
}
|
||||
|
||||
export function kiloFallbackProvider(): Provider {
|
||||
return createKiloFallbackProvider()
|
||||
}
|
||||
|
||||
export function providerNoteKey(providerID: string) {
|
||||
if (providerID === "kilo") return "dialog.provider.kilo.note"
|
||||
if (providerID === "opencode") return "dialog.provider.opencode.note"
|
||||
if (providerID === "anthropic") return "dialog.provider.anthropic.note"
|
||||
if (providerID.startsWith("github-copilot")) return "dialog.provider.copilot.note"
|
||||
if (providerID === "openai") return "dialog.provider.openai.note"
|
||||
if (providerID === "google") return "dialog.provider.google.note"
|
||||
if (providerID === "openrouter") return "dialog.provider.openrouter.note"
|
||||
if (providerID === "vercel") return "dialog.provider.vercel.note"
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function sortProviders(items: Provider[]) {
|
||||
return items.slice().sort((a, b) => {
|
||||
const rank = popularProviderIndex(a.id) - popularProviderIndex(b.id)
|
||||
if (rank !== 0) return rank
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ProviderAuthState } from "../../types/messages"
|
||||
import { KILO_PROVIDER_ID } from "../../../../src/shared/provider-model"
|
||||
|
||||
export function visibleConnectedIds(connected: string[], authStates: Record<string, ProviderAuthState>) {
|
||||
return connected.filter((id) => id !== KILO_PROVIDER_ID || authStates[KILO_PROVIDER_ID] !== undefined)
|
||||
}
|
||||
@@ -65,6 +65,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
})
|
||||
|
||||
const hasProviders = () => visibleModels().length > 0
|
||||
const canOpen = () => hasProviders() || ((props.allowClear ?? false) && !!props.value)
|
||||
|
||||
// Debounce search input to avoid re-filtering on every keystroke
|
||||
createEffect(() => {
|
||||
@@ -217,6 +218,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
buildTriggerLabel(
|
||||
activeModel()?.name,
|
||||
activeModel()?.providerID,
|
||||
activeModel()?.providerName,
|
||||
props.value,
|
||||
props.allowClear ?? false,
|
||||
props.clearLabel ?? "",
|
||||
@@ -237,7 +239,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
triggerProps={{
|
||||
variant: "secondary",
|
||||
size: "normal",
|
||||
disabled: !hasProviders(),
|
||||
disabled: !canOpen(),
|
||||
title: activeModel()?.id,
|
||||
}}
|
||||
trigger={
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import type { ModelSelection } from "../../types/messages"
|
||||
import type { EnrichedModel } from "../../context/provider"
|
||||
import {
|
||||
KILO_PROVIDER_ID as KILO_GATEWAY_ID,
|
||||
PROVIDER_PRIORITY as PROVIDER_ORDER,
|
||||
providerOrderIndex,
|
||||
} from "../../../../src/shared/provider-model"
|
||||
|
||||
export { KILO_GATEWAY_ID, PROVIDER_ORDER }
|
||||
|
||||
export const KILO_GATEWAY_ID = "kilo"
|
||||
export const KILO_AUTO_SMALL_IDS = new Set(["kilo-auto/small", "auto-small"])
|
||||
|
||||
export function isSmall(model: Pick<EnrichedModel, "providerID" | "id">): boolean {
|
||||
return model.providerID === KILO_GATEWAY_ID && KILO_AUTO_SMALL_IDS.has(model.id)
|
||||
}
|
||||
|
||||
export const PROVIDER_ORDER = [KILO_GATEWAY_ID, "anthropic", "openai", "google"]
|
||||
|
||||
export function providerSortKey(providerID: string, order = PROVIDER_ORDER): number {
|
||||
const idx = order.indexOf(providerID.toLowerCase())
|
||||
return idx >= 0 ? idx : order.length
|
||||
export function providerSortKey(providerID: string, order: readonly string[] = PROVIDER_ORDER): number {
|
||||
return providerOrderIndex(providerID, order as typeof PROVIDER_ORDER)
|
||||
}
|
||||
|
||||
export function isFree(model: Pick<EnrichedModel, "isFree">): boolean {
|
||||
@@ -30,13 +33,18 @@ export function stripSubProviderPrefix(name: string): string {
|
||||
export function buildTriggerLabel(
|
||||
resolvedName: string | undefined,
|
||||
providerID: string | undefined,
|
||||
providerName: string | undefined,
|
||||
raw: ModelSelection | null,
|
||||
allowClear: boolean,
|
||||
clearLabel: string,
|
||||
hasProviders: boolean,
|
||||
labels: { select: string; noProviders: string; notSet: string },
|
||||
): string {
|
||||
if (resolvedName) return providerID === KILO_GATEWAY_ID ? stripSubProviderPrefix(resolvedName) : resolvedName
|
||||
if (resolvedName) {
|
||||
if (providerID === KILO_GATEWAY_ID) return stripSubProviderPrefix(resolvedName)
|
||||
if (providerName) return `${providerName} / ${resolvedName}`
|
||||
return resolvedName
|
||||
}
|
||||
if (raw?.providerID && raw?.modelID) {
|
||||
return raw.providerID === KILO_GATEWAY_ID ? raw.modelID : `${raw.providerID} / ${raw.modelID}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ModelSelection, Provider } from "../types/messages"
|
||||
import { isModelValid } from "./provider-utils"
|
||||
|
||||
function validate(
|
||||
providers: Record<string, Provider>,
|
||||
connected: string[],
|
||||
selection: ModelSelection | null | undefined,
|
||||
): ModelSelection | null {
|
||||
if (!selection) return null
|
||||
if (Object.keys(providers).length === 0) return selection
|
||||
return isModelValid(providers, connected, selection) ? selection : null
|
||||
}
|
||||
|
||||
function recent(
|
||||
providers: Record<string, Provider>,
|
||||
connected: string[],
|
||||
selections: ModelSelection[] | undefined,
|
||||
): ModelSelection | null {
|
||||
for (const item of selections ?? []) {
|
||||
const selection = validate(providers, connected, item)
|
||||
if (selection) return selection
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveModelSelection(input: {
|
||||
providers: Record<string, Provider>
|
||||
connected: string[]
|
||||
override?: ModelSelection | null
|
||||
mode?: ModelSelection | null
|
||||
global?: ModelSelection | null
|
||||
recent?: ModelSelection[]
|
||||
fallback?: ModelSelection | null
|
||||
}): ModelSelection | null {
|
||||
return (
|
||||
validate(input.providers, input.connected, input.override) ??
|
||||
validate(input.providers, input.connected, input.mode) ??
|
||||
validate(input.providers, input.connected, input.global) ??
|
||||
recent(input.providers, input.connected, input.recent) ??
|
||||
input.fallback ??
|
||||
null
|
||||
)
|
||||
}
|
||||
@@ -28,3 +28,19 @@ export function findModel(models: EnrichedModel[], selection: ModelSelection | n
|
||||
if (!selection) return undefined
|
||||
return models.find((m) => m.providerID === selection.providerID && m.id === selection.modelID)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the selection points to an existing model in a connected provider.
|
||||
* Kilo gateway models remain usable whenever the provider catalog exposes them.
|
||||
*/
|
||||
export function isModelValid(
|
||||
providers: Record<string, Provider>,
|
||||
connected: string[],
|
||||
selection: ModelSelection | null,
|
||||
): boolean {
|
||||
if (!selection) return false
|
||||
const provider = providers[selection.providerID]
|
||||
if (!provider) return false
|
||||
if (selection.providerID !== "kilo" && !connected.includes(selection.providerID)) return false
|
||||
return !!provider.models[selection.modelID]
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
|
||||
import { createContext, useContext, createSignal, createMemo, onCleanup, ParentComponent, Accessor } from "solid-js"
|
||||
import { useVSCode } from "./vscode"
|
||||
import type { Provider, ProviderModel, ModelSelection, ExtensionMessage } from "../types/messages"
|
||||
import { flattenModels, findModel as _findModel } from "./provider-utils"
|
||||
import type { Provider, ProviderModel, ModelSelection, ExtensionMessage, ProviderAuthState } from "../types/messages"
|
||||
import type { ProviderAuthMethod } from "@kilocode/sdk/v2/client"
|
||||
import { flattenModels, findModel as _findModel, isModelValid as isValid } from "./provider-utils"
|
||||
import { KILO_AUTO } from "../../../src/shared/provider-model"
|
||||
|
||||
export type EnrichedModel = ProviderModel & { providerID: string; providerName: string }
|
||||
|
||||
@@ -18,10 +20,11 @@ interface ProviderContextValue {
|
||||
defaultSelection: Accessor<ModelSelection>
|
||||
models: Accessor<EnrichedModel[]>
|
||||
findModel: (selection: ModelSelection | null) => EnrichedModel | undefined
|
||||
authMethods: Accessor<Record<string, ProviderAuthMethod[]>>
|
||||
authStates: Accessor<Record<string, ProviderAuthState>>
|
||||
isModelValid: (selection: ModelSelection | null) => boolean
|
||||
}
|
||||
|
||||
const KILO_AUTO: ModelSelection = { providerID: "kilo", modelID: "kilo-auto/free" }
|
||||
|
||||
export const ProviderContext = createContext<ProviderContextValue>()
|
||||
|
||||
export const ProviderProvider: ParentComponent = (props) => {
|
||||
@@ -31,6 +34,8 @@ export const ProviderProvider: ParentComponent = (props) => {
|
||||
const [connected, setConnected] = createSignal<string[]>([])
|
||||
const [defaults, setDefaults] = createSignal<Record<string, string>>({})
|
||||
const [defaultSelection, setDefaultSelection] = createSignal<ModelSelection>(KILO_AUTO)
|
||||
const [authMethods, setAuthMethods] = createSignal<Record<string, ProviderAuthMethod[]>>({})
|
||||
const [authStates, setAuthStates] = createSignal<Record<string, ProviderAuthState>>({})
|
||||
|
||||
const models = createMemo<EnrichedModel[]>(() => flattenModels(providers()))
|
||||
|
||||
@@ -38,6 +43,10 @@ export const ProviderProvider: ParentComponent = (props) => {
|
||||
return _findModel(models(), selection)
|
||||
}
|
||||
|
||||
function isModelValid(selection: ModelSelection | null): boolean {
|
||||
return isValid(providers(), connected(), selection)
|
||||
}
|
||||
|
||||
// Register handler immediately (not in onMount) so we never miss
|
||||
// a providersLoaded message that arrives before the DOM mount.
|
||||
const unsubscribe = vscode.onMessage((message: ExtensionMessage) => {
|
||||
@@ -49,6 +58,8 @@ export const ProviderProvider: ParentComponent = (props) => {
|
||||
setConnected(message.connected)
|
||||
setDefaults(message.defaults)
|
||||
setDefaultSelection(message.defaultSelection)
|
||||
setAuthMethods(message.authMethods)
|
||||
setAuthStates(message.authStates)
|
||||
})
|
||||
|
||||
onCleanup(unsubscribe)
|
||||
@@ -80,6 +91,9 @@ export const ProviderProvider: ParentComponent = (props) => {
|
||||
defaultSelection,
|
||||
models,
|
||||
findModel,
|
||||
authMethods,
|
||||
authStates,
|
||||
isModelValid,
|
||||
}
|
||||
|
||||
return <ProviderContext.Provider value={value}>{props.children}</ProviderContext.Provider>
|
||||
|
||||
@@ -126,6 +126,10 @@ export const ServerProvider: ParentComponent = (props) => {
|
||||
})
|
||||
|
||||
const startLogin = () => {
|
||||
const status = deviceAuth().status
|
||||
if (status === "initiating" || status === "pending") {
|
||||
return
|
||||
}
|
||||
setDeviceAuth({ status: "initiating" })
|
||||
vscode.postMessage({ type: "login" })
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ import type {
|
||||
import { removeSessionPermissions, upsertPermission } from "./permission-queue"
|
||||
import { computeStatus, calcTotalCost, calcContextUsage } from "./session-utils"
|
||||
import { Identifier } from "../utils/id"
|
||||
import { resolveModelSelection } from "./model-selection"
|
||||
import { KILO_AUTO, parseModelString } from "../../../src/shared/provider-model"
|
||||
|
||||
const RECENT_LIMIT = 5
|
||||
|
||||
// Store structure for messages and parts
|
||||
interface SessionStore {
|
||||
@@ -51,10 +55,11 @@ interface SessionStore {
|
||||
messages: Record<string, Message[]> // sessionID -> messages
|
||||
parts: Record<string, Part[]> // messageID -> parts
|
||||
todos: Record<string, TodoItem[]> // sessionID -> todos
|
||||
modelSelections: Record<string, ModelSelection> // agentName -> model (global, extension-lifetime)
|
||||
modelSelections: Record<string, ModelSelection | null> // agentName -> model (global, extension-lifetime)
|
||||
sessionOverrides: Record<string, ModelSelection> // sessionID -> per-session model override (compare mode)
|
||||
agentSelections: Record<string, string> // sessionID -> agent name
|
||||
variantSelections: Record<string, string> // "providerID/modelID" -> variant name
|
||||
recentModels: ModelSelection[]
|
||||
}
|
||||
|
||||
interface SessionContextValue {
|
||||
@@ -272,6 +277,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
sessionOverrides: {},
|
||||
agentSelections: {},
|
||||
variantSelections: {},
|
||||
recentModels: [],
|
||||
})
|
||||
|
||||
// Per-session agent selection
|
||||
@@ -283,34 +289,35 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return pendingAgentSelection() ?? defaultAgent()
|
||||
})
|
||||
|
||||
/** Parse a "provider/model" config string into a ModelSelection (or null). */
|
||||
function parseModel(raw: string | undefined | null): ModelSelection | null {
|
||||
if (!raw) return null
|
||||
const slash = raw.indexOf("/")
|
||||
if (slash <= 0) return null
|
||||
return { providerID: raw.slice(0, slash), modelID: raw.slice(slash + 1) }
|
||||
}
|
||||
|
||||
/** Per-mode model from config (e.g. config.agent.code.model). */
|
||||
function getModeModel(agentName: string): ModelSelection | null {
|
||||
return parseModel(config().agent?.[agentName]?.model)
|
||||
return parseModelString(config().agent?.[agentName]?.model)
|
||||
}
|
||||
|
||||
/** Global default model from config (config.model). */
|
||||
function getGlobalModel(): ModelSelection | null {
|
||||
return parseModel(config().model)
|
||||
return parseModelString(config().model)
|
||||
}
|
||||
|
||||
function resolveModel(agentName: string, override?: ModelSelection | null): ModelSelection | null {
|
||||
return resolveModelSelection({
|
||||
providers: provider.providers(),
|
||||
connected: provider.connected(),
|
||||
override,
|
||||
mode: getModeModel(agentName),
|
||||
global: getGlobalModel(),
|
||||
recent: store.recentModels,
|
||||
fallback: KILO_AUTO,
|
||||
})
|
||||
}
|
||||
|
||||
// Keep model selection in sync with provider/mode default until the user
|
||||
// explicitly overrides it.
|
||||
createEffect(() => {
|
||||
const def = provider.defaultSelection()
|
||||
const agentName = selectedAgentName()
|
||||
if (userSetAgents()[agentName]) return
|
||||
|
||||
// Per-mode config > global config model > VS Code default selection
|
||||
const sel = getModeModel(agentName) ?? getGlobalModel() ?? def
|
||||
if (sel) setStore("modelSelections", agentName, sel)
|
||||
const sel = resolveModel(agentName)
|
||||
setStore("modelSelections", agentName, sel)
|
||||
})
|
||||
|
||||
// Global model selection per agent/mode
|
||||
@@ -322,23 +329,33 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
if (session) return session
|
||||
}
|
||||
const agentName = selectedAgentName()
|
||||
const override = store.modelSelections[agentName]
|
||||
if (override) return override
|
||||
return getModeModel(agentName) ?? getGlobalModel() ?? provider.defaultSelection()
|
||||
return resolveModel(agentName, store.modelSelections[agentName])
|
||||
})
|
||||
function selectModel(providerID: string, modelID: string) {
|
||||
const agentName = selectedAgentName()
|
||||
|
||||
function pushRecent(selection: ModelSelection) {
|
||||
const key = `${selection.providerID}/${selection.modelID}`
|
||||
const filtered = store.recentModels.filter((r) => `${r.providerID}/${r.modelID}` !== key)
|
||||
const updated = [selection, ...filtered].slice(0, RECENT_LIMIT)
|
||||
setStore("recentModels", updated)
|
||||
vscode.postMessage({ type: "persistRecents", recents: updated })
|
||||
}
|
||||
|
||||
function applyModel(agentName: string, selection: ModelSelection) {
|
||||
setUserSetAgents((prev) => ({ ...prev, [agentName]: true }))
|
||||
setStore("modelSelections", agentName, { providerID, modelID })
|
||||
// Update per-session override so compare-mode sessions stay independent
|
||||
setStore("modelSelections", agentName, selection)
|
||||
pushRecent(selection)
|
||||
const sid = currentSessionID()
|
||||
if (sid) setStore("sessionOverrides", sid, { providerID, modelID })
|
||||
if (sid) setStore("sessionOverrides", sid, selection)
|
||||
}
|
||||
|
||||
function selectModel(providerID: string, modelID: string) {
|
||||
applyModel(selectedAgentName(), { providerID, modelID })
|
||||
}
|
||||
|
||||
/** The config/default model for the current mode (what settings says). */
|
||||
const configModel = createMemo<ModelSelection | null>(() => {
|
||||
const agentName = selectedAgentName()
|
||||
return getModeModel(agentName) ?? getGlobalModel() ?? provider.defaultSelection()
|
||||
return resolveModel(agentName)
|
||||
})
|
||||
|
||||
/** True when the active model differs from what the config dictates. */
|
||||
@@ -470,6 +487,14 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
onCleanup(unsubVariants)
|
||||
|
||||
// Load persisted recent models from extension globalState
|
||||
const unsubRecents = vscode.onMessage((message: ExtensionMessage) => {
|
||||
if (message.type !== "recentsLoaded") return
|
||||
setStore("recentModels", message.recents)
|
||||
})
|
||||
vscode.postMessage({ type: "requestRecents" })
|
||||
onCleanup(unsubRecents)
|
||||
|
||||
// Handle messages from extension
|
||||
onMount(() => {
|
||||
const unsubscribe = vscode.onMessage((message: ExtensionMessage) => {
|
||||
@@ -1113,9 +1138,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
// When switching mode, initialize model for the new mode if the user
|
||||
// hasn't explicitly set one for it
|
||||
if (!userSetAgents()[name] && !store.modelSelections[name]) {
|
||||
const modeModel = getModeModel(name)
|
||||
const sel = modeModel ?? provider.defaultSelection()
|
||||
if (sel) setStore("modelSelections", name, sel)
|
||||
setStore("modelSelections", name, resolveModel(name))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1559,7 +1582,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
const override = store.sessionOverrides[sessionID]
|
||||
if (override) return override
|
||||
const agentName = store.agentSelections[sessionID] ?? defaultAgent()
|
||||
return store.modelSelections[agentName] ?? provider.defaultSelection()
|
||||
return resolveModel(agentName, store.modelSelections[agentName])
|
||||
},
|
||||
setSessionModel: (sessionID: string, providerID: string, modelID: string) => {
|
||||
// Only write per-session override — do NOT touch global modelSelections or
|
||||
|
||||
@@ -551,6 +551,8 @@ export const dict = {
|
||||
"terminal.connectionLost.description": "انقطع اتصال المحطة الطرفية. يمكن أن يحدث هذا عند إعادة تشغيل الخادم.",
|
||||
|
||||
"common.closeTab": "إغلاق علامة التبويب",
|
||||
"common.signIn": "تسجيل الدخول",
|
||||
"common.signOut": "تسجيل الخروج",
|
||||
"common.dismiss": "رفض",
|
||||
"common.requestFailed": "فشل الطلب",
|
||||
"common.moreOptions": "مزيد من الخيارات",
|
||||
@@ -704,13 +706,56 @@ export const dict = {
|
||||
"settings.providers.section.connected": "الموفرون المتصلون",
|
||||
"settings.providers.connected.empty": "لا يوجد موفرون متصلون",
|
||||
"settings.providers.section.popular": "الموفرون الشائعون",
|
||||
"settings.providers.search.placeholder": "البحث عن موفرين",
|
||||
"settings.providers.select.placeholder": "اختر موفرًا...",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "البيئة",
|
||||
"settings.providers.tag.config": "التكوين",
|
||||
"settings.providers.tag.custom": "مخصص",
|
||||
"settings.providers.tag.other": "أخرى",
|
||||
"settings.providers.tag.customProvider": "مزود مخصص",
|
||||
"settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك",
|
||||
"settings.providers.custom.description": "أضف مزوداً متوافقاً مع OpenAI عبر عنوان URL الأساسي.",
|
||||
"settings.providers.modeModels": "نموذج لكل وضع",
|
||||
"settings.providers.custom.note": "أضف موفرًا متوافقًا مع OpenAI عبر عنوان URL الأساسي.",
|
||||
"settings.providers.modeModels.description":
|
||||
"تجاوز النموذج الافتراضي لأوضاع محددة. إذا لم يتم التعيين، يتم استخدام النموذج الافتراضي العام.",
|
||||
"provider.custom.title": "مزود مخصص",
|
||||
"provider.custom.description.prefix": "قم بتكوين مزود متوافق مع OpenAI. انظر ",
|
||||
"provider.custom.description.link": "وثائق تكوين المزود",
|
||||
"provider.custom.description.suffix": ".",
|
||||
"provider.custom.field.providerID.label": "معرف المزود",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "أحرف صغيرة وأرقام وواصلات أو شرطات سفلية",
|
||||
"provider.custom.field.name.label": "الاسم المعروض",
|
||||
"provider.custom.field.name.placeholder": "مزود الذكاء الاصطناعي",
|
||||
"provider.custom.field.baseURL.label": "عنوان URL الأساسي",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "مفتاح API",
|
||||
"provider.custom.field.apiKey.placeholder": "مفتاح API",
|
||||
"provider.custom.field.apiKey.description": "اختياري. اتركه فارغاً إذا كنت تدير المصادقة عبر الرؤوس.",
|
||||
"provider.custom.models.label": "النماذج",
|
||||
"provider.custom.models.id.label": "المعرف",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "الاسم",
|
||||
"provider.custom.models.name.placeholder": "الاسم المعروض",
|
||||
"provider.custom.models.remove": "إزالة النموذج",
|
||||
"provider.custom.models.add": "إضافة نموذج",
|
||||
"provider.custom.headers.label": "الرؤوس (اختياري)",
|
||||
"provider.custom.headers.key.label": "الرأس",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "القيمة",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "إزالة الرأس",
|
||||
"provider.custom.headers.add": "إضافة رأس",
|
||||
"provider.custom.error.providerID.required": "معرف المزود مطلوب",
|
||||
"provider.custom.error.providerID.format": "استخدم أحرفاً صغيرة وأرقاماً وواصلات أو شرطات سفلية",
|
||||
"provider.custom.error.providerID.exists": "معرف المزود هذا موجود بالفعل",
|
||||
"provider.custom.error.name.required": "الاسم المعروض مطلوب",
|
||||
"provider.custom.error.baseURL.required": "عنوان URL الأساسي مطلوب",
|
||||
"provider.custom.error.baseURL.format": "يجب أن يبدأ بـ http:// أو https://",
|
||||
"provider.custom.error.required": "مطلوب",
|
||||
"provider.custom.error.duplicate": "مكرر",
|
||||
"settings.models.title": "النماذج",
|
||||
"settings.models.description": "ستكون إعدادات النموذج قابلة للتكوين هنا.",
|
||||
"settings.agents.title": "الوكلاء",
|
||||
@@ -813,6 +858,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "تم نسخ الرابط إلى الحافظة",
|
||||
"deviceAuth.toast.codeCopied": "تم نسخ الرمز إلى الحافظة",
|
||||
"deviceAuth.toast.errorCopied": "تم نسخ الخطأ إلى الحافظة",
|
||||
"deviceAuth.status.initiating": "جارٍ بدء تسجيل الدخول...",
|
||||
"deviceAuth.title": "تسجيل الدخول إلى Kilo Code",
|
||||
"deviceAuth.step1": "الخطوة 1: افتح هذا الرابط",
|
||||
@@ -824,8 +870,11 @@ export const dict = {
|
||||
"deviceAuth.status.waiting": "في انتظار التفويض...",
|
||||
"deviceAuth.status.success": "تم تسجيل الدخول بنجاح!",
|
||||
"deviceAuth.status.failed": "فشل تسجيل الدخول",
|
||||
"deviceAuth.error.detailsTitle": "تفاصيل خطأ تسجيل الدخول",
|
||||
"deviceAuth.status.cancelled": "تم إلغاء تسجيل الدخول",
|
||||
"deviceAuth.action.tryAgain": "حاول مرة أخرى",
|
||||
"deviceAuth.action.copyError": "نسخ الخطأ",
|
||||
"deviceAuth.action.showDetails": "عرض التفاصيل",
|
||||
|
||||
"common.retry": "إعادة المحاولة",
|
||||
"common.refresh": "تحديث",
|
||||
|
||||
@@ -556,6 +556,8 @@ export const dict = {
|
||||
"A conexão do terminal foi interrompida. Isso pode acontecer quando o servidor reinicia.",
|
||||
|
||||
"common.closeTab": "Fechar aba",
|
||||
"common.signIn": "Entrar",
|
||||
"common.signOut": "Sair",
|
||||
"common.dismiss": "Descartar",
|
||||
"common.requestFailed": "Requisição falhou",
|
||||
"common.moreOptions": "Mais opções",
|
||||
@@ -711,13 +713,56 @@ export const dict = {
|
||||
"settings.providers.section.connected": "Provedores conectados",
|
||||
"settings.providers.connected.empty": "Nenhum provedor conectado",
|
||||
"settings.providers.section.popular": "Provedores populares",
|
||||
"settings.providers.search.placeholder": "Buscar provedores",
|
||||
"settings.providers.select.placeholder": "Selecionar provedor...",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "Ambiente",
|
||||
"settings.providers.tag.config": "Configuração",
|
||||
"settings.providers.tag.custom": "Personalizado",
|
||||
"settings.providers.tag.other": "Outro",
|
||||
"settings.providers.tag.customProvider": "Provedor personalizado",
|
||||
"settings.providers.connected.environmentDescription": "Conectado a partir das suas variáveis de ambiente",
|
||||
"settings.providers.custom.description": "Adicione um provedor compatível com OpenAI pela URL base.",
|
||||
"settings.providers.modeModels": "Modelo por Modo",
|
||||
"settings.providers.custom.note": "Adicione um provedor compatível com OpenAI por URL base.",
|
||||
"settings.providers.modeModels.description":
|
||||
"Substitua o modelo padrão para modos específicos. Se não definido, o modelo padrão global é usado.",
|
||||
"provider.custom.title": "Provedor personalizado",
|
||||
"provider.custom.description.prefix": "Configure um provedor compatível com OpenAI. Veja a ",
|
||||
"provider.custom.description.link": "documentação de configuração de provedores",
|
||||
"provider.custom.description.suffix": ".",
|
||||
"provider.custom.field.providerID.label": "ID do provedor",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "Letras minúsculas, números, hifens ou underscores",
|
||||
"provider.custom.field.name.label": "Nome de exibição",
|
||||
"provider.custom.field.name.placeholder": "Meu Provedor de IA",
|
||||
"provider.custom.field.baseURL.label": "URL base",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "Chave de API",
|
||||
"provider.custom.field.apiKey.placeholder": "Chave de API",
|
||||
"provider.custom.field.apiKey.description": "Opcional. Deixe vazio se você gerencia autenticação via headers.",
|
||||
"provider.custom.models.label": "Modelos",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "Nome",
|
||||
"provider.custom.models.name.placeholder": "Nome de Exibição",
|
||||
"provider.custom.models.remove": "Remover modelo",
|
||||
"provider.custom.models.add": "Adicionar modelo",
|
||||
"provider.custom.headers.label": "Headers (opcional)",
|
||||
"provider.custom.headers.key.label": "Header",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "Valor",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "Remover header",
|
||||
"provider.custom.headers.add": "Adicionar header",
|
||||
"provider.custom.error.providerID.required": "ID do provedor é obrigatório",
|
||||
"provider.custom.error.providerID.format": "Use letras minúsculas, números, hifens ou underscores",
|
||||
"provider.custom.error.providerID.exists": "Esse ID de provedor já existe",
|
||||
"provider.custom.error.name.required": "Nome de exibição é obrigatório",
|
||||
"provider.custom.error.baseURL.required": "URL base é obrigatória",
|
||||
"provider.custom.error.baseURL.format": "Deve começar com http:// ou https://",
|
||||
"provider.custom.error.required": "Obrigatório",
|
||||
"provider.custom.error.duplicate": "Duplicado",
|
||||
"settings.models.title": "Modelos",
|
||||
"settings.models.description": "Configurações de modelos estarão disponíveis aqui.",
|
||||
"settings.agents.title": "Agentes",
|
||||
@@ -821,6 +866,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "URL copiada para a área de transferência",
|
||||
"deviceAuth.toast.codeCopied": "Código copiado para a área de transferência",
|
||||
"deviceAuth.toast.errorCopied": "Erro copiado para a área de transferência",
|
||||
"deviceAuth.status.initiating": "Iniciando login...",
|
||||
"deviceAuth.title": "Entrar no Kilo Code",
|
||||
"deviceAuth.step1": "Passo 1: Abra esta URL",
|
||||
@@ -832,8 +878,11 @@ export const dict = {
|
||||
"deviceAuth.status.waiting": "Aguardando autorização...",
|
||||
"deviceAuth.status.success": "Login realizado com sucesso!",
|
||||
"deviceAuth.status.failed": "Falha no login",
|
||||
"deviceAuth.error.detailsTitle": "Detalhes do erro de login",
|
||||
"deviceAuth.status.cancelled": "Login cancelado",
|
||||
"deviceAuth.action.tryAgain": "Tentar Novamente",
|
||||
"deviceAuth.action.copyError": "Copiar erro",
|
||||
"deviceAuth.action.showDetails": "Ver detalhes",
|
||||
|
||||
"common.retry": "Tentar novamente",
|
||||
"common.refresh": "Atualizar",
|
||||
|
||||
@@ -560,6 +560,8 @@ export const dict = {
|
||||
"Veza s terminalom je prekinuta. Ovo se može desiti kada se server restartuje.",
|
||||
|
||||
"common.closeTab": "Zatvori karticu",
|
||||
"common.signIn": "Prijava",
|
||||
"common.signOut": "Odjava",
|
||||
"common.dismiss": "Odbaci",
|
||||
"common.requestFailed": "Zahtjev nije uspio",
|
||||
"common.moreOptions": "Više opcija",
|
||||
@@ -715,13 +717,57 @@ export const dict = {
|
||||
"settings.providers.section.connected": "Povezani provajderi",
|
||||
"settings.providers.connected.empty": "Nema povezanih provajdera",
|
||||
"settings.providers.section.popular": "Popularni provajderi",
|
||||
"settings.providers.search.placeholder": "Pretraži provajdere",
|
||||
"settings.providers.select.placeholder": "Odaberi provajdera...",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "Okruženje",
|
||||
"settings.providers.tag.config": "Konfiguracija",
|
||||
"settings.providers.tag.custom": "Prilagođeno",
|
||||
"settings.providers.tag.other": "Ostalo",
|
||||
"settings.providers.tag.customProvider": "Prilagođeni provajder",
|
||||
"settings.providers.connected.environmentDescription": "Povezano iz vaših varijabli okruženja",
|
||||
"settings.providers.custom.description": "Dodaj OpenAI-kompatibilan provajder putem osnovnog URL-a.",
|
||||
"settings.providers.modeModels": "Model po režimu",
|
||||
"settings.providers.custom.note": "Dodajte provajdera kompatibilnog s OpenAI putem osnovnog URL-a.",
|
||||
"settings.providers.modeModels.description":
|
||||
"Zamijenite podrazumijevani model za određene režime. Ako nije postavljeno, koristi se globalni podrazumijevani model.",
|
||||
"provider.custom.title": "Prilagođeni provajder",
|
||||
"provider.custom.description.prefix": "Konfiguriši OpenAI-kompatibilan provajder. Pogledaj ",
|
||||
"provider.custom.description.link": "dokumentaciju za konfiguraciju provajdera",
|
||||
"provider.custom.description.suffix": ".",
|
||||
"provider.custom.field.providerID.label": "ID provajdera",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "Mala slova, brojevi, crtice ili donje crte",
|
||||
"provider.custom.field.name.label": "Naziv za prikaz",
|
||||
"provider.custom.field.name.placeholder": "Moj AI provajder",
|
||||
"provider.custom.field.baseURL.label": "Osnovni URL",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "API ključ",
|
||||
"provider.custom.field.apiKey.placeholder": "API ključ",
|
||||
"provider.custom.field.apiKey.description":
|
||||
"Opcionalno. Ostavi prazno ako upravljaš autentifikacijom putem zaglavlja.",
|
||||
"provider.custom.models.label": "Modeli",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "Naziv",
|
||||
"provider.custom.models.name.placeholder": "Naziv za prikaz",
|
||||
"provider.custom.models.remove": "Ukloni model",
|
||||
"provider.custom.models.add": "Dodaj model",
|
||||
"provider.custom.headers.label": "Zaglavlja (opcionalno)",
|
||||
"provider.custom.headers.key.label": "Zaglavlje",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "Vrijednost",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "Ukloni zaglavlje",
|
||||
"provider.custom.headers.add": "Dodaj zaglavlje",
|
||||
"provider.custom.error.providerID.required": "ID provajdera je obavezan",
|
||||
"provider.custom.error.providerID.format": "Koristi mala slova, brojeve, crtice ili donje crte",
|
||||
"provider.custom.error.providerID.exists": "Taj ID provajdera već postoji",
|
||||
"provider.custom.error.name.required": "Naziv za prikaz je obavezan",
|
||||
"provider.custom.error.baseURL.required": "Osnovni URL je obavezan",
|
||||
"provider.custom.error.baseURL.format": "Mora početi sa http:// ili https://",
|
||||
"provider.custom.error.required": "Obavezno",
|
||||
"provider.custom.error.duplicate": "Duplikat",
|
||||
"settings.models.title": "Modeli",
|
||||
"settings.models.description": "Postavke modela će se ovdje moći podešavati.",
|
||||
"settings.agents.title": "Agenti",
|
||||
@@ -824,6 +870,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "URL kopiran u međuspremnik",
|
||||
"deviceAuth.toast.codeCopied": "Kod kopiran u međuspremnik",
|
||||
"deviceAuth.toast.errorCopied": "Greška kopirana u međuspremnik",
|
||||
"deviceAuth.status.initiating": "Pokretanje prijave...",
|
||||
"deviceAuth.title": "Prijavite se u Kilo Code",
|
||||
"deviceAuth.step1": "Korak 1: Otvorite ovaj URL",
|
||||
@@ -835,8 +882,11 @@ export const dict = {
|
||||
"deviceAuth.status.waiting": "Čekanje na autorizaciju...",
|
||||
"deviceAuth.status.success": "Prijava uspješna!",
|
||||
"deviceAuth.status.failed": "Prijava neuspješna",
|
||||
"deviceAuth.error.detailsTitle": "Detalji greške pri prijavi",
|
||||
"deviceAuth.status.cancelled": "Prijava otkazana",
|
||||
"deviceAuth.action.tryAgain": "Pokušajte ponovo",
|
||||
"deviceAuth.action.copyError": "Kopiraj grešku",
|
||||
"deviceAuth.action.showDetails": "Prikaži detalje",
|
||||
|
||||
"common.retry": "Pokušaj ponovo",
|
||||
"common.refresh": "Osvježi",
|
||||
|
||||
@@ -556,6 +556,8 @@ export const dict = {
|
||||
"terminal.connectionLost.title": "Forbindelse mistet",
|
||||
"terminal.connectionLost.description": "Terminalforbindelsen blev afbrudt. Dette kan ske, når serveren genstarter.",
|
||||
"common.closeTab": "Luk fane",
|
||||
"common.signIn": "Log ind",
|
||||
"common.signOut": "Log ud",
|
||||
"common.dismiss": "Afvis",
|
||||
"common.requestFailed": "Forespørgsel mislykkedes",
|
||||
"common.moreOptions": "Flere muligheder",
|
||||
@@ -710,13 +712,56 @@ export const dict = {
|
||||
"settings.providers.section.connected": "Forbundne udbydere",
|
||||
"settings.providers.connected.empty": "Ingen forbundne udbydere",
|
||||
"settings.providers.section.popular": "Populære udbydere",
|
||||
"settings.providers.search.placeholder": "Søg udbydere",
|
||||
"settings.providers.select.placeholder": "Vælg udbyder...",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "Miljø",
|
||||
"settings.providers.tag.config": "Konfiguration",
|
||||
"settings.providers.tag.custom": "Brugerdefineret",
|
||||
"settings.providers.tag.other": "Andet",
|
||||
"settings.providers.tag.customProvider": "Brugerdefineret udbyder",
|
||||
"settings.providers.connected.environmentDescription": "Forbundet fra dine miljøvariabler",
|
||||
"settings.providers.custom.description": "Tilføj en OpenAI-kompatibel udbyder via basis-URL.",
|
||||
"settings.providers.modeModels": "Model pr. tilstand",
|
||||
"settings.providers.custom.note": "Tilføj en OpenAI-kompatibel udbyder via basis-URL.",
|
||||
"settings.providers.modeModels.description":
|
||||
"Tilsidesæt standardmodellen for bestemte tilstande. Hvis ikke angivet, bruges den globale standardmodel.",
|
||||
"provider.custom.title": "Brugerdefineret udbyder",
|
||||
"provider.custom.description.prefix": "Konfigurer en OpenAI-kompatibel udbyder. Se ",
|
||||
"provider.custom.description.link": "dokumentation for udbyderkonfiguration",
|
||||
"provider.custom.description.suffix": ".",
|
||||
"provider.custom.field.providerID.label": "Udbyder-ID",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "Små bogstaver, tal, bindestreger eller understregninger",
|
||||
"provider.custom.field.name.label": "Visningsnavn",
|
||||
"provider.custom.field.name.placeholder": "Min AI-udbyder",
|
||||
"provider.custom.field.baseURL.label": "Basis-URL",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "API-nøgle",
|
||||
"provider.custom.field.apiKey.placeholder": "API-nøgle",
|
||||
"provider.custom.field.apiKey.description": "Valgfrit. Lad stå tom, hvis du administrerer godkendelse via headers.",
|
||||
"provider.custom.models.label": "Modeller",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "Navn",
|
||||
"provider.custom.models.name.placeholder": "Visningsnavn",
|
||||
"provider.custom.models.remove": "Fjern model",
|
||||
"provider.custom.models.add": "Tilføj model",
|
||||
"provider.custom.headers.label": "Headers (valgfrit)",
|
||||
"provider.custom.headers.key.label": "Header",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "Værdi",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "Fjern header",
|
||||
"provider.custom.headers.add": "Tilføj header",
|
||||
"provider.custom.error.providerID.required": "Udbyder-ID er påkrævet",
|
||||
"provider.custom.error.providerID.format": "Brug små bogstaver, tal, bindestreger eller understregninger",
|
||||
"provider.custom.error.providerID.exists": "Det udbyder-ID eksisterer allerede",
|
||||
"provider.custom.error.name.required": "Visningsnavn er påkrævet",
|
||||
"provider.custom.error.baseURL.required": "Basis-URL er påkrævet",
|
||||
"provider.custom.error.baseURL.format": "Skal starte med http:// eller https://",
|
||||
"provider.custom.error.required": "Påkrævet",
|
||||
"provider.custom.error.duplicate": "Duplikat",
|
||||
"settings.models.title": "Modeller",
|
||||
"settings.models.description": "Modelindstillinger vil kunne konfigureres her.",
|
||||
"settings.agents.title": "Agenter",
|
||||
@@ -819,6 +864,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "URL kopieret til udklipsholder",
|
||||
"deviceAuth.toast.codeCopied": "Kode kopieret til udklipsholder",
|
||||
"deviceAuth.toast.errorCopied": "Fejl kopieret til udklipsholder",
|
||||
"deviceAuth.status.initiating": "Starter login...",
|
||||
"deviceAuth.title": "Log ind på Kilo Code",
|
||||
"deviceAuth.step1": "Trin 1: Åbn denne URL",
|
||||
@@ -830,8 +876,11 @@ export const dict = {
|
||||
"deviceAuth.status.waiting": "Venter på godkendelse...",
|
||||
"deviceAuth.status.success": "Login vellykket!",
|
||||
"deviceAuth.status.failed": "Login mislykkedes",
|
||||
"deviceAuth.error.detailsTitle": "Login-fejldetaljer",
|
||||
"deviceAuth.status.cancelled": "Login annulleret",
|
||||
"deviceAuth.action.tryAgain": "Prøv igen",
|
||||
"deviceAuth.action.copyError": "Kopiér fejl",
|
||||
"deviceAuth.action.showDetails": "Vis detaljer",
|
||||
|
||||
"common.retry": "Prøv igen",
|
||||
"common.refresh": "Opdatér",
|
||||
|
||||
@@ -564,6 +564,8 @@ export const dict = {
|
||||
"terminal.connectionLost.description":
|
||||
"Die Terminalverbindung wurde unterbrochen. Das kann passieren, wenn der Server neu startet.",
|
||||
"common.closeTab": "Tab schließen",
|
||||
"common.signIn": "Anmelden",
|
||||
"common.signOut": "Abmelden",
|
||||
"common.dismiss": "Verwerfen",
|
||||
"common.requestFailed": "Anfrage fehlgeschlagen",
|
||||
"common.moreOptions": "Weitere Optionen",
|
||||
@@ -720,13 +722,57 @@ export const dict = {
|
||||
"settings.providers.section.connected": "Verbundene Anbieter",
|
||||
"settings.providers.connected.empty": "Keine verbundenen Anbieter",
|
||||
"settings.providers.section.popular": "Beliebte Anbieter",
|
||||
"settings.providers.search.placeholder": "Anbieter durchsuchen",
|
||||
"settings.providers.select.placeholder": "Anbieter auswählen...",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "Umgebung",
|
||||
"settings.providers.tag.config": "Konfiguration",
|
||||
"settings.providers.tag.custom": "Benutzerdefiniert",
|
||||
"settings.providers.tag.other": "Andere",
|
||||
"settings.providers.tag.customProvider": "Benutzerdefinierter Anbieter",
|
||||
"settings.providers.connected.environmentDescription": "Verbunden über Ihre Umgebungsvariablen",
|
||||
"settings.providers.custom.description": "Fügen Sie einen OpenAI-kompatiblen Anbieter über die Basis-URL hinzu.",
|
||||
"settings.providers.modeModels": "Modell pro Modus",
|
||||
"settings.providers.custom.note": "Fügen Sie einen OpenAI-kompatiblen Anbieter per Basis-URL hinzu.",
|
||||
"settings.providers.modeModels.description":
|
||||
"Überschreiben Sie das Standardmodell für bestimmte Modi. Wenn nicht festgelegt, wird das globale Standardmodell verwendet.",
|
||||
"provider.custom.title": "Benutzerdefinierter Anbieter",
|
||||
"provider.custom.description.prefix": "Konfigurieren Sie einen OpenAI-kompatiblen Anbieter. Siehe die ",
|
||||
"provider.custom.description.link": "Anbieterkonfigurationsdokumentation",
|
||||
"provider.custom.description.suffix": ".",
|
||||
"provider.custom.field.providerID.label": "Anbieter-ID",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "Kleinbuchstaben, Zahlen, Bindestriche oder Unterstriche",
|
||||
"provider.custom.field.name.label": "Anzeigename",
|
||||
"provider.custom.field.name.placeholder": "Mein KI-Anbieter",
|
||||
"provider.custom.field.baseURL.label": "Basis-URL",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "API-Schlüssel",
|
||||
"provider.custom.field.apiKey.placeholder": "API-Schlüssel",
|
||||
"provider.custom.field.apiKey.description":
|
||||
"Optional. Leer lassen, wenn Sie Authentifizierung über Header verwalten.",
|
||||
"provider.custom.models.label": "Modelle",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "Name",
|
||||
"provider.custom.models.name.placeholder": "Anzeigename",
|
||||
"provider.custom.models.remove": "Modell entfernen",
|
||||
"provider.custom.models.add": "Modell hinzufügen",
|
||||
"provider.custom.headers.label": "Header (optional)",
|
||||
"provider.custom.headers.key.label": "Header",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "Wert",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "Header entfernen",
|
||||
"provider.custom.headers.add": "Header hinzufügen",
|
||||
"provider.custom.error.providerID.required": "Anbieter-ID ist erforderlich",
|
||||
"provider.custom.error.providerID.format": "Verwenden Sie Kleinbuchstaben, Zahlen, Bindestriche oder Unterstriche",
|
||||
"provider.custom.error.providerID.exists": "Diese Anbieter-ID existiert bereits",
|
||||
"provider.custom.error.name.required": "Anzeigename ist erforderlich",
|
||||
"provider.custom.error.baseURL.required": "Basis-URL ist erforderlich",
|
||||
"provider.custom.error.baseURL.format": "Muss mit http:// oder https:// beginnen",
|
||||
"provider.custom.error.required": "Erforderlich",
|
||||
"provider.custom.error.duplicate": "Duplikat",
|
||||
"settings.models.title": "Modelle",
|
||||
"settings.models.description": "Modelleinstellungen können hier konfiguriert werden.",
|
||||
"settings.agents.title": "Agenten",
|
||||
@@ -829,6 +875,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "URL in die Zwischenablage kopiert",
|
||||
"deviceAuth.toast.codeCopied": "Code in die Zwischenablage kopiert",
|
||||
"deviceAuth.toast.errorCopied": "Fehler in die Zwischenablage kopiert",
|
||||
"deviceAuth.status.initiating": "Anmeldung wird gestartet...",
|
||||
"deviceAuth.title": "Bei Kilo Code anmelden",
|
||||
"deviceAuth.step1": "Schritt 1: Diese URL öffnen",
|
||||
@@ -840,8 +887,11 @@ export const dict = {
|
||||
"deviceAuth.status.waiting": "Warten auf Autorisierung...",
|
||||
"deviceAuth.status.success": "Anmeldung erfolgreich!",
|
||||
"deviceAuth.status.failed": "Anmeldung fehlgeschlagen",
|
||||
"deviceAuth.error.detailsTitle": "Anmeldefehler-Details",
|
||||
"deviceAuth.status.cancelled": "Anmeldung abgebrochen",
|
||||
"deviceAuth.action.tryAgain": "Erneut versuchen",
|
||||
"deviceAuth.action.copyError": "Fehler kopieren",
|
||||
"deviceAuth.action.showDetails": "Details anzeigen",
|
||||
|
||||
"common.retry": "Erneut versuchen",
|
||||
"common.refresh": "Aktualisieren",
|
||||
|
||||
@@ -561,6 +561,8 @@ export const dict = {
|
||||
"The terminal connection was interrupted. This can happen when the server restarts.",
|
||||
|
||||
"common.closeTab": "Close tab",
|
||||
"common.signIn": "Sign in",
|
||||
"common.signOut": "Sign out",
|
||||
"common.dismiss": "Dismiss",
|
||||
"common.requestFailed": "Request failed",
|
||||
"common.moreOptions": "More options",
|
||||
@@ -716,10 +718,51 @@ export const dict = {
|
||||
"settings.providers.section.connected": "Connected providers",
|
||||
"settings.providers.connected.empty": "No connected providers",
|
||||
"settings.providers.section.popular": "Popular providers",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "Environment",
|
||||
"settings.providers.tag.config": "Config",
|
||||
"settings.providers.tag.custom": "Custom",
|
||||
"settings.providers.tag.customProvider": "Custom provider",
|
||||
"settings.providers.tag.other": "Other",
|
||||
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
|
||||
"settings.providers.custom.description": "Add an OpenAI-compatible provider by base URL.",
|
||||
|
||||
"provider.custom.title": "Custom provider",
|
||||
"provider.custom.description.prefix": "Configure an OpenAI-compatible provider. See the ",
|
||||
"provider.custom.description.link": "provider config docs",
|
||||
"provider.custom.description.suffix": ".",
|
||||
"provider.custom.field.providerID.label": "Provider ID",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "Lowercase letters, numbers, hyphens, or underscores",
|
||||
"provider.custom.field.name.label": "Display name",
|
||||
"provider.custom.field.name.placeholder": "My AI Provider",
|
||||
"provider.custom.field.baseURL.label": "Base URL",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "API key",
|
||||
"provider.custom.field.apiKey.placeholder": "API key",
|
||||
"provider.custom.field.apiKey.description": "Optional. Leave empty if you manage auth via headers.",
|
||||
"provider.custom.models.label": "Models",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "Name",
|
||||
"provider.custom.models.name.placeholder": "Display Name",
|
||||
"provider.custom.models.remove": "Remove model",
|
||||
"provider.custom.models.add": "Add model",
|
||||
"provider.custom.headers.label": "Headers (optional)",
|
||||
"provider.custom.headers.key.label": "Header",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "Value",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "Remove header",
|
||||
"provider.custom.headers.add": "Add header",
|
||||
"provider.custom.error.providerID.required": "Provider ID is required",
|
||||
"provider.custom.error.providerID.format": "Use lowercase letters, numbers, hyphens, or underscores",
|
||||
"provider.custom.error.providerID.exists": "That provider ID already exists",
|
||||
"provider.custom.error.name.required": "Display name is required",
|
||||
"provider.custom.error.baseURL.required": "Base URL is required",
|
||||
"provider.custom.error.baseURL.format": "Must start with http:// or https://",
|
||||
"provider.custom.error.required": "Required",
|
||||
"provider.custom.error.duplicate": "Duplicate",
|
||||
"settings.models.title": "Models",
|
||||
"settings.models.description": "Model settings will be configurable here.",
|
||||
"settings.agents.title": "Agents",
|
||||
@@ -821,6 +864,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "URL copied to clipboard",
|
||||
"deviceAuth.toast.codeCopied": "Code copied to clipboard",
|
||||
"deviceAuth.toast.errorCopied": "Error copied to clipboard",
|
||||
"deviceAuth.status.initiating": "Starting login...",
|
||||
"deviceAuth.title": "Sign in to Kilo Code",
|
||||
"deviceAuth.step1": "Step 1: Open this URL",
|
||||
@@ -833,7 +877,10 @@ export const dict = {
|
||||
"deviceAuth.status.success": "Login successful!",
|
||||
"deviceAuth.status.failed": "Login failed",
|
||||
"deviceAuth.status.cancelled": "Login cancelled",
|
||||
"deviceAuth.action.copyError": "Copy error",
|
||||
"deviceAuth.action.showDetails": "View details",
|
||||
"deviceAuth.action.tryAgain": "Try Again",
|
||||
"deviceAuth.error.detailsTitle": "Login error details",
|
||||
|
||||
"common.retry": "Retry",
|
||||
"common.refresh": "Refresh",
|
||||
@@ -1083,6 +1130,9 @@ export const dict = {
|
||||
"settings.providers.enabled": "Enabled Providers (Allowlist)",
|
||||
"settings.providers.enabled.description": "If set, only these providers will be available (exclusive allowlist)",
|
||||
"settings.providers.notSet": "Not set (use server default)",
|
||||
"settings.providers.custom.note": "Add an OpenAI-compatible provider by base URL.",
|
||||
"settings.providers.search.placeholder": "Search providers",
|
||||
"settings.providers.select.placeholder": "Select provider...",
|
||||
|
||||
"dialog.model.notSet": "Not set",
|
||||
|
||||
|
||||
@@ -559,6 +559,8 @@ export const dict = {
|
||||
"terminal.connectionLost.description":
|
||||
"La conexión del terminal se interrumpió. Esto puede ocurrir cuando el servidor se reinicia.",
|
||||
"common.closeTab": "Cerrar pestaña",
|
||||
"common.signIn": "Iniciar sesión",
|
||||
"common.signOut": "Cerrar sesión",
|
||||
"common.dismiss": "Descartar",
|
||||
"common.requestFailed": "Solicitud fallida",
|
||||
"common.moreOptions": "Más opciones",
|
||||
@@ -716,13 +718,56 @@ export const dict = {
|
||||
"settings.providers.section.connected": "Proveedores conectados",
|
||||
"settings.providers.connected.empty": "No hay proveedores conectados",
|
||||
"settings.providers.section.popular": "Proveedores populares",
|
||||
"settings.providers.search.placeholder": "Buscar proveedores",
|
||||
"settings.providers.select.placeholder": "Seleccionar proveedor...",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "Entorno",
|
||||
"settings.providers.tag.config": "Configuración",
|
||||
"settings.providers.tag.custom": "Personalizado",
|
||||
"settings.providers.tag.other": "Otro",
|
||||
"settings.providers.tag.customProvider": "Proveedor personalizado",
|
||||
"settings.providers.connected.environmentDescription": "Conectado desde tus variables de entorno",
|
||||
"settings.providers.custom.description": "Añade un proveedor compatible con OpenAI por URL base.",
|
||||
"settings.providers.modeModels": "Modelo por modo",
|
||||
"settings.providers.custom.note": "Agrega un proveedor compatible con OpenAI mediante URL base.",
|
||||
"settings.providers.modeModels.description":
|
||||
"Anula el modelo predeterminado para modos específicos. Si no se establece, se usa el modelo predeterminado global.",
|
||||
"provider.custom.title": "Proveedor personalizado",
|
||||
"provider.custom.description.prefix": "Configura un proveedor compatible con OpenAI. Consulta la ",
|
||||
"provider.custom.description.link": "documentación de configuración de proveedores",
|
||||
"provider.custom.description.suffix": ".",
|
||||
"provider.custom.field.providerID.label": "ID del proveedor",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "Letras minúsculas, números, guiones o guiones bajos",
|
||||
"provider.custom.field.name.label": "Nombre para mostrar",
|
||||
"provider.custom.field.name.placeholder": "Mi proveedor de IA",
|
||||
"provider.custom.field.baseURL.label": "URL base",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "Clave API",
|
||||
"provider.custom.field.apiKey.placeholder": "Clave API",
|
||||
"provider.custom.field.apiKey.description": "Opcional. Déjalo vacío si gestionas la autenticación mediante headers.",
|
||||
"provider.custom.models.label": "Modelos",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "Nombre",
|
||||
"provider.custom.models.name.placeholder": "Nombre para mostrar",
|
||||
"provider.custom.models.remove": "Eliminar modelo",
|
||||
"provider.custom.models.add": "Añadir modelo",
|
||||
"provider.custom.headers.label": "Headers (opcional)",
|
||||
"provider.custom.headers.key.label": "Header",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "Valor",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "Eliminar header",
|
||||
"provider.custom.headers.add": "Añadir header",
|
||||
"provider.custom.error.providerID.required": "El ID del proveedor es obligatorio",
|
||||
"provider.custom.error.providerID.format": "Usa letras minúsculas, números, guiones o guiones bajos",
|
||||
"provider.custom.error.providerID.exists": "Ese ID de proveedor ya existe",
|
||||
"provider.custom.error.name.required": "El nombre para mostrar es obligatorio",
|
||||
"provider.custom.error.baseURL.required": "La URL base es obligatoria",
|
||||
"provider.custom.error.baseURL.format": "Debe empezar con http:// o https://",
|
||||
"provider.custom.error.required": "Obligatorio",
|
||||
"provider.custom.error.duplicate": "Duplicado",
|
||||
"settings.models.title": "Modelos",
|
||||
"settings.models.description": "La configuración de modelos estará disponible aquí.",
|
||||
"settings.agents.title": "Agentes",
|
||||
@@ -826,6 +871,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "URL copiada al portapapeles",
|
||||
"deviceAuth.toast.codeCopied": "Código copiado al portapapeles",
|
||||
"deviceAuth.toast.errorCopied": "Error copiado al portapapeles",
|
||||
"deviceAuth.status.initiating": "Iniciando sesión...",
|
||||
"deviceAuth.title": "Iniciar sesión en Kilo Code",
|
||||
"deviceAuth.step1": "Paso 1: Abre esta URL",
|
||||
@@ -837,8 +883,11 @@ export const dict = {
|
||||
"deviceAuth.status.waiting": "Esperando autorización...",
|
||||
"deviceAuth.status.success": "¡Inicio de sesión exitoso!",
|
||||
"deviceAuth.status.failed": "Error en el inicio de sesión",
|
||||
"deviceAuth.error.detailsTitle": "Detalles del error de inicio de sesión",
|
||||
"deviceAuth.status.cancelled": "Inicio de sesión cancelado",
|
||||
"deviceAuth.action.tryAgain": "Intentar de nuevo",
|
||||
"deviceAuth.action.copyError": "Copiar error",
|
||||
"deviceAuth.action.showDetails": "Ver detalles",
|
||||
|
||||
"common.retry": "Reintentar",
|
||||
"common.refresh": "Actualizar",
|
||||
|
||||
@@ -564,6 +564,8 @@ export const dict = {
|
||||
"terminal.connectionLost.description":
|
||||
"La connexion au terminal a été interrompue. Cela peut arriver lorsque le serveur redémarre.",
|
||||
"common.closeTab": "Fermer l'onglet",
|
||||
"common.signIn": "Se connecter",
|
||||
"common.signOut": "Se déconnecter",
|
||||
"common.dismiss": "Ignorer",
|
||||
"common.requestFailed": "La demande a échoué",
|
||||
"common.moreOptions": "Plus d'options",
|
||||
@@ -720,13 +722,58 @@ export const dict = {
|
||||
"settings.providers.section.connected": "Fournisseurs connectés",
|
||||
"settings.providers.connected.empty": "Aucun fournisseur connecté",
|
||||
"settings.providers.section.popular": "Fournisseurs populaires",
|
||||
"settings.providers.search.placeholder": "Rechercher des fournisseurs",
|
||||
"settings.providers.select.placeholder": "Sélectionner un fournisseur...",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "Environnement",
|
||||
"settings.providers.tag.config": "Configuration",
|
||||
"settings.providers.tag.custom": "Personnalisé",
|
||||
"settings.providers.tag.other": "Autre",
|
||||
"settings.providers.tag.customProvider": "Fournisseur personnalisé",
|
||||
"settings.providers.connected.environmentDescription": "Connecté depuis vos variables d'environnement",
|
||||
"settings.providers.custom.description": "Ajoutez un fournisseur compatible OpenAI par URL de base.",
|
||||
"settings.providers.modeModels": "Modèle par mode",
|
||||
"settings.providers.custom.note": "Ajoutez un fournisseur compatible OpenAI par URL de base.",
|
||||
"settings.providers.modeModels.description":
|
||||
"Remplacez le modèle par défaut pour des modes spécifiques. Si non défini, le modèle par défaut global est utilisé.",
|
||||
"provider.custom.title": "Fournisseur personnalisé",
|
||||
"provider.custom.description.prefix": "Configurez un fournisseur compatible OpenAI. Voir la ",
|
||||
"provider.custom.description.link": "documentation de configuration des fournisseurs",
|
||||
"provider.custom.description.suffix": ".",
|
||||
"provider.custom.field.providerID.label": "ID du fournisseur",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "Lettres minuscules, chiffres, tirets ou underscores",
|
||||
"provider.custom.field.name.label": "Nom d'affichage",
|
||||
"provider.custom.field.name.placeholder": "Mon fournisseur IA",
|
||||
"provider.custom.field.baseURL.label": "URL de base",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "Clé API",
|
||||
"provider.custom.field.apiKey.placeholder": "Clé API",
|
||||
"provider.custom.field.apiKey.description":
|
||||
"Optionnel. Laissez vide si vous gérez l'authentification via les en-têtes.",
|
||||
"provider.custom.models.label": "Modèles",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "Nom",
|
||||
"provider.custom.models.name.placeholder": "Nom d'affichage",
|
||||
"provider.custom.models.remove": "Supprimer le modèle",
|
||||
"provider.custom.models.add": "Ajouter un modèle",
|
||||
"provider.custom.headers.label": "En-têtes (optionnel)",
|
||||
"provider.custom.headers.key.label": "En-tête",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "Valeur",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "Supprimer l'en-tête",
|
||||
"provider.custom.headers.add": "Ajouter un en-tête",
|
||||
"provider.custom.error.providerID.required": "L'ID du fournisseur est requis",
|
||||
"provider.custom.error.providerID.format":
|
||||
"Utilisez des lettres minuscules, des chiffres, des tirets ou des underscores",
|
||||
"provider.custom.error.providerID.exists": "Cet ID de fournisseur existe déjà",
|
||||
"provider.custom.error.name.required": "Le nom d'affichage est requis",
|
||||
"provider.custom.error.baseURL.required": "L'URL de base est requise",
|
||||
"provider.custom.error.baseURL.format": "Doit commencer par http:// ou https://",
|
||||
"provider.custom.error.required": "Requis",
|
||||
"provider.custom.error.duplicate": "Doublon",
|
||||
"settings.models.title": "Modèles",
|
||||
"settings.models.description": "Les paramètres des modèles seront configurables ici.",
|
||||
"settings.agents.title": "Agents",
|
||||
@@ -831,6 +878,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "URL copiée dans le presse-papier",
|
||||
"deviceAuth.toast.codeCopied": "Code copié dans le presse-papier",
|
||||
"deviceAuth.toast.errorCopied": "Erreur copiée dans le presse-papiers",
|
||||
"deviceAuth.status.initiating": "Démarrage de la connexion...",
|
||||
"deviceAuth.title": "Se connecter à Kilo Code",
|
||||
"deviceAuth.step1": "Étape 1 : Ouvrez cette URL",
|
||||
@@ -842,8 +890,11 @@ export const dict = {
|
||||
"deviceAuth.status.waiting": "En attente d'autorisation...",
|
||||
"deviceAuth.status.success": "Connexion réussie !",
|
||||
"deviceAuth.status.failed": "Échec de la connexion",
|
||||
"deviceAuth.error.detailsTitle": "Détails de l'erreur de connexion",
|
||||
"deviceAuth.status.cancelled": "Connexion annulée",
|
||||
"deviceAuth.action.tryAgain": "Réessayer",
|
||||
"deviceAuth.action.copyError": "Copier l'erreur",
|
||||
"deviceAuth.action.showDetails": "Voir les détails",
|
||||
|
||||
"common.retry": "Réessayer",
|
||||
"common.refresh": "Actualiser",
|
||||
|
||||
@@ -555,6 +555,8 @@ export const dict = {
|
||||
"terminal.connectionLost.description":
|
||||
"ターミナルの接続が中断されました。これはサーバーが再起動したときに発生することがあります。",
|
||||
"common.closeTab": "タブを閉じる",
|
||||
"common.signIn": "サインイン",
|
||||
"common.signOut": "サインアウト",
|
||||
"common.dismiss": "閉じる",
|
||||
"common.requestFailed": "リクエスト失敗",
|
||||
"common.moreOptions": "その他のオプション",
|
||||
@@ -709,13 +711,56 @@ export const dict = {
|
||||
"settings.providers.section.connected": "接続済みプロバイダー",
|
||||
"settings.providers.connected.empty": "接続済みプロバイダーはありません",
|
||||
"settings.providers.section.popular": "人気のプロバイダー",
|
||||
"settings.providers.search.placeholder": "プロバイダーを検索",
|
||||
"settings.providers.select.placeholder": "プロバイダーを選択...",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "環境",
|
||||
"settings.providers.tag.config": "設定",
|
||||
"settings.providers.tag.custom": "カスタム",
|
||||
"settings.providers.tag.other": "その他",
|
||||
"settings.providers.tag.customProvider": "カスタムプロバイダー",
|
||||
"settings.providers.connected.environmentDescription": "環境変数から接続されています",
|
||||
"settings.providers.custom.description": "ベースURLでOpenAI互換プロバイダーを追加します。",
|
||||
"settings.providers.modeModels": "モードごとのモデル",
|
||||
"settings.providers.custom.note": "Base URL で OpenAI 互換プロバイダーを追加します。",
|
||||
"settings.providers.modeModels.description":
|
||||
"特定のモードのデフォルトモデルを上書きします。設定されていない場合、グローバルデフォルトモデルが使用されます。",
|
||||
"provider.custom.title": "カスタムプロバイダー",
|
||||
"provider.custom.description.prefix": "OpenAI互換プロバイダーを設定します。",
|
||||
"provider.custom.description.link": "プロバイダー設定ドキュメント",
|
||||
"provider.custom.description.suffix": "を参照してください。",
|
||||
"provider.custom.field.providerID.label": "プロバイダーID",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "小文字、数字、ハイフン、アンダースコア",
|
||||
"provider.custom.field.name.label": "表示名",
|
||||
"provider.custom.field.name.placeholder": "マイAIプロバイダー",
|
||||
"provider.custom.field.baseURL.label": "ベースURL",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "APIキー",
|
||||
"provider.custom.field.apiKey.placeholder": "APIキー",
|
||||
"provider.custom.field.apiKey.description": "任意。ヘッダーで認証を管理する場合は空のままにしてください。",
|
||||
"provider.custom.models.label": "モデル",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "名前",
|
||||
"provider.custom.models.name.placeholder": "表示名",
|
||||
"provider.custom.models.remove": "モデルを削除",
|
||||
"provider.custom.models.add": "モデルを追加",
|
||||
"provider.custom.headers.label": "ヘッダー(任意)",
|
||||
"provider.custom.headers.key.label": "ヘッダー",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "値",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "ヘッダーを削除",
|
||||
"provider.custom.headers.add": "ヘッダーを追加",
|
||||
"provider.custom.error.providerID.required": "プロバイダーIDは必須です",
|
||||
"provider.custom.error.providerID.format": "小文字、数字、ハイフン、アンダースコアを使用してください",
|
||||
"provider.custom.error.providerID.exists": "そのプロバイダーIDは既に存在します",
|
||||
"provider.custom.error.name.required": "表示名は必須です",
|
||||
"provider.custom.error.baseURL.required": "ベースURLは必須です",
|
||||
"provider.custom.error.baseURL.format": "http:// または https:// で始まる必要があります",
|
||||
"provider.custom.error.required": "必須",
|
||||
"provider.custom.error.duplicate": "重複",
|
||||
"settings.models.title": "モデル",
|
||||
"settings.models.description": "モデル設定はここで構成できます。",
|
||||
"settings.agents.title": "エージェント",
|
||||
@@ -818,6 +863,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "URLをクリップボードにコピーしました",
|
||||
"deviceAuth.toast.codeCopied": "コードをクリップボードにコピーしました",
|
||||
"deviceAuth.toast.errorCopied": "エラーがクリップボードにコピーされました",
|
||||
"deviceAuth.status.initiating": "ログインを開始しています...",
|
||||
"deviceAuth.title": "Kilo Codeにサインイン",
|
||||
"deviceAuth.step1": "ステップ1:このURLを開く",
|
||||
@@ -829,8 +875,11 @@ export const dict = {
|
||||
"deviceAuth.status.waiting": "認証を待っています...",
|
||||
"deviceAuth.status.success": "ログイン成功!",
|
||||
"deviceAuth.status.failed": "ログイン失敗",
|
||||
"deviceAuth.error.detailsTitle": "ログインエラーの詳細",
|
||||
"deviceAuth.status.cancelled": "ログインがキャンセルされました",
|
||||
"deviceAuth.action.tryAgain": "再試行",
|
||||
"deviceAuth.action.copyError": "エラーをコピー",
|
||||
"deviceAuth.action.showDetails": "詳細を表示",
|
||||
|
||||
"common.retry": "再試行",
|
||||
"common.refresh": "更新",
|
||||
|
||||
@@ -557,6 +557,8 @@ export const dict = {
|
||||
"terminal.connectionLost.description":
|
||||
"터미널 연결이 중단되었습니다. 서버가 재시작하면 이런 일이 발생할 수 있습니다.",
|
||||
"common.closeTab": "탭 닫기",
|
||||
"common.signIn": "로그인",
|
||||
"common.signOut": "로그아웃",
|
||||
"common.dismiss": "닫기",
|
||||
"common.requestFailed": "요청 실패",
|
||||
"common.moreOptions": "더 많은 옵션",
|
||||
@@ -710,13 +712,56 @@ export const dict = {
|
||||
"settings.providers.section.connected": "연결된 공급자",
|
||||
"settings.providers.connected.empty": "연결된 공급자 없음",
|
||||
"settings.providers.section.popular": "인기 공급자",
|
||||
"settings.providers.search.placeholder": "공급자 검색",
|
||||
"settings.providers.select.placeholder": "공급자 선택...",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "환경",
|
||||
"settings.providers.tag.config": "구성",
|
||||
"settings.providers.tag.custom": "사용자 지정",
|
||||
"settings.providers.tag.other": "기타",
|
||||
"settings.providers.tag.customProvider": "사용자 정의 공급자",
|
||||
"settings.providers.connected.environmentDescription": "환경 변수에서 연결됨",
|
||||
"settings.providers.custom.description": "기본 URL로 OpenAI 호환 공급자를 추가합니다.",
|
||||
"settings.providers.modeModels": "모드별 모델",
|
||||
"settings.providers.custom.note": "Base URL로 OpenAI 호환 공급자를 추가합니다.",
|
||||
"settings.providers.modeModels.description":
|
||||
"특정 모드의 기본 모델을 재정의합니다. 설정하지 않으면 전역 기본 모델이 사용됩니다.",
|
||||
"provider.custom.title": "사용자 정의 공급자",
|
||||
"provider.custom.description.prefix": "OpenAI 호환 공급자를 구성합니다. ",
|
||||
"provider.custom.description.link": "공급자 구성 문서",
|
||||
"provider.custom.description.suffix": "를 참조하세요.",
|
||||
"provider.custom.field.providerID.label": "공급자 ID",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "소문자, 숫자, 하이픈 또는 밑줄",
|
||||
"provider.custom.field.name.label": "표시 이름",
|
||||
"provider.custom.field.name.placeholder": "내 AI 공급자",
|
||||
"provider.custom.field.baseURL.label": "기본 URL",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "API 키",
|
||||
"provider.custom.field.apiKey.placeholder": "API 키",
|
||||
"provider.custom.field.apiKey.description": "선택사항. 헤더로 인증을 관리하는 경우 비워두세요.",
|
||||
"provider.custom.models.label": "모델",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "이름",
|
||||
"provider.custom.models.name.placeholder": "표시 이름",
|
||||
"provider.custom.models.remove": "모델 제거",
|
||||
"provider.custom.models.add": "모델 추가",
|
||||
"provider.custom.headers.label": "헤더 (선택사항)",
|
||||
"provider.custom.headers.key.label": "헤더",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "값",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "헤더 제거",
|
||||
"provider.custom.headers.add": "헤더 추가",
|
||||
"provider.custom.error.providerID.required": "공급자 ID는 필수입니다",
|
||||
"provider.custom.error.providerID.format": "소문자, 숫자, 하이픈 또는 밑줄을 사용하세요",
|
||||
"provider.custom.error.providerID.exists": "해당 공급자 ID가 이미 존재합니다",
|
||||
"provider.custom.error.name.required": "표시 이름은 필수입니다",
|
||||
"provider.custom.error.baseURL.required": "기본 URL은 필수입니다",
|
||||
"provider.custom.error.baseURL.format": "http:// 또는 https://로 시작해야 합니다",
|
||||
"provider.custom.error.required": "필수",
|
||||
"provider.custom.error.duplicate": "중복",
|
||||
"settings.models.title": "모델",
|
||||
"settings.models.description": "모델 설정은 여기서 구성할 수 있습니다.",
|
||||
"settings.agents.title": "에이전트",
|
||||
@@ -818,6 +863,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "URL이 클립보드에 복사되었습니다",
|
||||
"deviceAuth.toast.codeCopied": "코드가 클립보드에 복사되었습니다",
|
||||
"deviceAuth.toast.errorCopied": "오류가 클립보드에 복사되었습니다",
|
||||
"deviceAuth.status.initiating": "로그인 시작 중...",
|
||||
"deviceAuth.title": "Kilo Code에 로그인",
|
||||
"deviceAuth.step1": "1단계: 이 URL을 여세요",
|
||||
@@ -829,8 +875,11 @@ export const dict = {
|
||||
"deviceAuth.status.waiting": "인증 대기 중...",
|
||||
"deviceAuth.status.success": "로그인 성공!",
|
||||
"deviceAuth.status.failed": "로그인 실패",
|
||||
"deviceAuth.error.detailsTitle": "로그인 오류 상세 정보",
|
||||
"deviceAuth.status.cancelled": "로그인 취소됨",
|
||||
"deviceAuth.action.tryAgain": "다시 시도",
|
||||
"deviceAuth.action.copyError": "오류 복사",
|
||||
"deviceAuth.action.showDetails": "자세히 보기",
|
||||
|
||||
"common.retry": "재시도",
|
||||
"common.refresh": "새로고침",
|
||||
|
||||
@@ -560,6 +560,8 @@ export const dict = {
|
||||
"Terminalforbindelsen ble avbrutt. Dette kan skje når serveren starter på nytt.",
|
||||
|
||||
"common.closeTab": "Lukk fane",
|
||||
"common.signIn": "Logg inn",
|
||||
"common.signOut": "Logg ut",
|
||||
"common.dismiss": "Avvis",
|
||||
"common.requestFailed": "Forespørsel mislyktes",
|
||||
"common.moreOptions": "Flere alternativer",
|
||||
@@ -714,13 +716,56 @@ export const dict = {
|
||||
"settings.providers.section.connected": "Tilkoblede leverandører",
|
||||
"settings.providers.connected.empty": "Ingen tilkoblede leverandører",
|
||||
"settings.providers.section.popular": "Populære leverandører",
|
||||
"settings.providers.search.placeholder": "Søk etter leverandører",
|
||||
"settings.providers.select.placeholder": "Velg leverandør...",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "Miljø",
|
||||
"settings.providers.tag.config": "Konfigurasjon",
|
||||
"settings.providers.tag.custom": "Tilpasset",
|
||||
"settings.providers.tag.other": "Annet",
|
||||
"settings.providers.tag.customProvider": "Egendefinert leverandør",
|
||||
"settings.providers.connected.environmentDescription": "Koblet til fra dine miljøvariabler",
|
||||
"settings.providers.custom.description": "Legg til en OpenAI-kompatibel leverandør via basis-URL.",
|
||||
"settings.providers.modeModels": "Modell per modus",
|
||||
"settings.providers.custom.note": "Legg til en OpenAI-kompatibel leverandør via basis-URL.",
|
||||
"settings.providers.modeModels.description":
|
||||
"Overstyr standardmodellen for bestemte moduser. Hvis ikke angitt, brukes den globale standardmodellen.",
|
||||
"provider.custom.title": "Egendefinert leverandør",
|
||||
"provider.custom.description.prefix": "Konfigurer en OpenAI-kompatibel leverandør. Se ",
|
||||
"provider.custom.description.link": "dokumentasjon for leverandørkonfigurasjon",
|
||||
"provider.custom.description.suffix": ".",
|
||||
"provider.custom.field.providerID.label": "Leverandør-ID",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "Små bokstaver, tall, bindestreker eller understreker",
|
||||
"provider.custom.field.name.label": "Visningsnavn",
|
||||
"provider.custom.field.name.placeholder": "Min AI-leverandør",
|
||||
"provider.custom.field.baseURL.label": "Basis-URL",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "API-nøkkel",
|
||||
"provider.custom.field.apiKey.placeholder": "API-nøkkel",
|
||||
"provider.custom.field.apiKey.description": "Valgfritt. La stå tomt hvis du administrerer autentisering via headere.",
|
||||
"provider.custom.models.label": "Modeller",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "Navn",
|
||||
"provider.custom.models.name.placeholder": "Visningsnavn",
|
||||
"provider.custom.models.remove": "Fjern modell",
|
||||
"provider.custom.models.add": "Legg til modell",
|
||||
"provider.custom.headers.label": "Headere (valgfritt)",
|
||||
"provider.custom.headers.key.label": "Header",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "Verdi",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "Fjern header",
|
||||
"provider.custom.headers.add": "Legg til header",
|
||||
"provider.custom.error.providerID.required": "Leverandør-ID er påkrevd",
|
||||
"provider.custom.error.providerID.format": "Bruk små bokstaver, tall, bindestreker eller understreker",
|
||||
"provider.custom.error.providerID.exists": "Den leverandør-IDen eksisterer allerede",
|
||||
"provider.custom.error.name.required": "Visningsnavn er påkrevd",
|
||||
"provider.custom.error.baseURL.required": "Basis-URL er påkrevd",
|
||||
"provider.custom.error.baseURL.format": "Må starte med http:// eller https://",
|
||||
"provider.custom.error.required": "Påkrevd",
|
||||
"provider.custom.error.duplicate": "Duplikat",
|
||||
"settings.models.title": "Modeller",
|
||||
"settings.models.description": "Modellinnstillinger vil kunne konfigureres her.",
|
||||
"settings.agents.title": "Agenter",
|
||||
@@ -823,6 +868,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "URL kopiert til utklippstavlen",
|
||||
"deviceAuth.toast.codeCopied": "Kode kopiert til utklippstavlen",
|
||||
"deviceAuth.toast.errorCopied": "Feil kopiert til utklippstavlen",
|
||||
"deviceAuth.status.initiating": "Starter pålogging...",
|
||||
"deviceAuth.title": "Logg inn på Kilo Code",
|
||||
"deviceAuth.step1": "Trinn 1: Åpne denne URLen",
|
||||
@@ -834,8 +880,11 @@ export const dict = {
|
||||
"deviceAuth.status.waiting": "Venter på autorisasjon...",
|
||||
"deviceAuth.status.success": "Pålogging vellykket!",
|
||||
"deviceAuth.status.failed": "Pålogging mislyktes",
|
||||
"deviceAuth.error.detailsTitle": "Detaljer om påloggingsfeil",
|
||||
"deviceAuth.status.cancelled": "Pålogging avbrutt",
|
||||
"deviceAuth.action.tryAgain": "Prøv igjen",
|
||||
"deviceAuth.action.copyError": "Kopier feil",
|
||||
"deviceAuth.action.showDetails": "Vis detaljer",
|
||||
|
||||
"common.retry": "Prøv igjen",
|
||||
"common.refresh": "Oppdater",
|
||||
|
||||
@@ -558,6 +558,8 @@ export const dict = {
|
||||
"Połączenie z terminalem zostało przerwane. Może się to zdarzyć przy restarcie serwera.",
|
||||
|
||||
"common.closeTab": "Zamknij kartę",
|
||||
"common.signIn": "Zaloguj się",
|
||||
"common.signOut": "Wyloguj się",
|
||||
"common.dismiss": "Odrzuć",
|
||||
"common.requestFailed": "Żądanie nie powiodło się",
|
||||
"common.moreOptions": "Więcej opcji",
|
||||
@@ -713,13 +715,57 @@ export const dict = {
|
||||
"settings.providers.section.connected": "Połączeni dostawcy",
|
||||
"settings.providers.connected.empty": "Brak połączonych dostawców",
|
||||
"settings.providers.section.popular": "Popularni dostawcy",
|
||||
"settings.providers.search.placeholder": "Szukaj dostawców",
|
||||
"settings.providers.select.placeholder": "Wybierz dostawcę...",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "Środowisko",
|
||||
"settings.providers.tag.config": "Konfiguracja",
|
||||
"settings.providers.tag.custom": "Niestandardowe",
|
||||
"settings.providers.tag.other": "Inne",
|
||||
"settings.providers.tag.customProvider": "Niestandardowy dostawca",
|
||||
"settings.providers.connected.environmentDescription": "Połączony z twoich zmiennych środowiskowych",
|
||||
"settings.providers.custom.description": "Dodaj dostawcę kompatybilnego z OpenAI przez bazowy URL.",
|
||||
"settings.providers.modeModels": "Model na tryb",
|
||||
"settings.providers.custom.note": "Dodaj dostawcę kompatybilnego z OpenAI przez bazowy URL.",
|
||||
"settings.providers.modeModels.description":
|
||||
"Zastąp domyślny model dla określonych trybów. Jeśli nie ustawiono, używany jest globalny domyślny model.",
|
||||
"provider.custom.title": "Niestandardowy dostawca",
|
||||
"provider.custom.description.prefix": "Skonfiguruj dostawcę kompatybilnego z OpenAI. Zobacz ",
|
||||
"provider.custom.description.link": "dokumentację konfiguracji dostawcy",
|
||||
"provider.custom.description.suffix": ".",
|
||||
"provider.custom.field.providerID.label": "ID dostawcy",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "Małe litery, cyfry, myślniki lub podkreślenia",
|
||||
"provider.custom.field.name.label": "Nazwa wyświetlana",
|
||||
"provider.custom.field.name.placeholder": "Mój dostawca AI",
|
||||
"provider.custom.field.baseURL.label": "Bazowy URL",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "Klucz API",
|
||||
"provider.custom.field.apiKey.placeholder": "Klucz API",
|
||||
"provider.custom.field.apiKey.description":
|
||||
"Opcjonalnie. Pozostaw puste, jeśli zarządzasz uwierzytelnianiem przez nagłówki.",
|
||||
"provider.custom.models.label": "Modele",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "Nazwa",
|
||||
"provider.custom.models.name.placeholder": "Nazwa wyświetlana",
|
||||
"provider.custom.models.remove": "Usuń model",
|
||||
"provider.custom.models.add": "Dodaj model",
|
||||
"provider.custom.headers.label": "Nagłówki (opcjonalnie)",
|
||||
"provider.custom.headers.key.label": "Nagłówek",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "Wartość",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "Usuń nagłówek",
|
||||
"provider.custom.headers.add": "Dodaj nagłówek",
|
||||
"provider.custom.error.providerID.required": "ID dostawcy jest wymagane",
|
||||
"provider.custom.error.providerID.format": "Użyj małych liter, cyfr, myślników lub podkreśleń",
|
||||
"provider.custom.error.providerID.exists": "Ten ID dostawcy już istnieje",
|
||||
"provider.custom.error.name.required": "Nazwa wyświetlana jest wymagana",
|
||||
"provider.custom.error.baseURL.required": "Bazowy URL jest wymagany",
|
||||
"provider.custom.error.baseURL.format": "Musi zaczynać się od http:// lub https://",
|
||||
"provider.custom.error.required": "Wymagane",
|
||||
"provider.custom.error.duplicate": "Duplikat",
|
||||
"settings.models.title": "Modele",
|
||||
"settings.models.description": "Ustawienia modeli będą tutaj konfigurowalne.",
|
||||
"settings.agents.title": "Agenci",
|
||||
@@ -821,6 +867,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "URL skopiowany do schowka",
|
||||
"deviceAuth.toast.codeCopied": "Kod skopiowany do schowka",
|
||||
"deviceAuth.toast.errorCopied": "Błąd skopiowany do schowka",
|
||||
"deviceAuth.status.initiating": "Rozpoczynanie logowania...",
|
||||
"deviceAuth.title": "Zaloguj się do Kilo Code",
|
||||
"deviceAuth.step1": "Krok 1: Otwórz ten URL",
|
||||
@@ -832,8 +879,11 @@ export const dict = {
|
||||
"deviceAuth.status.waiting": "Oczekiwanie na autoryzację...",
|
||||
"deviceAuth.status.success": "Logowanie powiodło się!",
|
||||
"deviceAuth.status.failed": "Logowanie nie powiodło się",
|
||||
"deviceAuth.error.detailsTitle": "Szczegóły błędu logowania",
|
||||
"deviceAuth.status.cancelled": "Logowanie anulowane",
|
||||
"deviceAuth.action.tryAgain": "Spróbuj ponownie",
|
||||
"deviceAuth.action.copyError": "Kopiuj błąd",
|
||||
"deviceAuth.action.showDetails": "Pokaż szczegóły",
|
||||
|
||||
"common.retry": "Ponów",
|
||||
"common.refresh": "Odśwież",
|
||||
|
||||
@@ -560,6 +560,8 @@ export const dict = {
|
||||
"Соединение с терминалом прервано. Это может произойти при перезапуске сервера.",
|
||||
|
||||
"common.closeTab": "Закрыть вкладку",
|
||||
"common.signIn": "Войти",
|
||||
"common.signOut": "Выйти",
|
||||
"common.dismiss": "Закрыть",
|
||||
"common.requestFailed": "Запрос не выполнен",
|
||||
"common.moreOptions": "Дополнительные опции",
|
||||
@@ -716,13 +718,57 @@ export const dict = {
|
||||
"settings.providers.section.connected": "Подключённые провайдеры",
|
||||
"settings.providers.connected.empty": "Нет подключённых провайдеров",
|
||||
"settings.providers.section.popular": "Популярные провайдеры",
|
||||
"settings.providers.search.placeholder": "Поиск провайдеров",
|
||||
"settings.providers.select.placeholder": "Выберите провайдера...",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "Среда",
|
||||
"settings.providers.tag.config": "Конфигурация",
|
||||
"settings.providers.tag.custom": "Пользовательский",
|
||||
"settings.providers.tag.other": "Другое",
|
||||
"settings.providers.tag.customProvider": "Пользовательский провайдер",
|
||||
"settings.providers.connected.environmentDescription": "Подключён из ваших переменных окружения",
|
||||
"settings.providers.custom.description": "Добавьте OpenAI-совместимый провайдер по базовому URL.",
|
||||
"settings.providers.modeModels": "Модель для режима",
|
||||
"settings.providers.custom.note": "Добавьте OpenAI-совместимого провайдера по базовому URL.",
|
||||
"settings.providers.modeModels.description":
|
||||
"Переопределите модель по умолчанию для определённых режимов. Если не задано, используется глобальная модель по умолчанию.",
|
||||
"provider.custom.title": "Пользовательский провайдер",
|
||||
"provider.custom.description.prefix": "Настройте провайдер, совместимый с OpenAI. См. ",
|
||||
"provider.custom.description.link": "документацию по настройке провайдера",
|
||||
"provider.custom.description.suffix": ".",
|
||||
"provider.custom.field.providerID.label": "ID провайдера",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "Строчные буквы, цифры, дефисы или подчёркивания",
|
||||
"provider.custom.field.name.label": "Отображаемое имя",
|
||||
"provider.custom.field.name.placeholder": "Мой AI-провайдер",
|
||||
"provider.custom.field.baseURL.label": "Базовый URL",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "API-ключ",
|
||||
"provider.custom.field.apiKey.placeholder": "API-ключ",
|
||||
"provider.custom.field.apiKey.description":
|
||||
"Необязательно. Оставьте пустым, если вы управляете аутентификацией через заголовки.",
|
||||
"provider.custom.models.label": "Модели",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "Название",
|
||||
"provider.custom.models.name.placeholder": "Отображаемое имя",
|
||||
"provider.custom.models.remove": "Удалить модель",
|
||||
"provider.custom.models.add": "Добавить модель",
|
||||
"provider.custom.headers.label": "Заголовки (необязательно)",
|
||||
"provider.custom.headers.key.label": "Заголовок",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "Значение",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "Удалить заголовок",
|
||||
"provider.custom.headers.add": "Добавить заголовок",
|
||||
"provider.custom.error.providerID.required": "ID провайдера обязателен",
|
||||
"provider.custom.error.providerID.format": "Используйте строчные буквы, цифры, дефисы или подчёркивания",
|
||||
"provider.custom.error.providerID.exists": "Такой ID провайдера уже существует",
|
||||
"provider.custom.error.name.required": "Отображаемое имя обязательно",
|
||||
"provider.custom.error.baseURL.required": "Базовый URL обязателен",
|
||||
"provider.custom.error.baseURL.format": "Должен начинаться с http:// или https://",
|
||||
"provider.custom.error.required": "Обязательно",
|
||||
"provider.custom.error.duplicate": "Дубликат",
|
||||
"settings.models.title": "Модели",
|
||||
"settings.models.description": "Настройки моделей будут доступны здесь.",
|
||||
"settings.agents.title": "Агенты",
|
||||
@@ -825,6 +871,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "URL скопирован в буфер обмена",
|
||||
"deviceAuth.toast.codeCopied": "Код скопирован в буфер обмена",
|
||||
"deviceAuth.toast.errorCopied": "Ошибка скопирована в буфер обмена",
|
||||
"deviceAuth.status.initiating": "Начинаем вход...",
|
||||
"deviceAuth.title": "Войти в Kilo Code",
|
||||
"deviceAuth.step1": "Шаг 1: Откройте этот URL",
|
||||
@@ -836,8 +883,11 @@ export const dict = {
|
||||
"deviceAuth.status.waiting": "Ожидание авторизации...",
|
||||
"deviceAuth.status.success": "Вход выполнен успешно!",
|
||||
"deviceAuth.status.failed": "Ошибка входа",
|
||||
"deviceAuth.error.detailsTitle": "Подробности ошибки входа",
|
||||
"deviceAuth.status.cancelled": "Вход отменён",
|
||||
"deviceAuth.action.tryAgain": "Попробовать снова",
|
||||
"deviceAuth.action.copyError": "Копировать ошибку",
|
||||
"deviceAuth.action.showDetails": "Посмотреть детали",
|
||||
|
||||
"common.retry": "Повторить",
|
||||
"common.refresh": "Обновить",
|
||||
|
||||
@@ -554,6 +554,8 @@ export const dict = {
|
||||
"terminal.connectionLost.description": "การเชื่อมต่อเทอร์มินัลถูกขัดจังหวะ อาจเกิดขึ้นเมื่อเซิร์ฟเวอร์รีสตาร์ท",
|
||||
|
||||
"common.closeTab": "ปิดแท็บ",
|
||||
"common.signIn": "เข้าสู่ระบบ",
|
||||
"common.signOut": "ออกจากระบบ",
|
||||
"common.dismiss": "ปิด",
|
||||
"common.requestFailed": "คำขอล้มเหลว",
|
||||
"common.moreOptions": "ตัวเลือกเพิ่มเติม",
|
||||
@@ -707,13 +709,56 @@ export const dict = {
|
||||
"settings.providers.section.connected": "ผู้ให้บริการที่เชื่อมต่อ",
|
||||
"settings.providers.connected.empty": "ไม่มีผู้ให้บริการที่เชื่อมต่อ",
|
||||
"settings.providers.section.popular": "ผู้ให้บริการยอดนิยม",
|
||||
"settings.providers.search.placeholder": "ค้นหาผู้ให้บริการ",
|
||||
"settings.providers.select.placeholder": "เลือกผู้ให้บริการ...",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "สภาพแวดล้อม",
|
||||
"settings.providers.tag.config": "กำหนดค่า",
|
||||
"settings.providers.tag.custom": "กำหนดเอง",
|
||||
"settings.providers.tag.other": "อื่น ๆ",
|
||||
"settings.providers.tag.customProvider": "ผู้ให้บริการที่กำหนดเอง",
|
||||
"settings.providers.connected.environmentDescription": "เชื่อมต่อจากตัวแปรสภาพแวดล้อมของคุณ",
|
||||
"settings.providers.custom.description": "เพิ่มผู้ให้บริการที่เข้ากันได้กับ OpenAI ด้วย URL พื้นฐาน",
|
||||
"settings.providers.modeModels": "โมเดลต่อโหมด",
|
||||
"settings.providers.custom.note": "เพิ่มผู้ให้บริการที่รองรับ OpenAI ด้วย Base URL",
|
||||
"settings.providers.modeModels.description":
|
||||
"แทนที่โมเดลเริ่มต้นสำหรับโหมดที่กำหนด หากไม่ได้ตั้งค่า จะใช้โมเดลเริ่มต้นทั่วไป",
|
||||
"provider.custom.title": "ผู้ให้บริการที่กำหนดเอง",
|
||||
"provider.custom.description.prefix": "กำหนดค่าผู้ให้บริการที่เข้ากันได้กับ OpenAI ดู",
|
||||
"provider.custom.description.link": "เอกสารการกำหนดค่าผู้ให้บริการ",
|
||||
"provider.custom.description.suffix": "",
|
||||
"provider.custom.field.providerID.label": "ID ผู้ให้บริการ",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "ตัวอักษรพิมพ์เล็ก ตัวเลข ขีดกลาง หรือขีดล่าง",
|
||||
"provider.custom.field.name.label": "ชื่อที่แสดง",
|
||||
"provider.custom.field.name.placeholder": "ผู้ให้บริการ AI ของฉัน",
|
||||
"provider.custom.field.baseURL.label": "URL พื้นฐาน",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "คีย์ API",
|
||||
"provider.custom.field.apiKey.placeholder": "คีย์ API",
|
||||
"provider.custom.field.apiKey.description": "ไม่จำเป็น เว้นว่างไว้หากคุณจัดการการรับรองความถูกต้องผ่านส่วนหัว",
|
||||
"provider.custom.models.label": "โมเดล",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "ชื่อ",
|
||||
"provider.custom.models.name.placeholder": "ชื่อที่แสดง",
|
||||
"provider.custom.models.remove": "ลบโมเดล",
|
||||
"provider.custom.models.add": "เพิ่มโมเดล",
|
||||
"provider.custom.headers.label": "ส่วนหัว (ไม่จำเป็น)",
|
||||
"provider.custom.headers.key.label": "ส่วนหัว",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "ค่า",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "ลบส่วนหัว",
|
||||
"provider.custom.headers.add": "เพิ่มส่วนหัว",
|
||||
"provider.custom.error.providerID.required": "ต้องระบุ ID ผู้ให้บริการ",
|
||||
"provider.custom.error.providerID.format": "ใช้ตัวอักษรพิมพ์เล็ก ตัวเลข ขีดกลาง หรือขีดล่าง",
|
||||
"provider.custom.error.providerID.exists": "ID ผู้ให้บริการนี้มีอยู่แล้ว",
|
||||
"provider.custom.error.name.required": "ต้องระบุชื่อที่แสดง",
|
||||
"provider.custom.error.baseURL.required": "ต้องระบุ URL พื้นฐาน",
|
||||
"provider.custom.error.baseURL.format": "ต้องขึ้นต้นด้วย http:// หรือ https://",
|
||||
"provider.custom.error.required": "จำเป็น",
|
||||
"provider.custom.error.duplicate": "ซ้ำ",
|
||||
"settings.models.title": "โมเดล",
|
||||
"settings.models.description": "การตั้งค่าโมเดลจะสามารถกำหนดค่าได้ที่นี่",
|
||||
"settings.agents.title": "เอเจนต์",
|
||||
@@ -815,6 +860,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "คัดลอก URL ไปยังคลิปบอร์ดแล้ว",
|
||||
"deviceAuth.toast.codeCopied": "คัดลอกรหัสไปยังคลิปบอร์ดแล้ว",
|
||||
"deviceAuth.toast.errorCopied": "คัดลอกข้อผิดพลาดไปยังคลิปบอร์ดแล้ว",
|
||||
"deviceAuth.status.initiating": "กำลังเริ่มเข้าสู่ระบบ...",
|
||||
"deviceAuth.title": "เข้าสู่ระบบ Kilo Code",
|
||||
"deviceAuth.step1": "ขั้นตอนที่ 1: เปิด URL นี้",
|
||||
@@ -826,8 +872,11 @@ export const dict = {
|
||||
"deviceAuth.status.waiting": "กำลังรอการอนุญาต...",
|
||||
"deviceAuth.status.success": "เข้าสู่ระบบสำเร็จ!",
|
||||
"deviceAuth.status.failed": "เข้าสู่ระบบล้มเหลว",
|
||||
"deviceAuth.error.detailsTitle": "รายละเอียดข้อผิดพลาดการเข้าสู่ระบบ",
|
||||
"deviceAuth.status.cancelled": "ยกเลิกการเข้าสู่ระบบ",
|
||||
"deviceAuth.action.tryAgain": "ลองอีกครั้ง",
|
||||
"deviceAuth.action.copyError": "คัดลอกข้อผิดพลาด",
|
||||
"deviceAuth.action.showDetails": "ดูรายละเอียด",
|
||||
|
||||
"common.retry": "ลองอีกครั้ง",
|
||||
"common.refresh": "รีเฟรช",
|
||||
|
||||
@@ -548,6 +548,8 @@ export const dict = {
|
||||
"terminal.connectionLost.title": "连接已丢失",
|
||||
"terminal.connectionLost.description": "终端连接已中断。这可能发生在服务器重启时。",
|
||||
"common.closeTab": "关闭标签页",
|
||||
"common.signIn": "登录",
|
||||
"common.signOut": "退出登录",
|
||||
"common.dismiss": "忽略",
|
||||
"common.requestFailed": "请求失败",
|
||||
"common.moreOptions": "更多选项",
|
||||
@@ -700,12 +702,55 @@ export const dict = {
|
||||
"settings.providers.section.connected": "已连接的提供商",
|
||||
"settings.providers.connected.empty": "没有已连接的提供商",
|
||||
"settings.providers.section.popular": "热门提供商",
|
||||
"settings.providers.search.placeholder": "搜索提供商",
|
||||
"settings.providers.select.placeholder": "选择提供商...",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "环境",
|
||||
"settings.providers.tag.config": "配置",
|
||||
"settings.providers.tag.custom": "自定义",
|
||||
"settings.providers.tag.other": "其他",
|
||||
"settings.providers.tag.customProvider": "自定义提供商",
|
||||
"settings.providers.connected.environmentDescription": "从您的环境变量连接",
|
||||
"settings.providers.custom.description": "通过基础 URL 添加 OpenAI 兼容的提供商。",
|
||||
"settings.providers.modeModels": "按模式选择模型",
|
||||
"settings.providers.custom.note": "通过 Base URL 添加 OpenAI 兼容提供商。",
|
||||
"settings.providers.modeModels.description": "为特定模式覆盖默认模型。如果未设置,将使用全局默认模型。",
|
||||
"provider.custom.title": "自定义提供商",
|
||||
"provider.custom.description.prefix": "配置 OpenAI 兼容的提供商。请参阅",
|
||||
"provider.custom.description.link": "提供商配置文档",
|
||||
"provider.custom.description.suffix": "。",
|
||||
"provider.custom.field.providerID.label": "提供商 ID",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "小写字母、数字、连字符或下划线",
|
||||
"provider.custom.field.name.label": "显示名称",
|
||||
"provider.custom.field.name.placeholder": "我的 AI 提供商",
|
||||
"provider.custom.field.baseURL.label": "基础 URL",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "API 密钥",
|
||||
"provider.custom.field.apiKey.placeholder": "API 密钥",
|
||||
"provider.custom.field.apiKey.description": "可选。如果您通过请求头管理认证,请留空。",
|
||||
"provider.custom.models.label": "模型",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "名称",
|
||||
"provider.custom.models.name.placeholder": "显示名称",
|
||||
"provider.custom.models.remove": "移除模型",
|
||||
"provider.custom.models.add": "添加模型",
|
||||
"provider.custom.headers.label": "请求头(可选)",
|
||||
"provider.custom.headers.key.label": "请求头",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "值",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "移除请求头",
|
||||
"provider.custom.headers.add": "添加请求头",
|
||||
"provider.custom.error.providerID.required": "提供商 ID 为必填项",
|
||||
"provider.custom.error.providerID.format": "使用小写字母、数字、连字符或下划线",
|
||||
"provider.custom.error.providerID.exists": "该提供商 ID 已存在",
|
||||
"provider.custom.error.name.required": "显示名称为必填项",
|
||||
"provider.custom.error.baseURL.required": "基础 URL 为必填项",
|
||||
"provider.custom.error.baseURL.format": "必须以 http:// 或 https:// 开头",
|
||||
"provider.custom.error.required": "必填",
|
||||
"provider.custom.error.duplicate": "重复",
|
||||
"settings.models.title": "模型",
|
||||
"settings.models.description": "模型设置将在此处可配置。",
|
||||
"settings.agents.title": "智能体",
|
||||
@@ -807,6 +852,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "URL 已复制到剪贴板",
|
||||
"deviceAuth.toast.codeCopied": "代码已复制到剪贴板",
|
||||
"deviceAuth.toast.errorCopied": "错误已复制到剪贴板",
|
||||
"deviceAuth.status.initiating": "正在启动登录...",
|
||||
"deviceAuth.title": "登录 Kilo Code",
|
||||
"deviceAuth.step1": "步骤 1:打开此 URL",
|
||||
@@ -818,8 +864,11 @@ export const dict = {
|
||||
"deviceAuth.status.waiting": "等待授权中...",
|
||||
"deviceAuth.status.success": "登录成功!",
|
||||
"deviceAuth.status.failed": "登录失败",
|
||||
"deviceAuth.error.detailsTitle": "登录错误详情",
|
||||
"deviceAuth.status.cancelled": "登录已取消",
|
||||
"deviceAuth.action.tryAgain": "重试",
|
||||
"deviceAuth.action.copyError": "复制错误",
|
||||
"deviceAuth.action.showDetails": "查看详情",
|
||||
|
||||
"common.retry": "重试",
|
||||
"common.refresh": "刷新",
|
||||
|
||||
@@ -549,6 +549,8 @@ export const dict = {
|
||||
"terminal.connectionLost.title": "連線中斷",
|
||||
"terminal.connectionLost.description": "終端機連線已中斷。這可能會在伺服器重新啟動時發生。",
|
||||
"common.closeTab": "關閉標籤頁",
|
||||
"common.signIn": "登入",
|
||||
"common.signOut": "登出",
|
||||
"common.dismiss": "忽略",
|
||||
"common.requestFailed": "要求失敗",
|
||||
"common.moreOptions": "更多選項",
|
||||
@@ -702,12 +704,55 @@ export const dict = {
|
||||
"settings.providers.section.connected": "已連線的供應商",
|
||||
"settings.providers.connected.empty": "沒有已連線的供應商",
|
||||
"settings.providers.section.popular": "熱門供應商",
|
||||
"settings.providers.search.placeholder": "搜尋供應商",
|
||||
"settings.providers.select.placeholder": "選擇供應商...",
|
||||
"settings.providers.tag.gateway": "Gateway",
|
||||
"settings.providers.tag.environment": "環境",
|
||||
"settings.providers.tag.config": "設定",
|
||||
"settings.providers.tag.custom": "自訂",
|
||||
"settings.providers.tag.other": "其他",
|
||||
"settings.providers.tag.customProvider": "自訂提供商",
|
||||
"settings.providers.connected.environmentDescription": "從您的環境變數連線",
|
||||
"settings.providers.custom.description": "透過基礎 URL 新增 OpenAI 相容的提供商。",
|
||||
"settings.providers.modeModels": "按模式選擇模型",
|
||||
"settings.providers.custom.note": "透過 Base URL 新增 OpenAI 相容供應商。",
|
||||
"settings.providers.modeModels.description": "為特定模式覆寫預設模型。如果未設定,將使用全域預設模型。",
|
||||
"provider.custom.title": "自訂提供商",
|
||||
"provider.custom.description.prefix": "設定 OpenAI 相容的提供商。請參閱",
|
||||
"provider.custom.description.link": "提供商設定文件",
|
||||
"provider.custom.description.suffix": "。",
|
||||
"provider.custom.field.providerID.label": "提供商 ID",
|
||||
"provider.custom.field.providerID.placeholder": "myprovider",
|
||||
"provider.custom.field.providerID.description": "小寫字母、數字、連字號或底線",
|
||||
"provider.custom.field.name.label": "顯示名稱",
|
||||
"provider.custom.field.name.placeholder": "我的 AI 提供商",
|
||||
"provider.custom.field.baseURL.label": "基礎 URL",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
|
||||
"provider.custom.field.apiKey.label": "API 金鑰",
|
||||
"provider.custom.field.apiKey.placeholder": "API 金鑰",
|
||||
"provider.custom.field.apiKey.description": "選填。如果您透過標頭管理驗證,請留空。",
|
||||
"provider.custom.models.label": "模型",
|
||||
"provider.custom.models.id.label": "ID",
|
||||
"provider.custom.models.id.placeholder": "model-id",
|
||||
"provider.custom.models.name.label": "名稱",
|
||||
"provider.custom.models.name.placeholder": "顯示名稱",
|
||||
"provider.custom.models.remove": "移除模型",
|
||||
"provider.custom.models.add": "新增模型",
|
||||
"provider.custom.headers.label": "標頭(選填)",
|
||||
"provider.custom.headers.key.label": "標頭",
|
||||
"provider.custom.headers.key.placeholder": "Header-Name",
|
||||
"provider.custom.headers.value.label": "值",
|
||||
"provider.custom.headers.value.placeholder": "value",
|
||||
"provider.custom.headers.remove": "移除標頭",
|
||||
"provider.custom.headers.add": "新增標頭",
|
||||
"provider.custom.error.providerID.required": "提供商 ID 為必填",
|
||||
"provider.custom.error.providerID.format": "請使用小寫字母、數字、連字號或底線",
|
||||
"provider.custom.error.providerID.exists": "該提供商 ID 已存在",
|
||||
"provider.custom.error.name.required": "顯示名稱為必填",
|
||||
"provider.custom.error.baseURL.required": "基礎 URL 為必填",
|
||||
"provider.custom.error.baseURL.format": "必須以 http:// 或 https:// 開頭",
|
||||
"provider.custom.error.required": "必填",
|
||||
"provider.custom.error.duplicate": "重複",
|
||||
"settings.models.title": "模型",
|
||||
"settings.models.description": "可在此調整模型設定。",
|
||||
"settings.agents.title": "Agents",
|
||||
@@ -809,6 +854,7 @@ export const dict = {
|
||||
|
||||
"deviceAuth.toast.urlCopied": "URL 已複製到剪貼簿",
|
||||
"deviceAuth.toast.codeCopied": "驗證碼已複製到剪貼簿",
|
||||
"deviceAuth.toast.errorCopied": "錯誤已複製到剪貼簿",
|
||||
"deviceAuth.status.initiating": "正在啟動登入...",
|
||||
"deviceAuth.title": "登入 Kilo Code",
|
||||
"deviceAuth.step1": "步驟 1:開啟此 URL",
|
||||
@@ -820,8 +866,11 @@ export const dict = {
|
||||
"deviceAuth.status.waiting": "等待授權中...",
|
||||
"deviceAuth.status.success": "登入成功!",
|
||||
"deviceAuth.status.failed": "登入失敗",
|
||||
"deviceAuth.error.detailsTitle": "登入錯誤詳細資訊",
|
||||
"deviceAuth.status.cancelled": "登入已取消",
|
||||
"deviceAuth.action.tryAgain": "重試",
|
||||
"deviceAuth.action.copyError": "複製錯誤",
|
||||
"deviceAuth.action.showDetails": "檢視詳細資訊",
|
||||
|
||||
"common.retry": "重試",
|
||||
"common.refresh": "重新整理",
|
||||
|
||||
@@ -76,6 +76,9 @@ const MockProviderProvider: ParentComponent = (props) => {
|
||||
defaultSelection: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }),
|
||||
models: () => MOCK_MODELS,
|
||||
findModel: (sel: any) => _findModel(MOCK_MODELS, sel),
|
||||
authMethods: () => ({}),
|
||||
authStates: () => ({}),
|
||||
isModelValid: () => true,
|
||||
}
|
||||
return <ProviderContext.Provider value={value}>{props.children}</ProviderContext.Provider>
|
||||
}
|
||||
|
||||
@@ -1157,6 +1157,7 @@
|
||||
|
||||
.model-selector-list {
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
max-height: 300px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
@@ -1621,6 +1622,32 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Provider Connect Dialog */
|
||||
.provider-connect-body {
|
||||
font-size: 13px;
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
.provider-connect-code-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-weak-base);
|
||||
}
|
||||
|
||||
.provider-connect-code {
|
||||
font-family: monospace;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.provider-connect-status {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Working Indicator
|
||||
============================================ */
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Types for extension <-> webview message communication
|
||||
*/
|
||||
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@kilocode/sdk/v2/client"
|
||||
|
||||
// Connection states
|
||||
export type ConnectionState = "connecting" | "connected" | "disconnected" | "error"
|
||||
|
||||
@@ -289,6 +291,8 @@ export interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
models: Record<string, ProviderModel>
|
||||
source?: "env" | "config" | "custom" | "api"
|
||||
env?: string[]
|
||||
}
|
||||
|
||||
export interface ModelSelection {
|
||||
@@ -296,6 +300,8 @@ export interface ModelSelection {
|
||||
modelID: string
|
||||
}
|
||||
|
||||
export type ProviderAuthState = "api" | "oauth" | "wellknown"
|
||||
|
||||
// ============================================
|
||||
// Backend Config Types (mirrored for webview)
|
||||
// ============================================
|
||||
@@ -321,6 +327,9 @@ export interface ProviderConfig {
|
||||
api_key?: string
|
||||
base_url?: string
|
||||
models?: Record<string, unknown>
|
||||
npm?: string
|
||||
env?: string[]
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface McpConfig {
|
||||
@@ -611,6 +620,8 @@ export interface ProvidersLoadedMessage {
|
||||
connected: string[]
|
||||
defaults: Record<string, string>
|
||||
defaultSelection: ModelSelection
|
||||
authMethods: Record<string, ProviderAuthMethod[]>
|
||||
authStates: Record<string, ProviderAuthState>
|
||||
}
|
||||
|
||||
export interface AgentsLoadedMessage {
|
||||
@@ -806,6 +817,11 @@ export interface VariantsLoadedMessage {
|
||||
variants: Record<string, string>
|
||||
}
|
||||
|
||||
export interface RecentsLoadedMessage {
|
||||
type: "recentsLoaded"
|
||||
recents: ModelSelection[]
|
||||
}
|
||||
|
||||
export interface BranchInfo {
|
||||
name: string
|
||||
isLocal: boolean
|
||||
@@ -1134,6 +1150,33 @@ export interface RemoveInstalledMarketplaceItemMessage {
|
||||
mpInstallOptions: InstallMarketplaceItemOptions
|
||||
}
|
||||
|
||||
export interface ProviderOAuthReadyMessage {
|
||||
type: "providerOAuthReady"
|
||||
requestId: string
|
||||
providerID: string
|
||||
authorization: ProviderAuthAuthorization
|
||||
}
|
||||
|
||||
export interface ProviderConnectedMessage {
|
||||
type: "providerConnected"
|
||||
requestId: string
|
||||
providerID: string
|
||||
}
|
||||
|
||||
export interface ProviderDisconnectedMessage {
|
||||
type: "providerDisconnected"
|
||||
requestId: string
|
||||
providerID: string
|
||||
}
|
||||
|
||||
export interface ProviderActionErrorMessage {
|
||||
type: "providerActionError"
|
||||
requestId: string
|
||||
providerID: string
|
||||
action: "authorize" | "connect" | "disconnect"
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ExtensionMessage =
|
||||
| ReadyMessage
|
||||
| ConnectionStateMessage
|
||||
@@ -1218,6 +1261,11 @@ export type ExtensionMessage =
|
||||
| MarketplaceDataMessage
|
||||
| MarketplaceInstallResultMessage
|
||||
| MarketplaceRemoveResultMessage
|
||||
| ProviderOAuthReadyMessage
|
||||
| ProviderConnectedMessage
|
||||
| ProviderDisconnectedMessage
|
||||
| ProviderActionErrorMessage
|
||||
| RecentsLoadedMessage
|
||||
|
||||
// ============================================
|
||||
// Messages FROM webview TO extension
|
||||
@@ -1771,6 +1819,51 @@ export interface SetDefaultBaseBranchRequest {
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export interface ConnectProviderMessage {
|
||||
type: "connectProvider"
|
||||
requestId: string
|
||||
providerID: string
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export interface AuthorizeProviderOAuthMessage {
|
||||
type: "authorizeProviderOAuth"
|
||||
requestId: string
|
||||
providerID: string
|
||||
method: number
|
||||
}
|
||||
|
||||
export interface CompleteProviderOAuthMessage {
|
||||
type: "completeProviderOAuth"
|
||||
requestId: string
|
||||
providerID: string
|
||||
method: number
|
||||
code?: string
|
||||
}
|
||||
|
||||
export interface DisconnectProviderMessage {
|
||||
type: "disconnectProvider"
|
||||
requestId: string
|
||||
providerID: string
|
||||
}
|
||||
|
||||
export interface SaveCustomProviderMessage {
|
||||
type: "saveCustomProvider"
|
||||
requestId: string
|
||||
providerID: string
|
||||
config: ProviderConfig
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface PersistRecentsRequest {
|
||||
type: "persistRecents"
|
||||
recents: ModelSelection[]
|
||||
}
|
||||
|
||||
export interface RequestRecentsMessage {
|
||||
type: "requestRecents"
|
||||
}
|
||||
|
||||
export type WebviewMessage =
|
||||
| SendMessageRequest
|
||||
| AbortRequest
|
||||
@@ -1874,6 +1967,13 @@ export type WebviewMessage =
|
||||
| FilterMarketplaceItemsMessage
|
||||
| InstallMarketplaceItemMessage
|
||||
| RemoveInstalledMarketplaceItemMessage
|
||||
| ConnectProviderMessage
|
||||
| AuthorizeProviderOAuthMessage
|
||||
| CompleteProviderOAuthMessage
|
||||
| DisconnectProviderMessage
|
||||
| SaveCustomProviderMessage
|
||||
| PersistRecentsRequest
|
||||
| RequestRecentsMessage
|
||||
|
||||
// ============================================
|
||||
// VS Code API type
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
AuthorizeProviderOAuthMessage,
|
||||
CompleteProviderOAuthMessage,
|
||||
ConnectProviderMessage,
|
||||
DisconnectProviderMessage,
|
||||
ExtensionMessage,
|
||||
ProviderActionErrorMessage,
|
||||
ProviderConnectedMessage,
|
||||
ProviderDisconnectedMessage,
|
||||
ProviderOAuthReadyMessage,
|
||||
SaveCustomProviderMessage,
|
||||
WebviewMessage,
|
||||
} from "../types/messages"
|
||||
|
||||
type ProviderRequest =
|
||||
| ConnectProviderMessage
|
||||
| AuthorizeProviderOAuthMessage
|
||||
| CompleteProviderOAuthMessage
|
||||
| DisconnectProviderMessage
|
||||
| SaveCustomProviderMessage
|
||||
|
||||
type ProviderRequestInput =
|
||||
| Omit<ConnectProviderMessage, "requestId">
|
||||
| Omit<AuthorizeProviderOAuthMessage, "requestId">
|
||||
| Omit<CompleteProviderOAuthMessage, "requestId">
|
||||
| Omit<DisconnectProviderMessage, "requestId">
|
||||
| Omit<SaveCustomProviderMessage, "requestId">
|
||||
|
||||
type Transport = {
|
||||
postMessage: (message: WebviewMessage) => void
|
||||
onMessage: (handler: (message: ExtensionMessage) => void) => () => void
|
||||
}
|
||||
|
||||
type Handlers = {
|
||||
onOAuthReady?: (message: ProviderOAuthReadyMessage) => void
|
||||
onConnected?: (message: ProviderConnectedMessage) => void
|
||||
onDisconnected?: (message: ProviderDisconnectedMessage) => void
|
||||
onError?: (message: ProviderActionErrorMessage) => void
|
||||
}
|
||||
|
||||
export function createProviderAction(vscode: Transport) {
|
||||
const pending = new Map<string, Handlers>()
|
||||
const unsubscribe = vscode.onMessage((message) => {
|
||||
if (!("requestId" in message)) return
|
||||
|
||||
const item = pending.get(message.requestId)
|
||||
if (!item) return
|
||||
pending.delete(message.requestId)
|
||||
|
||||
if (message.type === "providerOAuthReady") {
|
||||
item.onOAuthReady?.(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === "providerConnected") {
|
||||
item.onConnected?.(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === "providerDisconnected") {
|
||||
item.onDisconnected?.(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === "providerActionError") {
|
||||
item.onError?.(message)
|
||||
}
|
||||
})
|
||||
|
||||
function send(message: ProviderRequestInput, handlers: Handlers = {}) {
|
||||
const requestId = crypto.randomUUID()
|
||||
pending.set(requestId, handlers)
|
||||
vscode.postMessage({ ...message, requestId } as ProviderRequest)
|
||||
return requestId
|
||||
}
|
||||
|
||||
function clear(requestId?: string) {
|
||||
if (requestId) {
|
||||
pending.delete(requestId)
|
||||
return
|
||||
}
|
||||
pending.clear()
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
clear()
|
||||
unsubscribe()
|
||||
}
|
||||
|
||||
return { clear, send, dispose }
|
||||
}
|
||||
Reference in New Issue
Block a user