Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 37 additions & 19 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,43 +1,61 @@
# dependencies (bun install)
node_modules
node_modules/
.bun/

# output
out
dist
out/
dist/
build/
release/
*.tgz
*.tsbuildinfo

# vendor binaries (downloaded / generated)
vendor/
apps/electron/vendor/

# code coverage
coverage
coverage/
*.lcov
.nyc_output/

# test outputs
test-results/
playwright-report/
reports/

# logs
logs
_.log
logs/
*.log
*.tmp
*.swp
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json

# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.env.*

# caches
.eslintcache
.cache
*.tsbuildinfo
.cache/

# IntelliJ based IDEs
.idea
.idea/

# VSCode
.vscode/

# Finder (MacOS) folder config
.DS_Store
Thumbs.db

# ignore craft-agents-oss folder
craft-agents-oss
# Codex local workspace state
.codex/

# ignore what_are_humans_thinking folder
what_are_humans_thinking
# --- AI/Agent local instructions (do not commit) ---
AGENTS.md
CLAUDE.md

# vendor binaries
vendor
# ignore local scratch folders
craft-agents-oss/
what_are_humans_thinking/
13 changes: 10 additions & 3 deletions apps/electron/src/main/lib/agent-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import type { ClaudeAgentQueryOptions } from './adapters/claude-agent-adapter'
import { isPromptTooLongError } from './adapters/claude-agent-adapter'
import { AgentEventBus } from './agent-event-bus'
import { decryptApiKey, getChannelById, listChannels } from './channel-manager'
import { getAdapter, fetchTitle, normalizeAnthropicBaseUrlForSdk } from '@proma/core'
import { getAdapter, fetchTitle, normalizeAnthropicBaseUrlForSdk, normalizeOpenAIBaseUrl } from '@proma/core'
import { getFetchFn } from './proxy-fetch'
import { getEffectiveProxyUrl } from './proxy-settings-service'
import { appendAgentMessage, updateAgentSessionMeta, getAgentSessionMeta, getAgentSessionMessages } from './agent-session-manager'
Expand Down Expand Up @@ -564,9 +564,16 @@ export class AgentOrchestrator {
const apiKey = decryptApiKey(channelId)
const providerAdapter = getAdapter(channel.provider)
const request = providerAdapter.buildTitleRequest({
baseUrl: channel.baseUrl,
baseUrl:
channel.provider === 'openai'
? normalizeOpenAIBaseUrl(channel.baseUrl)
: channel.baseUrl,
apiKey,
modelId,
apiFormat:
channel.provider === 'openai' || channel.provider === 'custom'
? channel.apiFormat
: 'chat_completions',
prompt: TITLE_PROMPT + userMessage,
})

Expand Down Expand Up @@ -1461,4 +1468,4 @@ export class AgentOrchestrator {
this.adapter.dispose()
this.activeSessions.clear()
}
}
}
145 changes: 136 additions & 9 deletions apps/electron/src/main/lib/channel-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type {
} from '@proma/shared'
import { getFetchFn } from './proxy-fetch'
import { getEffectiveProxyUrl } from './proxy-settings-service'
import { normalizeAnthropicBaseUrl, normalizeBaseUrl } from '@proma/core'
import { normalizeAnthropicBaseUrl, normalizeBaseUrl, normalizeOpenAIBaseUrl, probeOpenAICompatibleModelsBaseUrl } from '@proma/core'

/** 当前配置版本 */
const CONFIG_VERSION = 1
Expand Down Expand Up @@ -132,11 +132,17 @@ export function createChannel(input: ChannelCreateInput): Channel {
const config = readConfig()
const now = Date.now()

const effectiveApiFormat =
input.provider === 'openai' || input.provider === 'custom'
? input.apiFormat
: undefined

const channel: Channel = {
id: randomUUID(),
name: input.name,
provider: input.provider,
baseUrl: input.baseUrl,
apiFormat: effectiveApiFormat,
apiKey: encryptApiKey(input.apiKey),
models: input.models,
enabled: input.enabled,
Expand Down Expand Up @@ -168,11 +174,18 @@ export function updateChannel(id: string, input: ChannelUpdateInput): Channel {

const existing = config.channels[index]!

const nextProvider = input.provider ?? existing.provider
const effectiveApiFormat =
nextProvider === 'openai' || nextProvider === 'custom'
? (input.apiFormat ?? existing.apiFormat)
: undefined

const updated: Channel = {
...existing,
name: input.name ?? existing.name,
provider: input.provider ?? existing.provider,
provider: nextProvider,
baseUrl: input.baseUrl ?? existing.baseUrl,
apiFormat: effectiveApiFormat,
apiKey: input.apiKey ? encryptApiKey(input.apiKey) : existing.apiKey,
models: input.models ?? existing.models,
enabled: input.enabled ?? existing.enabled,
Expand Down Expand Up @@ -240,14 +253,16 @@ export async function testChannel(channelId: string): Promise<ChannelTestResult>
case 'anthropic':
return await testAnthropic(channel.baseUrl, apiKey, proxyUrl)
case 'openai':
return await testOpenAICompatible(channel.baseUrl, apiKey, proxyUrl, true)
case 'deepseek':
case 'moonshot':
case 'zhipu':
case 'minimax':
case 'doubao':
case 'qwen':
case 'custom':
return await testOpenAICompatible(channel.baseUrl, apiKey, proxyUrl)
case 'custom':
return await testCustomOpenAICompatible(channel.baseUrl, apiKey, proxyUrl)
case 'google':
return await testGoogle(channel.baseUrl, apiKey, proxyUrl)
default:
Expand Down Expand Up @@ -297,8 +312,13 @@ async function testAnthropic(baseUrl: string, apiKey: string, proxyUrl?: string)
/**
* 测试 OpenAI 兼容 API 连接(OpenAI / DeepSeek / Custom)
*/
async function testOpenAICompatible(baseUrl: string, apiKey: string, proxyUrl?: string): Promise<ChannelTestResult> {
const url = normalizeBaseUrl(baseUrl)
async function testOpenAICompatible(
baseUrl: string,
apiKey: string,
proxyUrl?: string,
ensureV1: boolean = false,
): Promise<ChannelTestResult> {
const url = ensureV1 ? normalizeOpenAIBaseUrl(baseUrl) : normalizeBaseUrl(baseUrl)
const fetchFn = getFetchFn(proxyUrl)

const response = await fetchFn(`${url}/models`, {
Expand All @@ -320,6 +340,60 @@ async function testOpenAICompatible(baseUrl: string, apiKey: string, proxyUrl?:
return { success: false, message: `请求失败 (${response.status}): ${text.slice(0, 200)}` }
}

/**
* 测试 OpenAI 兼容 API 连接(Custom 专用:自动探测是否需要 /v1)
*
* 同时探测:
* - {baseUrl}/models
* - {baseUrl}/v1/models
*
* 以探测结果决定推荐的 Base URL(优先选择 /v1)。
*/
async function testCustomOpenAICompatible(baseUrl: string, apiKey: string, proxyUrl?: string): Promise<ChannelTestResult> {
const fetchFn = getFetchFn(proxyUrl)
const baseNoV1 = normalizeBaseUrl(baseUrl)
const baseV1 = normalizeOpenAIBaseUrl(baseUrl)

const { best, probes, resolvedBaseUrl } = await probeOpenAICompatibleModelsBaseUrl({
baseUrl,
apiKey,
fetchFn,
})
const suffixHint = resolvedBaseUrl && resolvedBaseUrl === baseV1 && baseV1 !== baseNoV1
? '(已自动补全 /v1)'
: ''

if (best.ok) {
return { success: true, message: `连接成功${suffixHint}`, resolvedBaseUrl }
}

if (best.status === 401) {
return { success: false, message: `API Key 无效${suffixHint}`, resolvedBaseUrl }
}

if (best.status === 404 && probes.every((p) => p.status === 404)) {
return {
success: false,
message: '请求失败 (404): 未找到 /models 或 /v1/models,请检查 Base URL 是否正确',
resolvedBaseUrl,
}
}

if (best.status > 0) {
return {
success: false,
message: `请求失败 (${best.status})${best.bodyPreview ? `: ${best.bodyPreview}` : ''}${suffixHint}`,
resolvedBaseUrl,
}
}

return {
success: false,
message: `连接测试失败: ${best.error ?? '未知错误'}${suffixHint}`,
resolvedBaseUrl,
}
}

/**
* 测试 Google Generative AI API 连接
*/
Expand Down Expand Up @@ -359,14 +433,16 @@ export async function testChannelDirect(input: FetchModelsInput): Promise<Channe
case 'anthropic':
return await testAnthropic(input.baseUrl, input.apiKey, proxyUrl)
case 'openai':
return await testOpenAICompatible(input.baseUrl, input.apiKey, proxyUrl, true)
case 'deepseek':
case 'moonshot':
case 'zhipu':
case 'minimax':
case 'doubao':
case 'qwen':
case 'custom':
return await testOpenAICompatible(input.baseUrl, input.apiKey, proxyUrl)
case 'custom':
return await testCustomOpenAICompatible(input.baseUrl, input.apiKey, proxyUrl)
case 'google':
return await testGoogle(input.baseUrl, input.apiKey, proxyUrl)
default:
Expand Down Expand Up @@ -394,14 +470,16 @@ export async function fetchModels(input: FetchModelsInput): Promise<FetchModelsR
case 'anthropic':
return await fetchAnthropicModels(input.baseUrl, input.apiKey, proxyUrl)
case 'openai':
return await fetchOpenAICompatibleModels(input.baseUrl, input.apiKey, proxyUrl, true)
case 'deepseek':
case 'moonshot':
case 'zhipu':
case 'minimax':
case 'doubao':
case 'qwen':
case 'custom':
return await fetchOpenAICompatibleModels(input.baseUrl, input.apiKey, proxyUrl)
case 'custom':
return await fetchCustomOpenAICompatibleModels(input.baseUrl, input.apiKey, proxyUrl)
case 'google':
return await fetchGoogleModels(input.baseUrl, input.apiKey, proxyUrl)
default:
Expand Down Expand Up @@ -482,8 +560,13 @@ interface OpenAIModelItem {
* API: GET {baseUrl}/models
* 通用 OpenAI 兼容格式,适用于大部分第三方供应商。
*/
async function fetchOpenAICompatibleModels(baseUrl: string, apiKey: string, proxyUrl?: string): Promise<FetchModelsResult> {
const url = normalizeBaseUrl(baseUrl)
async function fetchOpenAICompatibleModels(
baseUrl: string,
apiKey: string,
proxyUrl?: string,
ensureV1: boolean = false,
): Promise<FetchModelsResult> {
const url = ensureV1 ? normalizeOpenAIBaseUrl(baseUrl) : normalizeBaseUrl(baseUrl)
const fetchFn = getFetchFn(proxyUrl)

const response = await fetchFn(`${url}/models`, {
Expand Down Expand Up @@ -521,6 +604,50 @@ async function fetchOpenAICompatibleModels(baseUrl: string, apiKey: string, prox
}
}

/**
* 从 OpenAI 兼容服务拉取模型列表(Custom:自动探测是否需要 /v1)
*
* 对于第三方 OpenAI 兼容服务,用户可能输入:
* - https://host
* - https://host/v1
*
* 这里复用探测逻辑,优先选择更合适的 Base URL,再发起 /models 请求,
* 以提升“未先点测试连接就直接拉取模型”的成功率。
*/
async function fetchCustomOpenAICompatibleModels(
baseUrl: string,
apiKey: string,
proxyUrl?: string,
): Promise<FetchModelsResult> {
const fetchFn = getFetchFn(proxyUrl)
const baseNoV1 = normalizeBaseUrl(baseUrl)
const baseV1 = normalizeOpenAIBaseUrl(baseUrl)

const { best, probes, resolvedBaseUrl } = await probeOpenAICompatibleModelsBaseUrl({
baseUrl,
apiKey,
fetchFn,
})

const suffixHint = resolvedBaseUrl === baseV1 && baseV1 !== baseNoV1
? '(已自动补全 /v1)'
: ''

if (best.status === 404 && probes.every((p) => p.status === 404)) {
return {
success: false,
message: `请求失败 (404): 未找到 /models 或 /v1/models,请检查 Base URL 是否正确${suffixHint}`,
models: [],
}
}

const result = await fetchOpenAICompatibleModels(resolvedBaseUrl, apiKey, proxyUrl, false)
return {
...result,
message: `${result.message}${suffixHint}`,
}
}

/**
* Google Generative AI 模型响应项
*/
Expand Down
Loading