Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/b-m-capital-research_honeclaw.json

1 line
385 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "b-m-capital-research/honeclaw", "files": {"packages/app/src/pages/settings.tsx": {"adf14cae4ada95b61f9ac3b277a864035b231dbb": {"t": 1785674007.6366925, "n": 118234, "h": "1adc4457b5c9dbfb", "c": "import {\n For,\n Index,\n Show,\n createEffect,\n createMemo,\n createResource,\n createSignal,\n type Setter,\n} from \"solid-js\";\nimport { useSearchParams } from \"@solidjs/router\";\nimport { useBackend } from \"@/context/backend\";\nimport {\n checkDesktopAgentCli,\n loadDesktopAgentSettings,\n testDesktopOpenAiChannel,\n loadDesktopFmpSettings,\n saveDesktopFmpSettings,\n loadDesktopTavilySettings,\n saveDesktopTavilySettings,\n} from \"@/lib/backend\";\nimport {\n createWebInvite,\n disableWebInvite,\n enableWebInvite,\n getWebInvites,\n getWebInviteApiKey,\n resetWebInvite,\n resetWebInviteApiKey,\n} from \"@/lib/api\";\nimport { NotificationPreferencesCard } from \"@/components/notification-preferences-card\";\nimport type {\n AgentProvider,\n AgentSettings,\n DesktopChannelSettings,\n DesktopChannelSettingsInput,\n WebInviteInfo,\n} from \"@/lib/types\";\nimport {\n appendApiKeyDraftState,\n canSelectRunner,\n canShowSettingsTab,\n CHANNEL_CHAT_SCOPES,\n defaultAgentSettings,\n defaultChannelDraft,\n defaultFmpSettings,\n defaultLanguageDraft,\n defaultTavilySettings,\n formatCsv,\n inviteActionKey as buildInviteActionKey,\n isAgentSettingsRuntimeMismatch,\n isInviteActionRunning as isInviteActionKeyRunning,\n mergeAuxiliaryDraft,\n mergeAgentSettings,\n mergeHoneCloudDraft,\n normalizePhoneNumber,\n optionalNumber,\n parseCsv,\n prependWebInvite,\n removeApiKeyDraftState,\n replaceWebInvite,\n resolveSettingsTab,\n resolveHoneCloudOpenAiBaseUrl,\n SETTINGS_TAB_KEYS,\n toApiKeyDraftState,\n toChannelDraft,\n toggleApiKeyDraftState,\n updateApiKeyDraftState,\n type ApiKeyDraftState,\n updateLlmProfileBinding as updateLlmProfileBindingDraft,\n updateLlmProfileEntry as updateLlmProfileEntryDraft,\n type InviteAction,\n type LanguageDraft,\n type LlmProfileBindingKey,\n type SettingsTabKey,\n} from \"@/pages/settings-model\";\nimport { SETTINGS } from \"@/lib/admin-content/settings\";\nimport { tpl } from \"@/lib/i18n\";\n\ntype LlmProfileSettingsDraft = NonNullable<AgentSettings[\"llmProfiles\"]>;\ntype LlmProfileEntryDraft = LlmProfileSettingsDraft[\"profiles\"][number];\ntype LlmProfileBindingRow = { key: LlmProfileBindingKey; label: string };\ntype ApiKeyDraftStateSetter<T extends { apiKeys: string[] }> = Setter<\n ApiKeyDraftState<T>\n>;\ntype CheckStatus = \"idle\" | \"checking\" | \"ok\" | \"error\";\ntype CheckProbeResult = { ok: boolean; message: string };\n\nasync function runCheckState(\n setStatus: (value: CheckStatus) => void,\n setMessage: (value: string) => void,\n probe: () => Promise<CheckProbeResult>,\n) {\n setStatus(\"checking\");\n setMessage(\"\");\n try {\n const checkResult = await probe();\n setStatus(checkResult.ok ? \"ok\" : \"error\");\n setMessage(checkResult.message);\n } catch (e) {\n setStatus(\"error\");\n setMessage(e instanceof Error ? e.message : String(e));\n }\n}\n\nfunction createCheckState() {\n const [status, setStatus] = createSignal<CheckStatus>(\"idle\");\n const [message, setMessage] = createSignal(\"\");\n const run = (probe: () => Promise<CheckProbeResult>) =>\n runCheckState(setStatus, setMessage, probe);\n return { status, message, run };\n}\n\nfunction checkFeedbackClass(status: CheckStatus) {\n return [\n \"flex items-center gap-2 rounded-lg border px-3 py-2 text-xs\",\n status === \"checking\"\n ? \"border-amber-300/40 bg-amber-500/10 text-amber-300\"\n : status === \"ok\"\n ? \"border-emerald-300/40 bg-emerald-500/10 text-emerald-300\"\n : \"border-rose-300/40 bg-rose-500/10 text-rose-300\",\n ].join(\" \");\n}\n\nfunction CheckStatusBanner(props: {\n status: CheckStatus;\n checkingMessage: string;\n message: string;\n showIcon?: boolean;\n}) {\n return (\n <div class={checkFeedbackClass(props.status)}>\n <Show when={props.showIcon && props.status === \"checking\"}>\n <svg\n class=\"h-3.5 w-3.5 shrink-0 animate-spin\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n >\n <circle\n class=\"opacity-25\"\n cx=\"12\"\n cy=\"12\"\n r=\"10\"\n stroke=\"currentColor\"\n stroke-width=\"4\"\n />\n <path\n class=\"opacity-75\"\n fill=\"currentColor\"\n d=\"M4 12a8 8 0 018-8V0C5.373 0 22 6.477 22 12h-4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"\n />\n </svg>\n </Show>\n <Show when={props.showIcon && props.status === \"ok\"}>\n <svg\n class=\"h-3.5 w-3.5 shrink-0\"\n viewBox=\"0 0 20 20\"\n fill=\"currentColor\"\n >\n <path\n fill-rule=\"evenodd\"\n d=\"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z\"\n clip-rule=\"evenodd\"\n />\n </svg>\n </Show>\n <Show when={props.showIcon && props.status === \"error\"}>\n <svg\n class=\"h-3.5 w-3.5 shrink-0\"\n viewBox=\"0 0 20 20\"\n fill=\"currentColor\"\n >\n <path\n fill-rule=\"evenodd\"\n d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z\"\n clip-rule=\"evenodd\"\n />\n </svg>\n </Show>\n <span>\n {props.status === \"checking\" ? props.checkingMessage : props.message}\n </span>\n </div>\n );\n}\n\nexport default function SettingsPage() {\n const backend = useBackend();\n const [channelDraft, setChannelDraft] =\n createSignal<DesktopChannelSettingsInput>(defaultChannelDraft());\n const updateChannelDraft = (patch: Partial<DesktopChannelSettingsInput>) => {\n setChannelDraft((prev) => ({ ...prev, ...patch }));\n };\n const [desktopChannelSettings, setDesktopChannelSettings] =\n createSignal<DesktopChannelSettings>();\n const [channelSettingsReloadToken, setChannelSettingsReloadToken] =\n createSignal(0);\n const refetchDesktopChannelSettings = async () => {\n if (!backend.state.connected) {\n setDesktopChannelSettings(undefined);\n return undefined;\n }\n const settings = await backend.loadChannelSettings();\n setDesktopChannelSettings(settings);\n return settings;\n };\n createEffect(() => {\n channelSettingsReloadToken();\n if (!backend.state.connected) {\n setDesktopChannelSettings(undefined);\n return;\n }\n void refetchDesktopChannelSettings().catch((error) => {\n console.warn(\"loadChannelSettings failed\", error);\n });\n });\n\n // ── 界面语言 ────────────────────────────────────────────────────────────────\n const [languageDraft, setLanguageDraft] = createSignal<LanguageDraft>(\n defaultLanguageDraft(backend.state.meta),\n );\n const [languageSaving, setLanguageSaving] = createSignal(false);\n const [languageMessage, setLanguageMessage] = createSignal(\"\");\n const [languageError, setLanguageError] = createSignal(\"\");\n createEffect(() => {\n // Re-sync draft whenever the canonical meta language changes (e.g. after\n // a save round-trip or another device pushed an update on reconnect).\n setLanguageDraft(defaultLanguageDraft(backend.state.meta));\n });\n const languageDirty = createMemo(\n () => languageDraft() !== defaultLanguageDraft(backend.state.meta),\n );\n const submitLanguage = async (event: Event) => {\n event.preventDefault();\n setLanguageSaving(true);\n setLanguageMessage(\"\");\n setLanguageError(\"\");\n try {\n await backend.saveLanguage(languageDraft());\n setLanguageMessage(SETTINGS.language.saved);\n } catch (e) {\n setLanguageError(e instanceof Error ? e.message : String(e));\n } finally {\n setLanguageSaving(false);\n }\n };\n\n // ── Agent 基础设置 ──────────────────────────────────────────────────────────\n const [agentDraft, setAgentDraft] = createSignal<AgentSettings>(\n defaultAgentSettings(),\n );\n const updateAgentDraft = (patch: Partial<AgentSettings>) => {\n setAgentDraft((prev) => ({ ...prev, ...patch }));\n };\n const [agentSaving, setAgentSaving] = createSignal(false);\n const [agentMessage, setAgentMessage] = createSignal(\"\");\n const [agentError, setAgentError] = createSignal(\"\");\n\n const openaiCheck = createCheckState();\n const honeCloudCheck = createCheckState();\n const auxiliaryCheck = createCheckState();\n const geminiCheck = createCheckState();\n const codexAcpCheck = createCheckState();\n const [showHoneCloudKey, setShowHoneCloudKey] = createSignal(false);\n const [showOpenaiKey, setShowOpenaiKey] = createSignal(false);\n const [showAuxiliaryKey, setShowAuxiliaryKey] = createSignal(false);\n const profileIdOptions = createMemo(\n () =>\n agentDraft()\n .llmProfiles?.profiles.map((profile) => profile.id.trim())\n .filter(Boolean) ?? [],\n );\n const llmProfileBindingRows: LlmProfileBindingRow[] = [\n {\n key: \"defaultProfile\",\n label: SETTINGS.agent.openai.profiles_default_label,\n },\n {\n key: \"auxiliaryProfile\",\n label: SETTINGS.agent.openai.profiles_auxiliary_label,\n },\n {\n key: \"polishProfile\",\n label: SETTINGS.agent.openai.profiles_polish_label,\n },\n {\n key: \"newsClassifierProfile\",\n label: SETTINGS.agent.openai.profiles_news_label,\n },\n {\n key: \"filingSummaryProfile\",\n label: SETTINGS.agent.openai.profiles_filing_label,\n },\n {\n key: \"earningsQualityProfile\",\n label: SETTINGS.agent.openai.profiles_earnings_label,\n },\n {\n key: \"digestPass1Profile\",\n label: SETTINGS.agent.openai.profiles_digest_pass1_label,\n },\n {\n key: \"digestPass2Profile\",\n label: SETTINGS.agent.openai.profiles_digest_pass2_label,\n },\n {\n key: \"digestEventDedupeProfile\",\n label: SETTINGS.agent.openai.profiles_digest_dedupe_label,\n },\n {\n key: \"mainlineDistillProfile\",\n label: SETTINGS.agent.openai.profiles_mainline_label,\n },\n ];\n const [showFeishuSecret, setShowFeishuSecret] = createSignal(false);\n const [showTelegramToken, setShowTelegramToken] = createSignal(false);\n const [showDiscordToken, setShowDiscordToken] = createSignal(false);\n const [inviteMessage, setInviteMessage] = createSignal(\"\");\n const [inviteError, setInviteError] = createSignal(\"\");\n const [inviteCreating, setInviteCreating] = createSignal(false);\n const [inviteActionKey, setInviteActionKey] = createSignal(\"\");\n const [invitePhoneNumber, setInvitePhoneNumber] = createSignal(\"\");\n const clearInviteFeedback = () => {\n setInviteMessage(\"\");\n setInviteError(\"\");\n };\n\n const [webInvites, setWebInvites] = createSignal<WebInviteInfo[]>([]);\n const [webInvitesReloadToken, setWebInvitesReloadToken] = createSignal(0);\n const refetchWebInvites = async () => {\n if (!backend.state.connected || !backend.hasCapability(\"web_invites\")) {\n setWebInvites([]);\n return [];\n }\n const invites = await getWebInvites();\n setWebInvites(invites);\n return invites;\n };\n createEffect(() => {\n webInvitesReloadToken();\n if (!backend.state.connected || !backend.hasCapability(\"web_invites\")) {\n setWebInvites([]);\n return;\n }\n void refetchWebInvites().catch((error) => {\n console.warn(\"getWebInvites failed\", error);\n setWebInvites([]);\n });\n });\n\n const [agentSettingsRes] = createResource(\n () => backend.state.isDesktop,\n async (isDesktop) => {\n if (!isDesktop) return undefined;\n return loadDesktopAgentSettings();\n },\n );\n\n createEffect(() => {\n const s = agentSettingsRes();\n if (s) {\n setAgentDraft(mergeAgentSettings(s));\n }\n });\n\n // ── FMP API Keys 设置 ───────────────────────────────────────────────────────\n const [fmpDraftState, setFmpDraftState] = createSignal(\n toApiKeyDraftState(defaultFmpSettings()),\n );\n const fmpDraft = () => fmpDraftState().settings;\n const showFmpKeys = () => fmpDraftState().visibility;\n const [fmpSaving, setFmpSaving] = createSignal(false);\n\n const [fmpSettingsRes] = createResource(\n () => backend.state.isDesktop,\n async (isDesktop) => {\n if (!isDesktop) return undefined;\n return loadDesktopFmpSettings();\n },\n );\n\n createEffect(() => {\n const s = fmpSettingsRes();\n if (s) {\n setFmpDraftState(toApiKeyDraftState(s));\n }\n });\n\n const submitFmpSettings = async (event: Event) => {\n event.preventDefault();\n setFmpSaving(true);\n try {\n await saveDesktopFmpSettings(fmpDraft());\n } catch {\n } finally {\n setFmpSaving(false);\n }\n };\n\n // ── Tavily API Keys 设置 ────────────────────────────────────────────────────\n const [tavilyDraftState, setTavilyDraftState] = createSignal(\n toApiKeyDraftState(defaultTavilySettings()),\n );\n const tavilyDraft = () => tavilyDraftState().settings;\n const showTavilyKeys = () => tavilyDraftState().visibility;\n const [tavilySaving, setTavilySaving] = createSignal(false);\n\n const [tavilySettingsRes] = createResource(\n () => backend.state.isDesktop,\n async (isDesktop) => {\n if (!isDesktop) return undefined;\n return loadDesktopTavilySettings();\n },\n );\n\n createEffect(() => {\n const s = tavilySettingsRes();\n if (s) {\n setTavilyDraftState(toApiKeyDraftState(s));\n }\n });\n\n const submitTavilySettings = async (event: Event) => {\n event.preventDefault();\n setTavilySaving(true);\n try {\n await saveDesktopTavilySettings(tavilyDraft());\n } catch {\n } finally {\n setTavilySaving(false);\n }\n };\n\n // ── 多 Key 输入辅助函数 ──────────────────────────────────────────────────────\n /** 更新指定索引的 key 值 */\n function updateApiKeyDraft<T extends { apiKeys: string[] }>(\n setter: ApiKeyDraftStateSetter<T>,\n index: number,\n value: string,\n ) {\n setter((prev) => updateApiKeyDraftState(prev, index, value));\n }\n\n /** 追加一个空 key 输入行 */\n function addApiKeyDraftRow<T extends { apiKeys: string[] }>(\n setter: ApiKeyDraftStateSetter<T>,\n ) {\n setter((prev) => appendApiKeyDraftState(prev));\n }\n\n /** 删除指定索引的 key */\n function removeApiKeyDraftRow<T extends { apiKeys: string[] }>(\n setter: ApiKeyDraftStateSetter<T>,\n index: number,\n ) {\n setter((prev) => removeApiKeyDraftState(prev, index));\n }\n\n /** 切换指定索引的 key 显示/隐藏 */\n function toggleApiKeyDraftVisibility<T extends { apiKeys: string[] }>(\n setter: ApiKeyDraftStateSetter<T>,\n index: number,\n ) {\n setter((prev) => toggleApiKeyDraftState(prev, index));\n }\n\n const updateHoneCloudDraft = (\n patch: Partial<NonNullable<AgentSettings[\"honeCloud\"]>>,\n ) => {\n setAgentDraft((prev) => mergeHoneCloudDraft(prev, patch));\n };\n\n const updateAuxiliaryDraft = (\n patch: Partial<NonNullable<AgentSettings[\"auxiliary\"]>>,\n ) => {\n setAgentDraft((prev) => mergeAuxiliaryDraft(prev, patch));\n };\n\n // ── OpenAI-compatible endpoint tests ───────────────────────────────────────\n const handleTestOpenAi = async () => {\n await openaiCheck.run(async () => {\n const d = agentDraft();\n return testDesktopOpenAiChannel(\n d.openaiUrl,\n d.openaiModel,\n d.openaiApiKey,\n );\n });\n };\n\n const handleTestHoneCloud = async () => {\n await honeCloudCheck.run(\n async () => {\n const d = agentDraft().honeCloud;\n return testDesktopOpenAiChannel(\n resolveHoneCloudOpenAiBaseUrl(d?.baseUrl),\n d?.model || \"hone-cloud\",\n d?.apiKey ?? \"\",\n );\n },\n );\n };\n\n const handleTestAuxiliary = async () => {\n await auxiliaryCheck.run(\n async () => {\n const auxiliary = agentDraft().auxiliary;\n return testDesktopOpenAiChannel(\n auxiliary?.baseUrl ?? \"\",\n auxiliary?.model ?? \"\",\n auxiliary?.apiKey ?? \"\",\n );\n },\n );\n };\n\n // ── CLI / ACP checks ───────────────────────────────────────────────────────\n const handleCheckGemini = async () => {\n await geminiCheck.run(() => checkDesktopAgentCli(\"gemini_cli\"));\n };\n\n const handleCheckCodexAcp = async () => {\n await codexAcpCheck.run(() => checkDesktopAgentCli(\"codex_acp\"));\n };\n\n // ── 选中某个 runner 并立即保存 ───────────────────────────────────────────────\n const selectRunner = async (runner: AgentProvider) => {\n const previous = agentDraft();\n if (!canSelectRunner(previous.runner, runner, agentSaving())) return;\n const next = { ...previous, runner };\n setAgentDraft(next);\n setAgentSaving(true);\n setAgentMessage(\"\");\n setAgentError(\"\");\n try {\n const saveResult = await backend.saveAgentSettings(next);\n if (isAgentSettingsRuntimeMismatch(saveResult)) {\n setAgentError(saveResult.message);\n } else {\n setAgentMessage(saveResult.message);\n }\n } catch (e) {\n setAgentDraft(previous);\n setAgentError(e instanceof Error ? e.message : String(e));\n } finally {\n setAgentSaving(false);\n }\n };\n\n const submitAgentSettings = async (event: Event) => {\n event.preventDefault();\n setAgentSaving(true);\n setAgentMessage(\"\");\n setAgentError(\"\");\n try {\n const saveResult = await backend.saveAgentSettings(agentDraft());\n if (isAgentSettingsRuntimeMismatch(saveResult)) {\n setAgentError(saveResult.message);\n } else {\n setAgentMessage(saveResult.message);\n }\n } catch (e) {\n setAgentError(e instanceof Error ? e.message : String(e));\n } finally {\n setAgentSaving(false);\n }\n };\n\n const updateLlmProfiles = (\n updater: (current: LlmProfileSettingsDraft) => LlmProfileSettingsDraft,\n ) => {\n setAgentDraft((prev) => ({\n ...prev,\n llmProfiles: updater(prev.llmProfiles ?? defaultAgentSettings().llmProfiles!),\n }));\n };\n\n const updateLlmProfileBinding = (\n key: LlmProfileBindingKey,\n value: string,\n ) => {\n updateLlmProfiles((current) =>\n updateLlmProfileBindingDraft(current, key, value),\n );\n };\n\n const updateLlmProfileEntry = (\n index: number,\n patch: Partial<LlmProfileEntryDraft>,\n ) => {\n updateLlmProfiles((current) =>\n updateLlmProfileEntryDraft(current, index, patch),\n );\n };\n\n createEffect(() => {\n const settings = desktopChannelSettings();\n if (!settings) return;\n setChannelDraft(toChannelDraft(settings));\n });\n\n const submitChannels = async (event: Event) => {\n event.preventDefault();\n try {\n const saveResult = await backend.saveChannelSettings(channelDraft());\n setDesktopChannelSettings(saveResult.settings);\n } catch {\n }\n };\n\n const handleCreateInvite = async () => {\n const phoneNumber = normalizePhoneNumber(invitePhoneNumber());\n if (!phoneNumber) {\n setInviteMessage(\"\");\n setInviteError(SETTINGS.invite.phone_required);\n return;\n }\n setInviteCreating(true);\n clearInviteFeedback();\n try {\n const created = await createWebInvite(phoneNumber);\n setWebInvites((current) => prependWebInvite(current, created));\n setInvitePhoneNumber(\"\");\n setInviteMessage(\n tpl(created.api_key ? SETTINGS.invite.created_with_api_key : SETTINGS.invite.created, {\n phone: created.phone_number,\n code: created.invite_code,\n apiKey: created.api_key ?? \"\",\n }),\n );\n if (typeof navigator !== \"undefined\" && navigator.clipboard?.writeText) {\n await navigator.clipboard.writeText(\n created.api_key\n ? `Invite: ${created.invite_code}\\nAPI Key: ${created.api_key}`\n : created.invite_code,\n );\n setInviteMessage(\n tpl(\n created.api_key\n ? SETTINGS.invite.created_with_api_key_copied\n : SETTINGS.invite.created_copied,\n {\n phone: created.phone_number,\n code: created.invite_code,\n apiKey: created.api_key ?? \"\",\n },\n ),\n );\n }\n } catch (error) {\n setInviteError(error instanceof Error ? error.message : String(error));\n } finally {\n setInviteCreating(false);\n }\n };\n\n const copyInviteCode = async (code: string) => {\n clearInviteFeedback();\n try {\n if (!navigator.clipboard?.writeText) {\n throw new Error(SETTINGS.invite.copy_unsupported);\n }\n await navigator.clipboard.writeText(code);\n setInviteMessage(tpl(SETTINGS.invite.copied, { code }));\n } catch (error) {\n setInviteError(error instanceof Error ? error.message : String(error));\n }\n };\n\n const replaceInvite = (next: WebInviteInfo) => {\n setWebInvites((current) => replaceWebInvite(current, next));\n };\n\n const isInviteActionRunning = (\n userId: string,\n action: InviteAction,\n ) => isInviteActionKeyRunning(inviteActionKey(), userId, action);\n\n const runInviteAction = async (\n invite: WebInviteInfo,\n action: InviteAction,\n actionFn: () => Promise<void>,\n ) => {\n clearInviteFeedback();\n setInviteActionKey(buildInviteActionKey(invite.user_id, action));\n try {\n await actionFn();\n } catch (error) {\n setInviteError(error instanceof Error ? error.message : String(error));\n } finally {\n setInviteActionKey(\"\");\n }\n };\n\n const handleDisableInvite = async (invite: WebInviteInfo) => {\n if (typeof window !== \"undefined\") {\n const confirmed = window.confirm(\n tpl(SETTINGS.invite.disable_confirm, { userId: invite.user_id }),\n );\n if (!confirmed) return;\n }\n await runInviteAction(invite, \"disable\", async () => {\n const inviteResult = await disableWebInvite(invite.user_id);\n replaceInvite(inviteResult.invite);\n setInviteMessage(inviteResult.message);\n });\n };\n\n const handleEnableInvite = async (invite: WebInviteInfo) => {\n await runInviteAction(invite, \"enable\", async () => {\n const inviteResult = await enableWebInvite(invite.user_id);\n replaceInvite(inviteResult.invite);\n setInviteMessage(inviteResult.message);\n });\n };\n\n const handleResetInvite = async (invite: WebInviteInfo) => {\n if (typeof window !== \"undefined\") {\n const confirmed = window.confirm(\n tpl(SETTINGS.invite.reset_confirm, { userId: invite.user_id }),\n );\n if (!confirmed) return;\n }\n await runInviteAction(invite, \"reset\", async () => {\n const inviteResult = await resetWebInvite(invite.user_id);\n replaceInvite(inviteResult.invite);\n setInviteMessage(inviteResult.message);\n if (typeof navigator !== \"undefined\" && navigator.clipboard?.writeText) {\n await navigator.clipboard.writeText(inviteResult.invite.invite_code);\n setInviteMessage(\n tpl(SETTINGS.invite.reset_copied_suffix, {\n message: inviteResult.message,\n }),\n );\n }\n });\n };\n\n const copyInviteApiKey = async (apiKey: string) => {\n clearInviteFeedback();\n try {\n if (!navigator.clipboard?.writeText) {\n throw new Error(SETTINGS.invite.copy_unsupported);\n }\n await navigator.clipboard.writeText(apiKey);\n setInviteMessage(SETTINGS.invite.api_key_copied);\n } catch (error) {\n setInviteError(error instanceof Error ? error.message : String(error));\n }\n };\n\n const handleGetInviteApiKey = async (invite: WebInviteInfo) => {\n await runInviteAction(invite, \"api-key\", async () => {\n const inviteResult = await getWebInviteApiKey(invite.user_id);\n replaceInvite(inviteResult.invite);\n setInviteMessage(inviteResult.message);\n if (inviteResult.invite.api_key) {\n await copyInviteApiKey(inviteResult.invite.api_key);\n }\n });\n };\n\n const handleResetInviteApiKey = async (invite: WebInviteInfo) => {\n if (typeof window !== \"undefined\") {\n const confirmed = window.confirm(\n tpl(SETTINGS.invite.api_key_reset_confirm, { userId: invite.user_id }),\n );\n if (!confirmed) return;\n }\n await runInviteAction(invite, \"api-key-reset\", async () => {\n const inviteResult = await resetWebInviteApiKey(invite.user_id);\n replaceInvite(inviteResult.invite);\n setInviteMessage(inviteResult.message);\n if (inviteResult.invite.api_key) {\n await copyInviteApiKey(inviteResult.invite.api_key);\n }\n });\n };\n\n const tabLabel = (key: SettingsTabKey): string => SETTINGS.tabs[key];\n const [searchParams, setSearchParams] = useSearchParams<{ tab?: string }>();\n const activeTab = (): SettingsTabKey => resolveSettingsTab(searchParams.tab);\n const selectTab = (key: SettingsTabKey) => setSearchParams({ tab: key });\n let contentRef: HTMLDivElement | undefined;\n createEffect(() => {\n // track active tab and reset scroll on change\n activeTab();\n if (contentRef) contentRef.scrollTop = 0;\n });\n const isTab = (key: SettingsTabKey) => activeTab() === key;\n\n return (\n <div class=\"mx-auto flex h-full max-w-4xl flex-col\">\n <form\n onSubmit={submitLanguage}\n class=\"mb-3 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5 shadow-sm\"\n >\n <div class=\"flex items-start justify-between gap-4\">\n <div class=\"min-w-0\">\n <h2 class=\"text-base font-semibold text-[color:var(--text-primary)]\">\n {SETTINGS.language.title}\n </h2>\n <p class=\"mt-1 text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.language.subtitle}\n </p>\n </div>\n <button\n type=\"submit\"\n disabled={!languageDirty() || languageSaving()}\n class=\"shrink-0 rounded-md border border-[color:var(--accent)] bg-[color:var(--accent)] px-3 py-1.5 text-xs font-medium text-white transition disabled:cursor-not-allowed disabled:border-[color:var(--border)] disabled:bg-transparent disabled:text-[color:var(--text-muted)]\"\n >\n {languageSaving()\n ? SETTINGS.language.saving\n : SETTINGS.language.save}\n </button>\n </div>\n <div class=\"mt-3 flex flex-wrap gap-3\">\n <For each={[\"zh\", \"en\"] as const}>\n {(code) => (\n <label\n class={[\n \"flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm cursor-pointer\",\n languageDraft() === code\n ? \"border-[color:var(--accent)] bg-[color:var(--accent-soft)] text-[color:var(--text-primary)]\"\n : \"border-[color:var(--border)] bg-[color:var(--panel)] text-[color:var(--text-secondary)] hover:border-[color:var(--accent)]/50\",\n ].join(\" \")}\n >\n <input\n type=\"radio\"\n name=\"settings-language\"\n value={code}\n checked={languageDraft() === code}\n onChange={() => setLanguageDraft(code)}\n class=\"h-3.5 w-3.5\"\n />\n {code === \"zh\"\n ? SETTINGS.language.option_zh\n : SETTINGS.language.option_en}\n </label>\n )}\n </For>\n </div>\n <p class=\"mt-2 text-[11px] text-[color:var(--text-muted)]\">\n {SETTINGS.language.note}\n </p>\n <Show when={languageMessage()}>\n <p class=\"mt-2 text-xs text-[color:var(--accent)]\">\n {languageMessage()}\n </p>\n </Show>\n <Show when={languageError()}>\n <p class=\"mt-2 text-xs text-red-500\">\n {SETTINGS.language.save_failed}: {languageError()}\n </p>\n </Show>\n </form>\n <nav class=\"sticky top-0 z-10 -mx-1 flex gap-1 overflow-x-auto border-b border-[color:var(--border)] bg-[color:var(--surface)]/95 px-1 py-2 backdrop-blur\">\n <For each={SETTINGS_TAB_KEYS}>\n {(key) => (\n <Show\n when={canShowSettingsTab(\n key,\n backend.hasCapability(\"web_invites\"),\n )}\n >\n <button\n type=\"button\"\n onClick={() => selectTab(key)}\n class={[\n \"shrink-0 rounded-md px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]\",\n isTab(key)\n ? \"bg-[color:var(--accent-soft)] text-[color:var(--text-primary)]\"\n : \"text-[color:var(--text-secondary)] hover:bg-black/5 hover:text-[color:var(--text-primary)]\",\n ].join(\" \")}\n >\n {tabLabel(key)}\n </button>\n </Show>\n )}\n </For>\n </nav>\n <div\n ref={contentRef}\n class=\"flex flex-1 flex-col gap-4 overflow-y-auto py-4\"\n >\n {/* ── 基础设置 ── */}\n <div\n id=\"agent-settings\"\n classList={{ hidden: !isTab(\"agent\") }}\n class=\"rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6 shadow-sm\"\n >\n <h1 class=\"text-xl font-semibold text-[color:var(--text-primary)]\">\n {SETTINGS.agent.title}\n </h1>\n <p class=\"mt-2 text-sm text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.subtitle}\n </p>\n\n <fieldset\n disabled={\n !backend.state.isDesktop ||\n agentSettingsRes.loading ||\n agentSaving()\n }\n class=\"mt-6 space-y-4 disabled:opacity-60\"\n >\n {/* ── 卡片 0:HONE Cloud ── */}\n <div\n class={[\n \"rounded-xl border p-5 transition cursor-pointer\",\n agentDraft().runner === \"hone_cloud\"\n ? \"border-[color:var(--accent)] bg-[color:var(--accent-soft)]\"\n : \"border-[color:var(--border)] bg-[color:var(--panel)] hover:border-[color:var(--accent)]/50\",\n ].join(\" \")}\n onClick={() => void selectRunner(\"hone_cloud\")}\n >\n <div class=\"flex items-start justify-between gap-3\">\n <div>\n <div class=\"text-sm font-semibold text-[color:var(--text-primary)]\">\n {SETTINGS.agent.hone_cloud.name}\n </div>\n <div class=\"mt-0.5 text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.hone_cloud.description}\n </div>\n </div>\n <Show when={agentDraft().runner === \"hone_cloud\"}>\n <span class=\"shrink-0 rounded-full border border-[color:var(--accent)] px-2 py-0.5 text-[10px] font-medium text-[color:var(--accent)]\">\n {SETTINGS.agent.current_badge}\n </span>\n </Show>\n </div>\n\n <div class=\"mt-4 space-y-3\" onClick={(e) => e.stopPropagation()}>\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"hone-cloud-url\"\n >\n {SETTINGS.agent.hone_cloud.base_url_label}\n </label>\n <input\n id=\"hone-cloud-url\"\n type=\"url\"\n placeholder=\"https://hone-claw.com\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().honeCloud?.baseUrl ?? \"\"}\n onInput={(e) =>\n updateHoneCloudDraft({ baseUrl: e.currentTarget.value })\n }\n />\n </div>\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"hone-cloud-model\"\n >\n {SETTINGS.agent.hone_cloud.model_label}\n </label>\n <input\n id=\"hone-cloud-model\"\n type=\"text\"\n placeholder=\"hone-cloud\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().honeCloud?.model ?? \"\"}\n onInput={(e) =>\n updateHoneCloudDraft({ model: e.currentTarget.value })\n }\n />\n </div>\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"hone-cloud-key\"\n >\n {SETTINGS.agent.hone_cloud.api_key_label}\n </label>\n <div class=\"relative\">\n <input\n id=\"hone-cloud-key\"\n type={showHoneCloudKey() ? \"text\" : \"password\"}\n placeholder=\"hck_...\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 pr-16 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().honeCloud?.apiKey ?? \"\"}\n onInput={(e) =>\n updateHoneCloudDraft({ apiKey: e.currentTarget.value })\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 rounded px-2 py-0.5 text-xs text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() => setShowHoneCloudKey((v) => !v)}\n >\n {showHoneCloudKey()\n ? SETTINGS.agent.hone_cloud.hide\n : SETTINGS.agent.hone_cloud.show}\n </button>\n </div>\n </div>\n <div class=\"rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-soft)] p-3 text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.hone_cloud.contact_note}\n </div>\n <Show when={honeCloudCheck.status() !== \"idle\"}>\n <CheckStatusBanner\n status={honeCloudCheck.status()}\n checkingMessage={\n SETTINGS.agent.hone_cloud.connection_testing_status\n }\n message={honeCloudCheck.message()}\n />\n </Show>\n <div class=\"flex gap-2 pt-1\">\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={honeCloudCheck.status() === \"checking\"}\n onClick={() => void handleTestHoneCloud()}\n >\n {honeCloudCheck.status() === \"checking\"\n ? SETTINGS.agent.hone_cloud.testing\n : SETTINGS.agent.hone_cloud.test_connection}\n </button>\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--accent)] bg-[color:var(--accent-soft)] px-3 py-1.5 text-xs font-medium text-[color:var(--text-primary)] transition hover:opacity-90 disabled:opacity-50\"\n disabled={agentSaving()}\n onClick={(e) => void submitAgentSettings(e)}\n >\n {agentSaving()\n ? SETTINGS.agent.hone_cloud.saving\n : SETTINGS.agent.hone_cloud.save}\n </button>\n </div>\n </div>\n </div>\n\n {/* ── 卡片 1:OpenAI 协议渠道 ── */}\n <div\n class={[\n \"rounded-xl border p-5 transition cursor-pointer\",\n agentDraft().runner === \"opencode_acp\"\n ? \"border-[color:var(--accent)] bg-[color:var(--accent-soft)]\"\n : \"border-[color:var(--border)] bg-[color:var(--panel)] hover:border-[color:var(--accent)]/50\",\n ].join(\" \")}\n onClick={() => void selectRunner(\"opencode_acp\")}\n >\n <div class=\"flex items-start justify-between gap-3\">\n <div>\n <div class=\"text-sm font-semibold text-[color:var(--text-primary)]\">\n {SETTINGS.agent.openai.name}\n </div>\n <div class=\"mt-0.5 text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.openai.description}\n </div>\n </div>\n <Show when={agentDraft().runner === \"opencode_acp\"}>\n <span class=\"shrink-0 rounded-full border border-[color:var(--accent)] px-2 py-0.5 text-[10px] font-medium text-[color:var(--accent)]\">\n {SETTINGS.agent.current_badge}\n </span>\n </Show>\n </div>\n\n {/* 配置字段区(点击卡片内部不触发 selectRunner) */}\n <div class=\"mt-4 space-y-3\" onClick={(e) => e.stopPropagation()}>\n {/* Base URL */}\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"openai-url\"\n >\n {SETTINGS.agent.openai.base_url_label}\n </label>\n <input\n id=\"openai-url\"\n type=\"url\"\n placeholder=\"https://openrouter.ai/api/v1\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().openaiUrl}\n onInput={(e) =>\n updateAgentDraft({ openaiUrl: e.currentTarget.value })\n }\n />\n </div>\n\n {/* Model */}\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"openai-model\"\n >\n {SETTINGS.agent.openai.model_label}\n </label>\n <input\n id=\"openai-model\"\n type=\"text\"\n placeholder=\"google/gemini-2.5-pro-preview\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().openaiModel}\n onInput={(e) =>\n updateAgentDraft({ openaiModel: e.currentTarget.value })\n }\n />\n </div>\n\n <div class=\"rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-soft)] p-3\">\n <p class=\"text-xs font-medium text-[color:var(--text-primary)]\">\n {SETTINGS.agent.openai.auxiliary_title}\n </p>\n <p class=\"mt-1 text-[11px] text-[color:var(--text-muted)]\">\n {SETTINGS.agent.openai.auxiliary_subtitle}\n </p>\n <div class=\"mt-3 space-y-3\">\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"auxiliary-url\"\n >\n {SETTINGS.agent.openai.auxiliary_url_label}\n </label>\n <input\n id=\"auxiliary-url\"\n type=\"url\"\n placeholder=\"https://api.minimaxi.com/v1\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().auxiliary?.baseUrl ?? \"\"}\n onInput={(e) =>\n updateAuxiliaryDraft({ baseUrl: e.currentTarget.value })\n }\n />\n </div>\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"auxiliary-model\"\n >\n {SETTINGS.agent.openai.auxiliary_model_label}\n </label>\n <input\n id=\"auxiliary-model\"\n type=\"text\"\n placeholder=\"MiniMax-M2.7-highspeed\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().auxiliary?.model ?? \"\"}\n onInput={(e) =>\n updateAuxiliaryDraft({ model: e.currentTarget.value })\n }\n />\n </div>\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"auxiliary-apikey\"\n >\n {SETTINGS.agent.openai.auxiliary_apikey_label}\n </label>\n <div class=\"relative\">\n <input\n id=\"auxiliary-apikey\"\n type={showAuxiliaryKey() ? \"text\" : \"password\"}\n placeholder=\"sk-cp-...\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 pr-16 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().auxiliary?.apiKey ?? \"\"}\n onInput={(e) =>\n updateAuxiliaryDraft({\n apiKey: e.currentTarget.value,\n })\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 rounded px-2 py-0.5 text-xs text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() => setShowAuxiliaryKey((v) => !v)}\n >\n {showAuxiliaryKey()\n ? SETTINGS.agent.openai.hide\n : SETTINGS.agent.openai.show}\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"border-t border-[color:var(--border)] pt-4\">\n <p class=\"text-xs font-medium text-[color:var(--text-primary)]\">\n {SETTINGS.agent.openai.profiles_title}\n </p>\n <p class=\"mt-1 text-[11px] text-[color:var(--text-muted)]\">\n {SETTINGS.agent.openai.profiles_subtitle}\n </p>\n\n <div class=\"mt-3 grid gap-2 md:grid-cols-2\">\n <For each={llmProfileBindingRows}>\n {(row) => (\n <label class=\"block\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {row.label}\n </span>\n <select\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().llmProfiles?.[row.key] ?? \"\"}\n onChange={(e) =>\n updateLlmProfileBinding(\n row.key,\n e.currentTarget.value,\n )\n }\n >\n <For each={profileIdOptions()}>\n {(profileId) => (\n <option value={profileId}>{profileId}</option>\n )}\n </For>\n </select>\n </label>\n )}\n </For>\n </div>\n\n <div class=\"mt-4 space-y-3\">\n <For each={agentDraft().llmProfiles?.profiles ?? []}>\n {(profile, index) => (\n <div class=\"rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-soft)] p-3\">\n <div class=\"flex flex-wrap items-center justify-between gap-2\">\n <div class=\"font-mono text-xs font-semibold text-[color:var(--text-primary)]\">\n {profile.id}\n </div>\n <label class=\"inline-flex items-center gap-1.5 text-[11px] text-[color:var(--text-secondary)]\">\n <input\n type=\"checkbox\"\n class=\"h-3.5 w-3.5 rounded border-[color:var(--border)]\"\n checked={profile.responseFormatJson}\n onChange={(e) =>\n updateLlmProfileEntry(index(), {\n responseFormatJson: e.currentTarget.checked,\n })\n }\n />\n {SETTINGS.agent.openai.profiles_json_label}\n </label>\n </div>\n\n <div class=\"mt-3 grid gap-2 md:grid-cols-2\">\n <label class=\"block\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.openai.profiles_provider_label}\n </span>\n <input\n type=\"text\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={profile.provider}\n onInput={(e) =>\n updateLlmProfileEntry(index(), {\n provider: e.currentTarget.value,\n })\n }\n />\n </label>\n <label class=\"block\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.openai.profiles_model_label}\n </span>\n <input\n type=\"text\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={profile.model}\n onInput={(e) =>\n updateLlmProfileEntry(index(), {\n model: e.currentTarget.value,\n })\n }\n />\n </label>\n <label class=\"block\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.openai.profiles_max_tokens_label}\n </span>\n <input\n type=\"number\"\n min=\"1\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={profile.maxTokens ?? \"\"}\n onInput={(e) =>\n updateLlmProfileEntry(index(), {\n maxTokens: optionalNumber(\n e.currentTarget.value,\n ),\n })\n }\n />\n </label>\n <label class=\"block\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.openai.profiles_temperature_label}\n </span>\n <input\n type=\"number\"\n min=\"0\"\n max=\"2\"\n step=\"0.1\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={profile.temperature ?? \"\"}\n onInput={(e) =>\n updateLlmProfileEntry(index(), {\n temperature: optionalNumber(\n e.currentTarget.value,\n ),\n })\n }\n />\n </label>\n <label class=\"block\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.openai.profiles_top_p_label}\n </span>\n <input\n type=\"number\"\n min=\"0\"\n max=\"1\"\n step=\"0.05\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={profile.topP ?? \"\"}\n onInput={(e) =>\n updateLlmProfileEntry(index(), {\n topP: optionalNumber(e.currentTarget.value),\n })\n }\n />\n </label>\n <label class=\"block\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.openai.profiles_reasoning_label}\n </span>\n <select\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={profile.reasoningEffort ?? \"\"}\n onChange={(e) =>\n updateLlmProfileEntry(index(), {\n reasoningEffort:\n e.currentTarget.value || undefined,\n })\n }\n >\n <option value=\"\">default</option>\n <option value=\"low\">low</option>\n <option value=\"medium\">medium</option>\n <option value=\"high\">high</option>\n <option value=\"xhigh\">xhigh</option>\n </select>\n </label>\n <label class=\"block md:col-span-2\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {\n SETTINGS.agent.openai\n .profiles_reasoning_tokens_label\n }\n </span>\n <input\n type=\"number\"\n min=\"1\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={profile.reasoningMaxTokens ?? \"\"}\n onInput={(e) =>\n updateLlmProfileEntry(index(), {\n reasoningMaxTokens: optionalNumber(\n e.currentTarget.value,\n ),\n })\n }\n />\n </label>\n </div>\n </div>\n )}\n </For>\n </div>\n </div>\n\n {/* API Key */}\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"openai-apikey\"\n >\n {SETTINGS.agent.openai.api_key_label}\n </label>\n <div class=\"relative\">\n <input\n id=\"openai-apikey\"\n type={showOpenaiKey() ? \"text\" : \"password\"}\n placeholder=\"sk-or-...\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 pr-16 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().openaiApiKey}\n onInput={(e) =>\n updateAgentDraft({ openaiApiKey: e.currentTarget.value })\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 rounded px-2 py-0.5 text-xs text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() => setShowOpenaiKey((v) => !v)}\n >\n {showOpenaiKey()\n ? SETTINGS.agent.openai.hide\n : SETTINGS.agent.openai.show}\n </button>\n </div>\n </div>\n\n {/* 测试联通状态 */}\n <Show when={openaiCheck.status() !== \"idle\"}>\n <CheckStatusBanner\n status={openaiCheck.status()}\n checkingMessage={\n SETTINGS.agent.openai.connection_testing_status\n }\n message={openaiCheck.message()}\n showIcon\n />\n </Show>\n\n <Show when={auxiliaryCheck.status() !== \"idle\"}>\n <CheckStatusBanner\n status={auxiliaryCheck.status()}\n checkingMessage={SETTINGS.agent.openai.auxiliary_testing_status}\n message={auxiliaryCheck.message()}\n />\n </Show>\n\n {/* 反馈 */}\n {agentMessage() ? (\n <div class=\"rounded-md border border-emerald-300/40 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-300\">\n {agentMessage()}\n </div>\n ) : null}\n {agentError() ? (\n <div class=\"rounded-md border border-rose-300/40 bg-rose-500/10 px-3 py-2 text-xs text-rose-300\">\n {agentError()}\n </div>\n ) : null}\n\n {/* 操作按钮 */}\n <div class=\"flex gap-2 pt-1\">\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={openaiCheck.status() === \"checking\"}\n onClick={() => void handleTestOpenAi()}\n >\n {openaiCheck.status() === \"checking\"\n ? SETTINGS.agent.openai.testing\n : SETTINGS.agent.openai.test_connection}\n </button>\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={auxiliaryCheck.status() === \"checking\"}\n onClick={() => void handleTestAuxiliary()}\n >\n {auxiliaryCheck.status() === \"checking\"\n ? SETTINGS.agent.openai.testing\n : SETTINGS.agent.openai.test_auxiliary}\n </button>\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--accent)] bg-[color:var(--accent-soft)] px-3 py-1.5 text-xs font-medium text-[color:var(--text-primary)] transition hover:opacity-90 disabled:opacity-50\"\n disabled={agentSaving()}\n onClick={(e) => void submitAgentSettings(e)}\n >\n {agentSaving()\n ? SETTINGS.agent.openai.saving\n : SETTINGS.agent.openai.save}\n </button>\n </div>\n </div>\n </div>\n\n {/* ── 卡片 2:Codex ACP ── */}\n <div\n class={[\n \"rounded-xl border p-5 transition cursor-pointer\",\n agentDraft().runner === \"codex_acp\"\n ? \"border-[color:var(--accent)] bg-[color:var(--accent-soft)]\"\n : \"border-[color:var(--border)] bg-[color:var(--panel)] hover:border-[color:var(--accent)]/50\",\n ].join(\" \")}\n onClick={() => void selectRunner(\"codex_acp\")}\n >\n <div class=\"flex items-start justify-between gap-3\">\n <div>\n <div class=\"text-sm font-semibold text-[color:var(--text-primary)]\">\n {SETTINGS.agent.codex_acp.name}\n </div>\n <div class=\"mt-0.5 text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.codex_acp.description_prefix}\n <code class=\"rounded bg-black/20 px-1\">\n {SETTINGS.agent.codex_acp.description_code}\n </code>\n {SETTINGS.agent.codex_acp.description_suffix}\n </div>\n </div>\n <Show when={agentDraft().runner === \"codex_acp\"}>\n <span class=\"shrink-0 rounded-full border border-[color:var(--accent)] px-2 py-0.5 text-[10px] font-medium text-[color:var(--accent)]\">\n {SETTINGS.agent.current_badge}\n </span>\n </Show>\n </div>\n\n <div class=\"mt-4 space-y-3\" onClick={(e) => e.stopPropagation()}>\n <Show when={codexAcpCheck.status() !== \"idle\"}>\n <CheckStatusBanner\n status={codexAcpCheck.status()}\n checkingMessage={SETTINGS.agent.codex_acp.checking_status}\n message={codexAcpCheck.message()}\n showIcon\n />\n </Show>\n\n <div class=\"rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-3 text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.codex_acp.runtime_note}\n </div>\n\n <div class=\"flex gap-2 pt-1\">\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={codexAcpCheck.status() === \"checking\"}\n onClick={() => void handleCheckCodexAcp()}\n >\n {codexAcpCheck.status() === \"checking\"\n ? SETTINGS.agent.codex_acp.checking\n : SETTINGS.agent.codex_acp.test_connection}\n </button>\n </div>\n </div>\n </div>\n\n {/* ── 卡片 3:Gemini CLI ── */}\n <div\n class={[\n \"rounded-xl border p-5 transition cursor-pointer\",\n agentDraft().runner === \"gemini_cli\"\n ? \"border-[color:var(--accent)] bg-[color:var(--accent-soft)]\"\n : \"border-[color:var(--border)] bg-[color:var(--panel)] hover:border-[color:var(--accent)]/50\",\n ].join(\" \")}\n onClick={() => void selectRunner(\"gemini_cli\")}\n >\n <div class=\"flex items-start justify-between gap-3\">\n <div>\n <div class=\"text-sm font-semibold text-[color:var(--text-primary)]\">\n {SETTINGS.agent.gemini_cli.name}\n </div>\n <div class=\"mt-0.5 text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.gemini_cli.description_prefix}\n <code class=\"rounded bg-black/20 px-1\">\n {SETTINGS.agent.gemini_cli.description_code}\n </code>\n {SETTINGS.agent.gemini_cli.description_suffix}\n </div>\n </div>\n <Show when={agentDraft().runner === \"gemini_cli\"}>\n <span class=\"shrink-0 rounded-full border border-[color:var(--accent)] px-2 py-0.5 text-[10px] font-medium text-[color:var(--accent)]\">\n {SETTINGS.agent.current_badge}\n </span>\n </Show>\n </div>\n\n <div class=\"mt-4 space-y-3\" onClick={(e) => e.stopPropagation()}>\n {/* 检测状态 */}\n <Show when={geminiCheck.status() !== \"idle\"}>\n <CheckStatusBanner\n status={geminiCheck.status()}\n checkingMessage={SETTINGS.agent.gemini_cli.checking_status}\n message={geminiCheck.message()}\n showIcon\n />\n </Show>\n\n <div class=\"flex gap-2 pt-1\">\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={geminiCheck.status() === \"checking\"}\n onClick={() => void handleCheckGemini()}\n >\n {geminiCheck.status() === \"checking\"\n ? SETTINGS.agent.gemini_cli.checking\n : SETTINGS.agent.gemini_cli.test_connection}\n </button>\n </div>\n </div>\n </div>\n </fieldset>\n </div>\n\n <Show when={backend.hasCapability(\"web_invites\")}>\n <div\n id=\"web-invite-settings\"\n classList={{ hidden: !isTab(\"invite\") }}\n class=\"rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6 shadow-sm\"\n >\n <div class=\"flex items-start justify-between gap-4\">\n <div>\n <h1 class=\"text-xl font-semibold text-[color:var(--text-primary)]\">\n {SETTINGS.invite.title}\n </h1>\n <p class=\"mt-2 text-sm text-[color:var(--text-secondary)]\">\n {SETTINGS.invite.subtitle}\n </p>\n </div>\n <div class=\"flex gap-2\">\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-xs text-[color:var(--text-primary)] transition hover:bg-black/5\"\n onClick={() => setWebInvitesReloadToken((value) => value + 1)}\n >\n {SETTINGS.invite.refresh}\n </button>\n </div>\n </div>\n\n <div class=\"mt-4 flex flex-col gap-3 rounded-xl border border-[color:var(--border)] bg-[color:var(--panel)] p-4 lg:flex-row lg:items-end\">\n <label class=\"flex-1\">\n <div class=\"text-xs font-medium uppercase tracking-[0.14em] text-[color:var(--text-muted)]\">\n {SETTINGS.invite.phone_label}\n </div>\n <input\n type=\"tel\"\n value={invitePhoneNumber()}\n onInput={(event) =>\n setInvitePhoneNumber(\n normalizePhoneNumber(event.currentTarget.value),\n )\n }\n placeholder={SETTINGS.invite.phone_placeholder}\n autocomplete=\"tel\"\n class=\"mt-2 w-full rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-[color:var(--text-primary)] outline-none transition focus:border-[color:var(--accent)]\"\n />\n </label>\n <button\n type=\"button\"\n class=\"rounded-md bg-[color:var(--accent)] px-3 py-2 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50\"\n disabled={inviteCreating() || !invitePhoneNumber().trim()}\n onClick={() => void handleCreateInvite()}\n >\n {inviteCreating() ? SETTINGS.invite.creating : SETTINGS.invite.create}\n </button>\n </div>\n\n <Show when={inviteMessage()}>\n <div class=\"mt-4 rounded-md border border-emerald-300/40 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-300\">\n {inviteMessage()}\n </div>\n </Show>\n <Show when={inviteError()}>\n <div class=\"mt-4 rounded-md border border-rose-300/40 bg-rose-500/10 px-3 py-2 text-xs text-rose-300\">\n {inviteError()}\n </div>\n </Show>\n\n <div class=\"mt-6 overflow-hidden rounded-xl border border-[color:var(--border)]\">\n <div class=\"grid grid-cols-[1.2fr_1fr_1.1fr_0.8fr_0.8fr_0.7fr_0.9fr_1fr_auto] gap-3 bg-[color:var(--panel)] px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-[color:var(--text-muted)]\">\n <div>{SETTINGS.invite.table.code}</div>\n <div>{SETTINGS.invite.table.phone}</div>\n <div>{SETTINGS.invite.table.web_user}</div>\n <div>{SETTINGS.invite.table.status}</div>\n <div>{SETTINGS.invite.table.api_key}</div>\n <div>{SETTINGS.invite.table.sessions}</div>\n <div>{SETTINGS.invite.table.remaining}</div>\n <div>{SETTINGS.invite.table.last_login}</div>\n <div>{SETTINGS.invite.table.actions}</div>\n </div>\n <Show\n when={(webInvites() ?? []).length > 0}\n fallback={\n <div class=\"px-4 py-8 text-sm text-[color:var(--text-secondary)]\">\n {SETTINGS.invite.table.empty}\n </div>\n }\n >\n <div class=\"divide-y divide-[color:var(--border)]\">\n <For each={webInvites() ?? []}>\n {(invite) => (\n <div class=\"grid grid-cols-[1.2fr_1fr_1.1fr_0.8fr_0.8fr_0.7fr_0.9fr_1fr_auto] items-center gap-3 px-4 py-3 text-sm\">\n <div class=\"font-mono text-[color:var(--text-primary)]\">\n {invite.invite_code}\n </div>\n <div class=\"font-mono text-[color:var(--text-secondary)]\">\n {invite.phone_number || SETTINGS.invite.table.phone_unbound}\n </div>\n <div class=\"text-[color:var(--text-secondary)]\">\n {invite.user_id}\n </div>\n <div>\n <span\n class={[\n \"inline-flex rounded-full px-2.5 py-1 text-xs font-medium\",\n invite.enabled\n ? \"bg-emerald-500/10 text-emerald-300\"\n : \"bg-rose-500/10 text-rose-300\",\n ].join(\" \")}\n >\n {invite.enabled\n ? SETTINGS.invite.table.enabled\n : SETTINGS.invite.table.disabled}\n </span>\n </div>\n <div class=\"font-mono text-xs text-[color:var(--text-secondary)]\">\n {invite.api_key_prefix || SETTINGS.invite.table.api_key_missing}\n </div>\n <div class=\"text-[color:var(--text-secondary)]\">\n {invite.active_session_count}\n </div>\n <div class=\"text-[color:var(--text-secondary)]\">\n {invite.daily_limit === 0\n ? SETTINGS.invite.table.unlimited\n : `${invite.remaining_today}/${invite.daily_limit}`}\n </div>\n <div class=\"text-[color:var(--text-secondary)]\">\n {invite.last_login_at\n ? new Date(invite.last_login_at).toLocaleString()\n : SETTINGS.invite.table.never_logged_in}\n </div>\n <div class=\"flex flex-wrap justify-end gap-2\">\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] px-2.5 py-1 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60\"\n onClick={() =>\n void copyInviteCode(invite.invite_code)\n }\n >\n {SETTINGS.invite.table.copy}\n </button>\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] px-2.5 py-1 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={isInviteActionRunning(\n invite.user_id,\n \"reset\",\n )}\n onClick={() => void handleResetInvite(invite)}\n >\n {isInviteActionRunning(invite.user_id, \"reset\")\n ? SETTINGS.invite.table.resetting\n : SETTINGS.invite.table.reset}\n </button>\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] px-2.5 py-1 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={isInviteActionRunning(\n invite.user_id,\n \"api-key\",\n )}\n onClick={() => void handleGetInviteApiKey(invite)}\n >\n {isInviteActionRunning(invite.user_id, \"api-key\")\n ? SETTINGS.invite.table.api_key_getting\n : SETTINGS.invite.table.api_key_get}\n </button>\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] px-2.5 py-1 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={isInviteActionRunning(\n invite.user_id,\n \"api-key-reset\",\n )}\n onClick={() => void handleResetInviteApiKey(invite)}\n >\n {isInviteActionRunning(invite.user_id, \"api-key-reset\")\n ? SETTINGS.invite.table.api_key_resetting\n : SETTINGS.invite.table.api_key_reset}\n </button>\n <Show\n when={invite.enabled}\n fallback={\n <button\n type=\"button\"\n class=\"rounded-md border border-emerald-500/30 px-2.5 py-1 text-xs text-emerald-300 transition hover:border-emerald-400 disabled:opacity-50\"\n disabled={isInviteActionRunning(\n invite.user_id,\n \"enable\",\n )}\n onClick={() => void handleEnableInvite(invite)}\n >\n {isInviteActionRunning(invite.user_id, \"enable\")\n ? SETTINGS.invite.table.enabling\n : SETTINGS.invite.table.enable}\n </button>\n }\n >\n <button\n type=\"button\"\n class=\"rounded-md border border-rose-500/30 px-2.5 py-1 text-xs text-rose-300 transition hover:border-rose-400 disabled:opacity-50\"\n disabled={isInviteActionRunning(\n invite.user_id,\n \"disable\",\n )}\n onClick={() => void handleDisableInvite(invite)}\n >\n {isInviteActionRunning(invite.user_id, \"disable\")\n ? SETTINGS.invite.table.disabling\n : SETTINGS.invite.table.disable}\n </button>\n </Show>\n </div>\n </div>\n )}\n </For>\n </div>\n </Show>\n </div>\n </div>\n </Show>\n\n {/* ── 2. API 配置 ── */}\n <div\n id=\"api-settings\"\n classList={{ hidden: !isTab(\"data\") }}\n class=\"rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6 shadow-sm\"\n >\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10 text-amber-500 font-bold\">\n <svg\n class=\"h-5 w-5\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n >\n <path d=\"M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z\" />\n <polyline points=\"3.27 6.96 12 12.01 20.73 6.96\" />\n <line x1=\"12\" y1=\"22.08\" x2=\"12\" y2=\"12\" />\n </svg>\n </div>\n <h1 class=\"text-xl font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.data.title}\n </h1>\n </div>\n <p class=\"mt-2 text-sm text-[color:var(--text-secondary)]\">\n {SETTINGS.data.subtitle}\n </p>\n\n <div class=\"mt-8 space-y-6\">\n {/* FMP Subsection */}\n <div class=\"rounded-xl border border-[color:var(--border)] bg-[color:var(--panel)] p-5\">\n <div class=\"flex items-center justify-between\">\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-6 w-6 items-center justify-center rounded bg-emerald-500/10 text-emerald-500 font-extrabold text-[10px]\">\n FMP\n </div>\n <div>\n <div class=\"text-sm font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.data.fmp.name}\n </div>\n <div class=\"mt-0.5 text-[10px] text-[color:var(--text-secondary)]\">\n {SETTINGS.data.fmp.description}\n </div>\n </div>\n </div>\n <input\n type=\"checkbox\"\n checked={true}\n disabled\n class=\"h-3.5 w-3.5 rounded border-[color:var(--border)] text-[color:var(--accent)]\"\n />\n </div>\n <form\n class=\"mt-4 space-y-4\"\n onSubmit={(event) => void submitFmpSettings(event)}\n >\n <fieldset\n disabled={!backend.state.isDesktop || fmpSettingsRes.loading}\n class=\"space-y-3\"\n >\n <Index each={fmpDraft().apiKeys}>\n {(key, index) => (\n <div class=\"flex items-center gap-2\">\n <div class=\"relative flex-1\">\n <input\n type={showFmpKeys()[index] ? \"text\" : \"password\"}\n placeholder={SETTINGS.data.fmp.key_placeholder}\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={key()}\n onInput={(e) =>\n updateApiKeyDraft(\n setFmpDraftState,\n index,\n e.currentTarget.value,\n )\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() =>\n toggleApiKeyDraftVisibility(setFmpDraftState, index)\n }\n >\n <Show\n when={showFmpKeys()[index]}\n fallback={\n <svg\n class=\"h-3.5 w-3.5\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n />\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\"\n />\n </svg>\n }\n >\n <svg\n class=\"h-3.5 w-3.5\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.046m4.596-4.596A9.964 9.964 0 0112 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\"\n />\n </svg>\n </Show>\n </button>\n </div>\n <Show when={fmpDraft().apiKeys.length > 1}>\n <button\n type=\"button\"\n class=\"text-xs text-rose-500 px-2 font-medium\"\n onClick={() =>\n removeApiKeyDraftRow(\n setFmpDraftState,\n index,\n )\n }\n >\n {SETTINGS.data.fmp.remove}\n </button>\n </Show>\n </div>\n )}\n </Index>\n <div class=\"flex items-center justify-between pt-1\">\n <button\n type=\"button\"\n class=\"text-[10px] font-bold text-[color:var(--accent)]\"\n onClick={() => addApiKeyDraftRow(setFmpDraftState)}\n >\n {SETTINGS.data.fmp.add_key}\n </button>\n <button\n type=\"submit\"\n class=\"rounded bg-[color:var(--accent)] px-3 py-1 text-xs font-bold text-white shadow-sm\"\n disabled={fmpSaving()}\n >\n {fmpSaving() ? SETTINGS.data.fmp.saving : SETTINGS.data.fmp.save}\n </button>\n </div>\n </fieldset>\n </form>\n </div>\n\n {/* Tavily Subsection */}\n <div class=\"rounded-xl border border-[color:var(--border)] bg-[color:var(--panel)] p-5\">\n <div class=\"flex items-center justify-between\">\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-6 w-6 items-center justify-center rounded bg-blue-500/10 text-blue-500 font-extrabold text-[10px]\">\n TAV\n </div>\n <div>\n <div class=\"text-sm font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.data.tavily.name}\n </div>\n <div class=\"mt-0.5 text-[10px] text-[color:var(--text-secondary)]\">\n {SETTINGS.data.tavily.description}\n </div>\n </div>\n </div>\n <input\n type=\"checkbox\"\n checked={true}\n disabled\n class=\"h-3.5 w-3.5 rounded border-[color:var(--border)] text-[color:var(--accent)]\"\n />\n </div>\n <form\n class=\"mt-4 space-y-4\"\n onSubmit={(event) => void submitTavilySettings(event)}\n >\n <fieldset\n disabled={!backend.state.isDesktop || tavilySettingsRes.loading}\n class=\"space-y-3\"\n >\n <Index each={tavilyDraft().apiKeys}>\n {(key, index) => (\n <div class=\"flex items-center gap-2\">\n <div class=\"relative flex-1\">\n <input\n type={showTavilyKeys()[index] ? \"text\" : \"password\"}\n placeholder=\"tvly-...\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={key()}\n onInput={(e) =>\n updateApiKeyDraft(\n setTavilyDraftState,\n index,\n e.currentTarget.value,\n )\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() =>\n toggleApiKeyDraftVisibility(\n setTavilyDraftState,\n index,\n )\n }\n >\n <Show\n when={showTavilyKeys()[index]}\n fallback={\n <svg\n class=\"h-3.5 w-3.5\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n />\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\"\n />\n </svg>\n }\n >\n <svg\n class=\"h-3.5 w-3.5\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.046m4.596-4.596A9.964 9.964 0 0112 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\"\n />\n </svg>\n </Show>\n </button>\n </div>\n <Show when={tavilyDraft().apiKeys.length > 1}>\n <button\n type=\"button\"\n class=\"text-xs text-rose-500 px-2 font-medium\"\n onClick={() =>\n removeApiKeyDraftRow(\n setTavilyDraftState,\n index,\n )\n }\n >\n {SETTINGS.data.tavily.remove}\n </button>\n </Show>\n </div>\n )}\n </Index>\n <div class=\"flex items-center justify-between pt-1\">\n <button\n type=\"button\"\n class=\"text-[10px] font-bold text-[color:var(--accent)]\"\n onClick={() => addApiKeyDraftRow(setTavilyDraftState)}\n >\n {SETTINGS.data.tavily.add_key}\n </button>\n <button\n type=\"submit\"\n class=\"rounded bg-[color:var(--accent)] px-3 py-1 text-xs font-bold text-white shadow-sm\"\n disabled={tavilySaving()}\n >\n {tavilySaving()\n ? SETTINGS.data.tavily.saving\n : SETTINGS.data.tavily.save}\n </button>\n </div>\n </fieldset>\n </form>\n </div>\n </div>\n </div>\n\n {/* ── 2.5. 通知推送偏好 ── */}\n <div\n id=\"notification-prefs\"\n classList={{ hidden: !isTab(\"notify\") }}\n class=\"rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6 shadow-sm\"\n >\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/10 text-violet-500 font-bold\">\n <svg\n class=\"h-5 w-5\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n >\n <path d=\"M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9\" />\n <path d=\"M13.73 21a2 2 0 01-3.46 0\" />\n </svg>\n </div>\n <div>\n <h1 class=\"text-xl font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.notify.title}\n </h1>\n <p class=\"mt-1 text-sm text-[color:var(--text-secondary)]\">\n {SETTINGS.notify.subtitle}\n </p>\n </div>\n </div>\n <div class=\"mt-6\">\n <NotificationPreferencesCard />\n </div>\n </div>\n\n {/* ── 3. 渠道设置 ── */}\n <div\n id=\"channel-settings\"\n classList={{ hidden: !isTab(\"channel\") }}\n class=\"rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6 shadow-sm\"\n >\n <form onSubmit={(event) => void submitChannels(event)}>\n <fieldset\n disabled={!backend.state.connected || !desktopChannelSettings()}\n class=\"space-y-6 disabled:opacity-60\"\n >\n <div class=\"flex items-start justify-between gap-4\">\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/10 text-sky-500 font-bold\">\n <svg\n class=\"h-5 w-5\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n >\n <path d=\"M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z\" />\n </svg>\n </div>\n <div>\n <h1 class=\"text-xl font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.channel.title}\n </h1>\n <p class=\"mt-1 text-sm text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.subtitle}\n </p>\n </div>\n </div>\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-xs text-[color:var(--text-primary)] transition hover:bg-black/5\"\n onClick={() =>\n setChannelSettingsReloadToken((value) => value + 1)\n }\n >\n {SETTINGS.channel.refresh}\n </button>\n </div>\n\n <div class=\"grid gap-6 md:grid-cols-2\">\n {/* Feishu */}\n <div class=\"space-y-4 rounded-xl border border-[color:var(--border)] bg-[color:var(--panel)] p-5\">\n <div class=\"flex items-center justify-between\">\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-10 w-10 items-center justify-center rounded-full bg-[#3370ff]/10 text-[#3370ff]\">\n <svg\n class=\"h-6 w-6\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n >\n <path d=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.5 9h-9c-.28 0-.5-.22-.5-.5s.22-.5.5-.5h9c.28 0 .5.22.5.5s-.22.5-.5.5zm0 3h-9c-.28 0-.5-.22-.5-.5s.22-.5.5-.5h9c.28 0 .5.22.5.5s-.22.5-.5.5z\" />\n </svg>\n </div>\n <div class=\"font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.channel.feishu.name}\n </div>\n </div>\n <label class=\"relative inline-flex cursor-pointer items-center\">\n <input\n type=\"checkbox\"\n class=\"peer sr-only\"\n checked={channelDraft().feishuEnabled}\n onChange={(e) =>\n updateChannelDraft({\n feishuEnabled: e.currentTarget.checked,\n })\n }\n />\n <div class=\"peer h-5 w-9 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:bg-[color:var(--accent)] peer-checked:after:translate-x-full dark:bg-gray-700\"></div>\n </label>\n </div>\n <Show when={channelDraft().feishuEnabled}>\n <div class=\"space-y-3 pt-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.feishu.app_id_label}\n </label>\n <input\n type=\"text\"\n placeholder={SETTINGS.channel.feishu.app_id_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().feishuAppId || \"\"}\n onInput={(e) =>\n updateChannelDraft({\n feishuAppId: e.currentTarget.value,\n })\n }\n />\n </div>\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.feishu.app_secret_label}\n </label>\n <div class=\"relative\">\n <input\n type={showFeishuSecret() ? \"text\" : \"password\"}\n placeholder={SETTINGS.channel.feishu.app_secret_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 pr-14 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().feishuAppSecret || \"\"}\n onInput={(e) =>\n updateChannelDraft({\n feishuAppSecret: e.currentTarget.value,\n })\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 rounded px-1.5 py-0.5 text-[10px] text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() => setShowFeishuSecret((v) => !v)}\n >\n {showFeishuSecret()\n ? SETTINGS.channel.feishu.hide\n : SETTINGS.channel.feishu.show}\n </button>\n </div>\n </div>\n <div class=\"grid gap-3 md:grid-cols-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.chat_scope_label}\n </label>\n <select\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().feishuChatScope || \"DM_ONLY\"}\n onChange={(e) =>\n updateChannelDraft({\n feishuChatScope: e.currentTarget.value,\n })\n }\n >\n <For each={CHANNEL_CHAT_SCOPES}>\n {(scope) => <option value={scope}>{scope}</option>}\n </For>\n </select>\n </div>\n </div>\n <div class=\"grid gap-3 md:grid-cols-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.allow_emails_label}\n </label>\n <input\n type=\"text\"\n placeholder={SETTINGS.channel.common.csv_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={formatCsv(channelDraft().feishuAllowEmails)}\n onChange={(e) =>\n updateChannelDraft({\n feishuAllowEmails: parseCsv(e.currentTarget.value),\n })\n }\n />\n </div>\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.allow_open_ids_label}\n </label>\n <input\n type=\"text\"\n placeholder={SETTINGS.channel.common.csv_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={formatCsv(channelDraft().feishuAllowOpenIds)}\n onChange={(e) =>\n updateChannelDraft({\n feishuAllowOpenIds: parseCsv(e.currentTarget.value),\n })\n }\n />\n </div>\n </div>\n <div class=\"grid gap-3 md:grid-cols-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.allow_mobiles_label}\n </label>\n <input\n type=\"text\"\n placeholder={SETTINGS.channel.common.csv_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={formatCsv(channelDraft().feishuAllowMobiles)}\n onChange={(e) =>\n updateChannelDraft({\n feishuAllowMobiles: parseCsv(e.currentTarget.value),\n })\n }\n />\n </div>\n </div>\n </div>\n </Show>\n </div>\n\n {/* Discord */}\n <div class=\"space-y-4 rounded-xl border border-[color:var(--border)] bg-[color:var(--panel)] p-5\">\n <div class=\"flex items-center justify-between\">\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-10 w-10 items-center justify-center rounded-full bg-[#5865F2]/10 text-[#5865F2]\">\n <svg\n class=\"h-6 w-6\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n >\n <path d=\"M20.317 4.37c-1.215-.503-2.546-.882-3.932-1.057a.06.06 0 00-.063.03c-.157.28-.344.66-.464.945-1.497-.225-2.991-.225-4.463 0-.12-.285-.312-.665-.472-.945a.061.061 0 00-.063-.03 15.343 15.343 0 00-3.931 1.056.052.052 0 00-.024.02C4.195 7.42 2.91 10.375 3.328 13.25a.066.066 0 00.026.046 15.485 15.485 0 004.757 2.413.064.064 0 00.069-.022c.36-.492.684-1.02.954-1.574a.062.062 0 00-.034-.085c-.504-.19-1.002-.42-1.468-.69a.065.065 0 01-.006-.109c.097-.074.196-.15.291-.228a.063.063 0 01.066-.009 11.2 11.2 0 009.11 0 .063.063 0 01.067.01c.094.077.193.153.29.227a.065.065 0 01-.006.11c-.465.269-.963.499-1.467.689a.061.061 0 00-.034.086c.27.554.594 1.082.955 1.574a.063.063 0 00.068.022 15.441 15.441 0 004.759-2.413.06.06 0 00.026-.046c.491-3.415-.843-6.33-3.11-8.86a.052.052 0 00-.023-.02zM8.02 11.08c-.908 0-1.657-.84-1.657-1.87 0-1.03.731-1.87 1.657-1.87.935 0 1.666.84 1.657 1.87 0 1.03-.731 1.87-1.657 1.87zm7.96 0c-.908 0-1.657-.84-1.657-1.87 0-1.03.731-1.87 1.657-1.87.935 0 1.666.84 1.657 1.87 0 1.03-.732 1.87-1.657 1.87z\" />\n </svg>\n </div>\n <div class=\"font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.channel.discord.name}\n </div>\n </div>\n <label class=\"relative inline-flex cursor-pointer items-center\">\n <input\n type=\"checkbox\"\n class=\"peer sr-only\"\n checked={channelDraft().discordEnabled}\n onChange={(e) =>\n updateChannelDraft({\n discordEnabled: e.currentTarget.checked,\n })\n }\n />\n <div class=\"peer h-5 w-9 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:bg-[color:var(--accent)] peer-checked:after:translate-x-full dark:bg-gray-700\"></div>\n </label>\n </div>\n <Show when={channelDraft().discordEnabled}>\n <div class=\"space-y-1 pt-2\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.discord.bot_token_label}\n </label>\n <div class=\"relative\">\n <input\n type={showDiscordToken() ? \"text\" : \"password\"}\n placeholder={SETTINGS.channel.discord.bot_token_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 pr-14 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().discordBotToken || \"\"}\n onInput={(e) =>\n updateChannelDraft({\n discordBotToken: e.currentTarget.value,\n })\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 rounded px-1.5 py-0.5 text-[10px] text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() => setShowDiscordToken((v) => !v)}\n >\n {showDiscordToken()\n ? SETTINGS.channel.discord.hide\n : SETTINGS.channel.discord.show}\n </button>\n </div>\n <div class=\"grid gap-3 md:grid-cols-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.chat_scope_label}\n </label>\n <select\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().discordChatScope || \"DM_ONLY\"}\n onChange={(e) =>\n updateChannelDraft({\n discordChatScope: e.currentTarget.value,\n })\n }\n >\n <For each={CHANNEL_CHAT_SCOPES}>\n {(scope) => <option value={scope}>{scope}</option>}\n </For>\n </select>\n </div>\n </div>\n <div class=\"grid gap-3 md:grid-cols-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.allow_from_label}\n </label>\n <input\n type=\"text\"\n placeholder={SETTINGS.channel.common.csv_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={formatCsv(channelDraft().discordAllowFrom)}\n onChange={(e) =>\n updateChannelDraft({\n discordAllowFrom: parseCsv(e.currentTarget.value),\n })\n }\n />\n </div>\n </div>\n </div>\n </Show>\n </div>\n\n {/* Telegram */}\n <div class=\"space-y-4 rounded-xl border border-[color:var(--border)] bg-[color:var(--panel)] p-5\">\n <div class=\"flex items-center justify-between\">\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-10 w-10 items-center justify-center rounded-full bg-[#0088cc]/10 text-[#0088cc]\">\n <svg\n class=\"h-6 w-6\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n >\n <path d=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69.01-.03.01-.14-.07-.2-.08-.06-.19-.04-.27-.02-.12.02-1.96 1.25-5.54 3.69-.52.36-1 .54-1.43.53-.48-.01-1.4-.27-2.09-.49-.84-.27-1.51-.42-1.45-.88.03-.24.37-.48 1.02-.73 4-1.74 6.67-2.89 8.01-3.44 3.82-1.58 4.61-1.85 5.13-1.86.11 0 .37.03.54.17.14.12.18.28.2.45-.02.07-.02.13-.03.19z\" />\n </svg>\n </div>\n <div class=\"font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.channel.telegram.name}\n </div>\n </div>\n <label class=\"relative inline-flex cursor-pointer items-center\">\n <input\n type=\"checkbox\"\n class=\"peer sr-only\"\n checked={channelDraft().telegramEnabled}\n onChange={(e) =>\n updateChannelDraft({\n telegramEnabled: e.currentTarget.checked,\n })\n }\n />\n <div class=\"peer h-5 w-9 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:bg-[color:var(--accent)] peer-checked:after:translate-x-full dark:bg-gray-700\"></div>\n </label>\n </div>\n <Show when={channelDraft().telegramEnabled}>\n <div class=\"space-y-1 pt-2\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.telegram.bot_token_label}\n </label>\n <div class=\"relative\">\n <input\n type={showTelegramToken() ? \"text\" : \"password\"}\n placeholder={SETTINGS.channel.telegram.bot_token_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 pr-14 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().telegramBotToken || \"\"}\n onInput={(e) =>\n updateChannelDraft({\n telegramBotToken: e.currentTarget.value,\n })\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 rounded px-1.5 py-0.5 text-[10px] text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() => setShowTelegramToken((v) => !v)}\n >\n {showTelegramToken()\n ? SETTINGS.channel.telegram.hide\n : SETTINGS.channel.telegram.show}\n </button>\n </div>\n <div class=\"grid gap-3 md:grid-cols-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.chat_scope_label}\n </label>\n <select\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().telegramChatScope || \"DM_ONLY\"}\n onChange={(e) =>\n updateChannelDraft({\n telegramChatScope: e.currentTarget.value,\n })\n }\n >\n <For each={CHANNEL_CHAT_SCOPES}>\n {(scope) => <option value={scope}>{scope}</option>}\n </For>\n </select>\n </div>\n </div>\n <div class=\"grid gap-3 md:grid-cols-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.allow_from_label}\n </label>\n <input\n type=\"text\"\n placeholder={SETTINGS.channel.common.csv_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={formatCsv(channelDraft().telegramAllowFrom)}\n onChange={(e) =>\n updateChannelDraft({\n telegramAllowFrom: parseCsv(e.currentTarget.value),\n })\n }\n />\n </div>\n </div>\n </div>\n </Show>\n </div>\n\n {/* iMessage */}\n <div class=\"group relative overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--panel)] p-5 transition-all\">\n <div class=\"flex items-center justify-between\">\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-10 w-10 items-center justify-center rounded-full bg-[#34C759]/10 text-[#34C759]\">\n <svg\n class=\"h-6 w-6\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n >\n <path d=\"M12 2C6.48 2 2 5.92 2 10.74c0 2.22 1.03 4.25 2.74 5.75-.12.44-.74 2.1-1.74 3.5 0 0 2.13 0 4.14-1.22.88.24 1.83.37 2.86.37 5.52 0 10-3.92 10-8.74S17.52 2 12 2z\" />\n </svg>\n </div>\n <div>\n <div class=\"font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.channel.imessage.name}\n </div>\n <div class=\"text-[10px] font-bold text-amber-600\">\n {SETTINGS.channel.imessage.warning}\n </div>\n </div>\n </div>\n <label class=\"relative inline-flex cursor-pointer items-center\">\n <input\n type=\"checkbox\"\n class=\"peer sr-only\"\n checked={channelDraft().imessageEnabled}\n onChange={(e) =>\n updateChannelDraft({\n imessageEnabled: e.currentTarget.checked,\n })\n }\n />\n <div class=\"peer h-5 w-9 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:bg-[color:var(--accent)] peer-checked:after:translate-x-full dark:bg-gray-700\"></div>\n </label>\n </div>\n <Show when={channelDraft().imessageEnabled}>\n <div class=\"mt-4 space-y-3\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.target_handle_label}\n </label>\n <input\n type=\"text\"\n placeholder=\"+15551234567\"\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().imessageTargetHandle || \"\"}\n onInput={(e) =>\n updateChannelDraft({\n imessageTargetHandle: e.currentTarget.value,\n })\n }\n />\n </div>\n </div>\n </Show>\n </div>\n </div>\n\n <div class=\"mt-8 flex items-center justify-between border-t border-[color:var(--border)] pt-6\">\n <div class=\"text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.sync_note}\n </div>\n <button\n type=\"submit\"\n class=\"rounded-md bg-[color:var(--accent)] px-6 py-2 text-sm font-bold text-white shadow-sm transition-all hover:opacity-90 active:scale-95 disabled:opacity-50\"\n disabled={backend.state.saving}\n >\n {backend.state.saving\n ? SETTINGS.channel.saving\n : SETTINGS.channel.save}\n </button>\n </div>\n </fieldset>\n </form>\n </div>\n </div>\n </div>\n );\n}\n"}, "4a01a0163f6fe4ced0b3566a40543e14b4b3bb75": {"t": 1785846811.0862875, "n": 118234, "h": "1adc4457b5c9dbfb", "c": "import {\n For,\n Index,\n Show,\n createEffect,\n createMemo,\n createResource,\n createSignal,\n type Setter,\n} from \"solid-js\";\nimport { useSearchParams } from \"@solidjs/router\";\nimport { useBackend } from \"@/context/backend\";\nimport {\n checkDesktopAgentCli,\n loadDesktopAgentSettings,\n testDesktopOpenAiChannel,\n loadDesktopFmpSettings,\n saveDesktopFmpSettings,\n loadDesktopTavilySettings,\n saveDesktopTavilySettings,\n} from \"@/lib/backend\";\nimport {\n createWebInvite,\n disableWebInvite,\n enableWebInvite,\n getWebInvites,\n getWebInviteApiKey,\n resetWebInvite,\n resetWebInviteApiKey,\n} from \"@/lib/api\";\nimport { NotificationPreferencesCard } from \"@/components/notification-preferences-card\";\nimport type {\n AgentProvider,\n AgentSettings,\n DesktopChannelSettings,\n DesktopChannelSettingsInput,\n WebInviteInfo,\n} from \"@/lib/types\";\nimport {\n appendApiKeyDraftState,\n canSelectRunner,\n canShowSettingsTab,\n CHANNEL_CHAT_SCOPES,\n defaultAgentSettings,\n defaultChannelDraft,\n defaultFmpSettings,\n defaultLanguageDraft,\n defaultTavilySettings,\n formatCsv,\n inviteActionKey as buildInviteActionKey,\n isAgentSettingsRuntimeMismatch,\n isInviteActionRunning as isInviteActionKeyRunning,\n mergeAuxiliaryDraft,\n mergeAgentSettings,\n mergeHoneCloudDraft,\n normalizePhoneNumber,\n optionalNumber,\n parseCsv,\n prependWebInvite,\n removeApiKeyDraftState,\n replaceWebInvite,\n resolveSettingsTab,\n resolveHoneCloudOpenAiBaseUrl,\n SETTINGS_TAB_KEYS,\n toApiKeyDraftState,\n toChannelDraft,\n toggleApiKeyDraftState,\n updateApiKeyDraftState,\n type ApiKeyDraftState,\n updateLlmProfileBinding as updateLlmProfileBindingDraft,\n updateLlmProfileEntry as updateLlmProfileEntryDraft,\n type InviteAction,\n type LanguageDraft,\n type LlmProfileBindingKey,\n type SettingsTabKey,\n} from \"@/pages/settings-model\";\nimport { SETTINGS } from \"@/lib/admin-content/settings\";\nimport { tpl } from \"@/lib/i18n\";\n\ntype LlmProfileSettingsDraft = NonNullable<AgentSettings[\"llmProfiles\"]>;\ntype LlmProfileEntryDraft = LlmProfileSettingsDraft[\"profiles\"][number];\ntype LlmProfileBindingRow = { key: LlmProfileBindingKey; label: string };\ntype ApiKeyDraftStateSetter<T extends { apiKeys: string[] }> = Setter<\n ApiKeyDraftState<T>\n>;\ntype CheckStatus = \"idle\" | \"checking\" | \"ok\" | \"error\";\ntype CheckProbeResult = { ok: boolean; message: string };\n\nasync function runCheckState(\n setStatus: (value: CheckStatus) => void,\n setMessage: (value: string) => void,\n probe: () => Promise<CheckProbeResult>,\n) {\n setStatus(\"checking\");\n setMessage(\"\");\n try {\n const checkResult = await probe();\n setStatus(checkResult.ok ? \"ok\" : \"error\");\n setMessage(checkResult.message);\n } catch (e) {\n setStatus(\"error\");\n setMessage(e instanceof Error ? e.message : String(e));\n }\n}\n\nfunction createCheckState() {\n const [status, setStatus] = createSignal<CheckStatus>(\"idle\");\n const [message, setMessage] = createSignal(\"\");\n const run = (probe: () => Promise<CheckProbeResult>) =>\n runCheckState(setStatus, setMessage, probe);\n return { status, message, run };\n}\n\nfunction checkFeedbackClass(status: CheckStatus) {\n return [\n \"flex items-center gap-2 rounded-lg border px-3 py-2 text-xs\",\n status === \"checking\"\n ? \"border-amber-300/40 bg-amber-500/10 text-amber-300\"\n : status === \"ok\"\n ? \"border-emerald-300/40 bg-emerald-500/10 text-emerald-300\"\n : \"border-rose-300/40 bg-rose-500/10 text-rose-300\",\n ].join(\" \");\n}\n\nfunction CheckStatusBanner(props: {\n status: CheckStatus;\n checkingMessage: string;\n message: string;\n showIcon?: boolean;\n}) {\n return (\n <div class={checkFeedbackClass(props.status)}>\n <Show when={props.showIcon && props.status === \"checking\"}>\n <svg\n class=\"h-3.5 w-3.5 shrink-0 animate-spin\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n >\n <circle\n class=\"opacity-25\"\n cx=\"12\"\n cy=\"12\"\n r=\"10\"\n stroke=\"currentColor\"\n stroke-width=\"4\"\n />\n <path\n class=\"opacity-75\"\n fill=\"currentColor\"\n d=\"M4 12a8 8 0 018-8V0C5.373 0 22 6.477 22 12h-4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"\n />\n </svg>\n </Show>\n <Show when={props.showIcon && props.status === \"ok\"}>\n <svg\n class=\"h-3.5 w-3.5 shrink-0\"\n viewBox=\"0 0 20 20\"\n fill=\"currentColor\"\n >\n <path\n fill-rule=\"evenodd\"\n d=\"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z\"\n clip-rule=\"evenodd\"\n />\n </svg>\n </Show>\n <Show when={props.showIcon && props.status === \"error\"}>\n <svg\n class=\"h-3.5 w-3.5 shrink-0\"\n viewBox=\"0 0 20 20\"\n fill=\"currentColor\"\n >\n <path\n fill-rule=\"evenodd\"\n d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z\"\n clip-rule=\"evenodd\"\n />\n </svg>\n </Show>\n <span>\n {props.status === \"checking\" ? props.checkingMessage : props.message}\n </span>\n </div>\n );\n}\n\nexport default function SettingsPage() {\n const backend = useBackend();\n const [channelDraft, setChannelDraft] =\n createSignal<DesktopChannelSettingsInput>(defaultChannelDraft());\n const updateChannelDraft = (patch: Partial<DesktopChannelSettingsInput>) => {\n setChannelDraft((prev) => ({ ...prev, ...patch }));\n };\n const [desktopChannelSettings, setDesktopChannelSettings] =\n createSignal<DesktopChannelSettings>();\n const [channelSettingsReloadToken, setChannelSettingsReloadToken] =\n createSignal(0);\n const refetchDesktopChannelSettings = async () => {\n if (!backend.state.connected) {\n setDesktopChannelSettings(undefined);\n return undefined;\n }\n const settings = await backend.loadChannelSettings();\n setDesktopChannelSettings(settings);\n return settings;\n };\n createEffect(() => {\n channelSettingsReloadToken();\n if (!backend.state.connected) {\n setDesktopChannelSettings(undefined);\n return;\n }\n void refetchDesktopChannelSettings().catch((error) => {\n console.warn(\"loadChannelSettings failed\", error);\n });\n });\n\n // ── 界面语言 ────────────────────────────────────────────────────────────────\n const [languageDraft, setLanguageDraft] = createSignal<LanguageDraft>(\n defaultLanguageDraft(backend.state.meta),\n );\n const [languageSaving, setLanguageSaving] = createSignal(false);\n const [languageMessage, setLanguageMessage] = createSignal(\"\");\n const [languageError, setLanguageError] = createSignal(\"\");\n createEffect(() => {\n // Re-sync draft whenever the canonical meta language changes (e.g. after\n // a save round-trip or another device pushed an update on reconnect).\n setLanguageDraft(defaultLanguageDraft(backend.state.meta));\n });\n const languageDirty = createMemo(\n () => languageDraft() !== defaultLanguageDraft(backend.state.meta),\n );\n const submitLanguage = async (event: Event) => {\n event.preventDefault();\n setLanguageSaving(true);\n setLanguageMessage(\"\");\n setLanguageError(\"\");\n try {\n await backend.saveLanguage(languageDraft());\n setLanguageMessage(SETTINGS.language.saved);\n } catch (e) {\n setLanguageError(e instanceof Error ? e.message : String(e));\n } finally {\n setLanguageSaving(false);\n }\n };\n\n // ── Agent 基础设置 ──────────────────────────────────────────────────────────\n const [agentDraft, setAgentDraft] = createSignal<AgentSettings>(\n defaultAgentSettings(),\n );\n const updateAgentDraft = (patch: Partial<AgentSettings>) => {\n setAgentDraft((prev) => ({ ...prev, ...patch }));\n };\n const [agentSaving, setAgentSaving] = createSignal(false);\n const [agentMessage, setAgentMessage] = createSignal(\"\");\n const [agentError, setAgentError] = createSignal(\"\");\n\n const openaiCheck = createCheckState();\n const honeCloudCheck = createCheckState();\n const auxiliaryCheck = createCheckState();\n const geminiCheck = createCheckState();\n const codexAcpCheck = createCheckState();\n const [showHoneCloudKey, setShowHoneCloudKey] = createSignal(false);\n const [showOpenaiKey, setShowOpenaiKey] = createSignal(false);\n const [showAuxiliaryKey, setShowAuxiliaryKey] = createSignal(false);\n const profileIdOptions = createMemo(\n () =>\n agentDraft()\n .llmProfiles?.profiles.map((profile) => profile.id.trim())\n .filter(Boolean) ?? [],\n );\n const llmProfileBindingRows: LlmProfileBindingRow[] = [\n {\n key: \"defaultProfile\",\n label: SETTINGS.agent.openai.profiles_default_label,\n },\n {\n key: \"auxiliaryProfile\",\n label: SETTINGS.agent.openai.profiles_auxiliary_label,\n },\n {\n key: \"polishProfile\",\n label: SETTINGS.agent.openai.profiles_polish_label,\n },\n {\n key: \"newsClassifierProfile\",\n label: SETTINGS.agent.openai.profiles_news_label,\n },\n {\n key: \"filingSummaryProfile\",\n label: SETTINGS.agent.openai.profiles_filing_label,\n },\n {\n key: \"earningsQualityProfile\",\n label: SETTINGS.agent.openai.profiles_earnings_label,\n },\n {\n key: \"digestPass1Profile\",\n label: SETTINGS.agent.openai.profiles_digest_pass1_label,\n },\n {\n key: \"digestPass2Profile\",\n label: SETTINGS.agent.openai.profiles_digest_pass2_label,\n },\n {\n key: \"digestEventDedupeProfile\",\n label: SETTINGS.agent.openai.profiles_digest_dedupe_label,\n },\n {\n key: \"mainlineDistillProfile\",\n label: SETTINGS.agent.openai.profiles_mainline_label,\n },\n ];\n const [showFeishuSecret, setShowFeishuSecret] = createSignal(false);\n const [showTelegramToken, setShowTelegramToken] = createSignal(false);\n const [showDiscordToken, setShowDiscordToken] = createSignal(false);\n const [inviteMessage, setInviteMessage] = createSignal(\"\");\n const [inviteError, setInviteError] = createSignal(\"\");\n const [inviteCreating, setInviteCreating] = createSignal(false);\n const [inviteActionKey, setInviteActionKey] = createSignal(\"\");\n const [invitePhoneNumber, setInvitePhoneNumber] = createSignal(\"\");\n const clearInviteFeedback = () => {\n setInviteMessage(\"\");\n setInviteError(\"\");\n };\n\n const [webInvites, setWebInvites] = createSignal<WebInviteInfo[]>([]);\n const [webInvitesReloadToken, setWebInvitesReloadToken] = createSignal(0);\n const refetchWebInvites = async () => {\n if (!backend.state.connected || !backend.hasCapability(\"web_invites\")) {\n setWebInvites([]);\n return [];\n }\n const invites = await getWebInvites();\n setWebInvites(invites);\n return invites;\n };\n createEffect(() => {\n webInvitesReloadToken();\n if (!backend.state.connected || !backend.hasCapability(\"web_invites\")) {\n setWebInvites([]);\n return;\n }\n void refetchWebInvites().catch((error) => {\n console.warn(\"getWebInvites failed\", error);\n setWebInvites([]);\n });\n });\n\n const [agentSettingsRes] = createResource(\n () => backend.state.isDesktop,\n async (isDesktop) => {\n if (!isDesktop) return undefined;\n return loadDesktopAgentSettings();\n },\n );\n\n createEffect(() => {\n const s = agentSettingsRes();\n if (s) {\n setAgentDraft(mergeAgentSettings(s));\n }\n });\n\n // ── FMP API Keys 设置 ───────────────────────────────────────────────────────\n const [fmpDraftState, setFmpDraftState] = createSignal(\n toApiKeyDraftState(defaultFmpSettings()),\n );\n const fmpDraft = () => fmpDraftState().settings;\n const showFmpKeys = () => fmpDraftState().visibility;\n const [fmpSaving, setFmpSaving] = createSignal(false);\n\n const [fmpSettingsRes] = createResource(\n () => backend.state.isDesktop,\n async (isDesktop) => {\n if (!isDesktop) return undefined;\n return loadDesktopFmpSettings();\n },\n );\n\n createEffect(() => {\n const s = fmpSettingsRes();\n if (s) {\n setFmpDraftState(toApiKeyDraftState(s));\n }\n });\n\n const submitFmpSettings = async (event: Event) => {\n event.preventDefault();\n setFmpSaving(true);\n try {\n await saveDesktopFmpSettings(fmpDraft());\n } catch {\n } finally {\n setFmpSaving(false);\n }\n };\n\n // ── Tavily API Keys 设置 ────────────────────────────────────────────────────\n const [tavilyDraftState, setTavilyDraftState] = createSignal(\n toApiKeyDraftState(defaultTavilySettings()),\n );\n const tavilyDraft = () => tavilyDraftState().settings;\n const showTavilyKeys = () => tavilyDraftState().visibility;\n const [tavilySaving, setTavilySaving] = createSignal(false);\n\n const [tavilySettingsRes] = createResource(\n () => backend.state.isDesktop,\n async (isDesktop) => {\n if (!isDesktop) return undefined;\n return loadDesktopTavilySettings();\n },\n );\n\n createEffect(() => {\n const s = tavilySettingsRes();\n if (s) {\n setTavilyDraftState(toApiKeyDraftState(s));\n }\n });\n\n const submitTavilySettings = async (event: Event) => {\n event.preventDefault();\n setTavilySaving(true);\n try {\n await saveDesktopTavilySettings(tavilyDraft());\n } catch {\n } finally {\n setTavilySaving(false);\n }\n };\n\n // ── 多 Key 输入辅助函数 ──────────────────────────────────────────────────────\n /** 更新指定索引的 key 值 */\n function updateApiKeyDraft<T extends { apiKeys: string[] }>(\n setter: ApiKeyDraftStateSetter<T>,\n index: number,\n value: string,\n ) {\n setter((prev) => updateApiKeyDraftState(prev, index, value));\n }\n\n /** 追加一个空 key 输入行 */\n function addApiKeyDraftRow<T extends { apiKeys: string[] }>(\n setter: ApiKeyDraftStateSetter<T>,\n ) {\n setter((prev) => appendApiKeyDraftState(prev));\n }\n\n /** 删除指定索引的 key */\n function removeApiKeyDraftRow<T extends { apiKeys: string[] }>(\n setter: ApiKeyDraftStateSetter<T>,\n index: number,\n ) {\n setter((prev) => removeApiKeyDraftState(prev, index));\n }\n\n /** 切换指定索引的 key 显示/隐藏 */\n function toggleApiKeyDraftVisibility<T extends { apiKeys: string[] }>(\n setter: ApiKeyDraftStateSetter<T>,\n index: number,\n ) {\n setter((prev) => toggleApiKeyDraftState(prev, index));\n }\n\n const updateHoneCloudDraft = (\n patch: Partial<NonNullable<AgentSettings[\"honeCloud\"]>>,\n ) => {\n setAgentDraft((prev) => mergeHoneCloudDraft(prev, patch));\n };\n\n const updateAuxiliaryDraft = (\n patch: Partial<NonNullable<AgentSettings[\"auxiliary\"]>>,\n ) => {\n setAgentDraft((prev) => mergeAuxiliaryDraft(prev, patch));\n };\n\n // ── OpenAI-compatible endpoint tests ───────────────────────────────────────\n const handleTestOpenAi = async () => {\n await openaiCheck.run(async () => {\n const d = agentDraft();\n return testDesktopOpenAiChannel(\n d.openaiUrl,\n d.openaiModel,\n d.openaiApiKey,\n );\n });\n };\n\n const handleTestHoneCloud = async () => {\n await honeCloudCheck.run(\n async () => {\n const d = agentDraft().honeCloud;\n return testDesktopOpenAiChannel(\n resolveHoneCloudOpenAiBaseUrl(d?.baseUrl),\n d?.model || \"hone-cloud\",\n d?.apiKey ?? \"\",\n );\n },\n );\n };\n\n const handleTestAuxiliary = async () => {\n await auxiliaryCheck.run(\n async () => {\n const auxiliary = agentDraft().auxiliary;\n return testDesktopOpenAiChannel(\n auxiliary?.baseUrl ?? \"\",\n auxiliary?.model ?? \"\",\n auxiliary?.apiKey ?? \"\",\n );\n },\n );\n };\n\n // ── CLI / ACP checks ───────────────────────────────────────────────────────\n const handleCheckGemini = async () => {\n await geminiCheck.run(() => checkDesktopAgentCli(\"gemini_cli\"));\n };\n\n const handleCheckCodexAcp = async () => {\n await codexAcpCheck.run(() => checkDesktopAgentCli(\"codex_acp\"));\n };\n\n // ── 选中某个 runner 并立即保存 ───────────────────────────────────────────────\n const selectRunner = async (runner: AgentProvider) => {\n const previous = agentDraft();\n if (!canSelectRunner(previous.runner, runner, agentSaving())) return;\n const next = { ...previous, runner };\n setAgentDraft(next);\n setAgentSaving(true);\n setAgentMessage(\"\");\n setAgentError(\"\");\n try {\n const saveResult = await backend.saveAgentSettings(next);\n if (isAgentSettingsRuntimeMismatch(saveResult)) {\n setAgentError(saveResult.message);\n } else {\n setAgentMessage(saveResult.message);\n }\n } catch (e) {\n setAgentDraft(previous);\n setAgentError(e instanceof Error ? e.message : String(e));\n } finally {\n setAgentSaving(false);\n }\n };\n\n const submitAgentSettings = async (event: Event) => {\n event.preventDefault();\n setAgentSaving(true);\n setAgentMessage(\"\");\n setAgentError(\"\");\n try {\n const saveResult = await backend.saveAgentSettings(agentDraft());\n if (isAgentSettingsRuntimeMismatch(saveResult)) {\n setAgentError(saveResult.message);\n } else {\n setAgentMessage(saveResult.message);\n }\n } catch (e) {\n setAgentError(e instanceof Error ? e.message : String(e));\n } finally {\n setAgentSaving(false);\n }\n };\n\n const updateLlmProfiles = (\n updater: (current: LlmProfileSettingsDraft) => LlmProfileSettingsDraft,\n ) => {\n setAgentDraft((prev) => ({\n ...prev,\n llmProfiles: updater(prev.llmProfiles ?? defaultAgentSettings().llmProfiles!),\n }));\n };\n\n const updateLlmProfileBinding = (\n key: LlmProfileBindingKey,\n value: string,\n ) => {\n updateLlmProfiles((current) =>\n updateLlmProfileBindingDraft(current, key, value),\n );\n };\n\n const updateLlmProfileEntry = (\n index: number,\n patch: Partial<LlmProfileEntryDraft>,\n ) => {\n updateLlmProfiles((current) =>\n updateLlmProfileEntryDraft(current, index, patch),\n );\n };\n\n createEffect(() => {\n const settings = desktopChannelSettings();\n if (!settings) return;\n setChannelDraft(toChannelDraft(settings));\n });\n\n const submitChannels = async (event: Event) => {\n event.preventDefault();\n try {\n const saveResult = await backend.saveChannelSettings(channelDraft());\n setDesktopChannelSettings(saveResult.settings);\n } catch {\n }\n };\n\n const handleCreateInvite = async () => {\n const phoneNumber = normalizePhoneNumber(invitePhoneNumber());\n if (!phoneNumber) {\n setInviteMessage(\"\");\n setInviteError(SETTINGS.invite.phone_required);\n return;\n }\n setInviteCreating(true);\n clearInviteFeedback();\n try {\n const created = await createWebInvite(phoneNumber);\n setWebInvites((current) => prependWebInvite(current, created));\n setInvitePhoneNumber(\"\");\n setInviteMessage(\n tpl(created.api_key ? SETTINGS.invite.created_with_api_key : SETTINGS.invite.created, {\n phone: created.phone_number,\n code: created.invite_code,\n apiKey: created.api_key ?? \"\",\n }),\n );\n if (typeof navigator !== \"undefined\" && navigator.clipboard?.writeText) {\n await navigator.clipboard.writeText(\n created.api_key\n ? `Invite: ${created.invite_code}\\nAPI Key: ${created.api_key}`\n : created.invite_code,\n );\n setInviteMessage(\n tpl(\n created.api_key\n ? SETTINGS.invite.created_with_api_key_copied\n : SETTINGS.invite.created_copied,\n {\n phone: created.phone_number,\n code: created.invite_code,\n apiKey: created.api_key ?? \"\",\n },\n ),\n );\n }\n } catch (error) {\n setInviteError(error instanceof Error ? error.message : String(error));\n } finally {\n setInviteCreating(false);\n }\n };\n\n const copyInviteCode = async (code: string) => {\n clearInviteFeedback();\n try {\n if (!navigator.clipboard?.writeText) {\n throw new Error(SETTINGS.invite.copy_unsupported);\n }\n await navigator.clipboard.writeText(code);\n setInviteMessage(tpl(SETTINGS.invite.copied, { code }));\n } catch (error) {\n setInviteError(error instanceof Error ? error.message : String(error));\n }\n };\n\n const replaceInvite = (next: WebInviteInfo) => {\n setWebInvites((current) => replaceWebInvite(current, next));\n };\n\n const isInviteActionRunning = (\n userId: string,\n action: InviteAction,\n ) => isInviteActionKeyRunning(inviteActionKey(), userId, action);\n\n const runInviteAction = async (\n invite: WebInviteInfo,\n action: InviteAction,\n actionFn: () => Promise<void>,\n ) => {\n clearInviteFeedback();\n setInviteActionKey(buildInviteActionKey(invite.user_id, action));\n try {\n await actionFn();\n } catch (error) {\n setInviteError(error instanceof Error ? error.message : String(error));\n } finally {\n setInviteActionKey(\"\");\n }\n };\n\n const handleDisableInvite = async (invite: WebInviteInfo) => {\n if (typeof window !== \"undefined\") {\n const confirmed = window.confirm(\n tpl(SETTINGS.invite.disable_confirm, { userId: invite.user_id }),\n );\n if (!confirmed) return;\n }\n await runInviteAction(invite, \"disable\", async () => {\n const inviteResult = await disableWebInvite(invite.user_id);\n replaceInvite(inviteResult.invite);\n setInviteMessage(inviteResult.message);\n });\n };\n\n const handleEnableInvite = async (invite: WebInviteInfo) => {\n await runInviteAction(invite, \"enable\", async () => {\n const inviteResult = await enableWebInvite(invite.user_id);\n replaceInvite(inviteResult.invite);\n setInviteMessage(inviteResult.message);\n });\n };\n\n const handleResetInvite = async (invite: WebInviteInfo) => {\n if (typeof window !== \"undefined\") {\n const confirmed = window.confirm(\n tpl(SETTINGS.invite.reset_confirm, { userId: invite.user_id }),\n );\n if (!confirmed) return;\n }\n await runInviteAction(invite, \"reset\", async () => {\n const inviteResult = await resetWebInvite(invite.user_id);\n replaceInvite(inviteResult.invite);\n setInviteMessage(inviteResult.message);\n if (typeof navigator !== \"undefined\" && navigator.clipboard?.writeText) {\n await navigator.clipboard.writeText(inviteResult.invite.invite_code);\n setInviteMessage(\n tpl(SETTINGS.invite.reset_copied_suffix, {\n message: inviteResult.message,\n }),\n );\n }\n });\n };\n\n const copyInviteApiKey = async (apiKey: string) => {\n clearInviteFeedback();\n try {\n if (!navigator.clipboard?.writeText) {\n throw new Error(SETTINGS.invite.copy_unsupported);\n }\n await navigator.clipboard.writeText(apiKey);\n setInviteMessage(SETTINGS.invite.api_key_copied);\n } catch (error) {\n setInviteError(error instanceof Error ? error.message : String(error));\n }\n };\n\n const handleGetInviteApiKey = async (invite: WebInviteInfo) => {\n await runInviteAction(invite, \"api-key\", async () => {\n const inviteResult = await getWebInviteApiKey(invite.user_id);\n replaceInvite(inviteResult.invite);\n setInviteMessage(inviteResult.message);\n if (inviteResult.invite.api_key) {\n await copyInviteApiKey(inviteResult.invite.api_key);\n }\n });\n };\n\n const handleResetInviteApiKey = async (invite: WebInviteInfo) => {\n if (typeof window !== \"undefined\") {\n const confirmed = window.confirm(\n tpl(SETTINGS.invite.api_key_reset_confirm, { userId: invite.user_id }),\n );\n if (!confirmed) return;\n }\n await runInviteAction(invite, \"api-key-reset\", async () => {\n const inviteResult = await resetWebInviteApiKey(invite.user_id);\n replaceInvite(inviteResult.invite);\n setInviteMessage(inviteResult.message);\n if (inviteResult.invite.api_key) {\n await copyInviteApiKey(inviteResult.invite.api_key);\n }\n });\n };\n\n const tabLabel = (key: SettingsTabKey): string => SETTINGS.tabs[key];\n const [searchParams, setSearchParams] = useSearchParams<{ tab?: string }>();\n const activeTab = (): SettingsTabKey => resolveSettingsTab(searchParams.tab);\n const selectTab = (key: SettingsTabKey) => setSearchParams({ tab: key });\n let contentRef: HTMLDivElement | undefined;\n createEffect(() => {\n // track active tab and reset scroll on change\n activeTab();\n if (contentRef) contentRef.scrollTop = 0;\n });\n const isTab = (key: SettingsTabKey) => activeTab() === key;\n\n return (\n <div class=\"mx-auto flex h-full max-w-4xl flex-col\">\n <form\n onSubmit={submitLanguage}\n class=\"mb-3 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5 shadow-sm\"\n >\n <div class=\"flex items-start justify-between gap-4\">\n <div class=\"min-w-0\">\n <h2 class=\"text-base font-semibold text-[color:var(--text-primary)]\">\n {SETTINGS.language.title}\n </h2>\n <p class=\"mt-1 text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.language.subtitle}\n </p>\n </div>\n <button\n type=\"submit\"\n disabled={!languageDirty() || languageSaving()}\n class=\"shrink-0 rounded-md border border-[color:var(--accent)] bg-[color:var(--accent)] px-3 py-1.5 text-xs font-medium text-white transition disabled:cursor-not-allowed disabled:border-[color:var(--border)] disabled:bg-transparent disabled:text-[color:var(--text-muted)]\"\n >\n {languageSaving()\n ? SETTINGS.language.saving\n : SETTINGS.language.save}\n </button>\n </div>\n <div class=\"mt-3 flex flex-wrap gap-3\">\n <For each={[\"zh\", \"en\"] as const}>\n {(code) => (\n <label\n class={[\n \"flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm cursor-pointer\",\n languageDraft() === code\n ? \"border-[color:var(--accent)] bg-[color:var(--accent-soft)] text-[color:var(--text-primary)]\"\n : \"border-[color:var(--border)] bg-[color:var(--panel)] text-[color:var(--text-secondary)] hover:border-[color:var(--accent)]/50\",\n ].join(\" \")}\n >\n <input\n type=\"radio\"\n name=\"settings-language\"\n value={code}\n checked={languageDraft() === code}\n onChange={() => setLanguageDraft(code)}\n class=\"h-3.5 w-3.5\"\n />\n {code === \"zh\"\n ? SETTINGS.language.option_zh\n : SETTINGS.language.option_en}\n </label>\n )}\n </For>\n </div>\n <p class=\"mt-2 text-[11px] text-[color:var(--text-muted)]\">\n {SETTINGS.language.note}\n </p>\n <Show when={languageMessage()}>\n <p class=\"mt-2 text-xs text-[color:var(--accent)]\">\n {languageMessage()}\n </p>\n </Show>\n <Show when={languageError()}>\n <p class=\"mt-2 text-xs text-red-500\">\n {SETTINGS.language.save_failed}: {languageError()}\n </p>\n </Show>\n </form>\n <nav class=\"sticky top-0 z-10 -mx-1 flex gap-1 overflow-x-auto border-b border-[color:var(--border)] bg-[color:var(--surface)]/95 px-1 py-2 backdrop-blur\">\n <For each={SETTINGS_TAB_KEYS}>\n {(key) => (\n <Show\n when={canShowSettingsTab(\n key,\n backend.hasCapability(\"web_invites\"),\n )}\n >\n <button\n type=\"button\"\n onClick={() => selectTab(key)}\n class={[\n \"shrink-0 rounded-md px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]\",\n isTab(key)\n ? \"bg-[color:var(--accent-soft)] text-[color:var(--text-primary)]\"\n : \"text-[color:var(--text-secondary)] hover:bg-black/5 hover:text-[color:var(--text-primary)]\",\n ].join(\" \")}\n >\n {tabLabel(key)}\n </button>\n </Show>\n )}\n </For>\n </nav>\n <div\n ref={contentRef}\n class=\"flex flex-1 flex-col gap-4 overflow-y-auto py-4\"\n >\n {/* ── 基础设置 ── */}\n <div\n id=\"agent-settings\"\n classList={{ hidden: !isTab(\"agent\") }}\n class=\"rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6 shadow-sm\"\n >\n <h1 class=\"text-xl font-semibold text-[color:var(--text-primary)]\">\n {SETTINGS.agent.title}\n </h1>\n <p class=\"mt-2 text-sm text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.subtitle}\n </p>\n\n <fieldset\n disabled={\n !backend.state.isDesktop ||\n agentSettingsRes.loading ||\n agentSaving()\n }\n class=\"mt-6 space-y-4 disabled:opacity-60\"\n >\n {/* ── 卡片 0:HONE Cloud ── */}\n <div\n class={[\n \"rounded-xl border p-5 transition cursor-pointer\",\n agentDraft().runner === \"hone_cloud\"\n ? \"border-[color:var(--accent)] bg-[color:var(--accent-soft)]\"\n : \"border-[color:var(--border)] bg-[color:var(--panel)] hover:border-[color:var(--accent)]/50\",\n ].join(\" \")}\n onClick={() => void selectRunner(\"hone_cloud\")}\n >\n <div class=\"flex items-start justify-between gap-3\">\n <div>\n <div class=\"text-sm font-semibold text-[color:var(--text-primary)]\">\n {SETTINGS.agent.hone_cloud.name}\n </div>\n <div class=\"mt-0.5 text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.hone_cloud.description}\n </div>\n </div>\n <Show when={agentDraft().runner === \"hone_cloud\"}>\n <span class=\"shrink-0 rounded-full border border-[color:var(--accent)] px-2 py-0.5 text-[10px] font-medium text-[color:var(--accent)]\">\n {SETTINGS.agent.current_badge}\n </span>\n </Show>\n </div>\n\n <div class=\"mt-4 space-y-3\" onClick={(e) => e.stopPropagation()}>\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"hone-cloud-url\"\n >\n {SETTINGS.agent.hone_cloud.base_url_label}\n </label>\n <input\n id=\"hone-cloud-url\"\n type=\"url\"\n placeholder=\"https://hone-claw.com\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().honeCloud?.baseUrl ?? \"\"}\n onInput={(e) =>\n updateHoneCloudDraft({ baseUrl: e.currentTarget.value })\n }\n />\n </div>\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"hone-cloud-model\"\n >\n {SETTINGS.agent.hone_cloud.model_label}\n </label>\n <input\n id=\"hone-cloud-model\"\n type=\"text\"\n placeholder=\"hone-cloud\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().honeCloud?.model ?? \"\"}\n onInput={(e) =>\n updateHoneCloudDraft({ model: e.currentTarget.value })\n }\n />\n </div>\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"hone-cloud-key\"\n >\n {SETTINGS.agent.hone_cloud.api_key_label}\n </label>\n <div class=\"relative\">\n <input\n id=\"hone-cloud-key\"\n type={showHoneCloudKey() ? \"text\" : \"password\"}\n placeholder=\"hck_...\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 pr-16 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().honeCloud?.apiKey ?? \"\"}\n onInput={(e) =>\n updateHoneCloudDraft({ apiKey: e.currentTarget.value })\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 rounded px-2 py-0.5 text-xs text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() => setShowHoneCloudKey((v) => !v)}\n >\n {showHoneCloudKey()\n ? SETTINGS.agent.hone_cloud.hide\n : SETTINGS.agent.hone_cloud.show}\n </button>\n </div>\n </div>\n <div class=\"rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-soft)] p-3 text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.hone_cloud.contact_note}\n </div>\n <Show when={honeCloudCheck.status() !== \"idle\"}>\n <CheckStatusBanner\n status={honeCloudCheck.status()}\n checkingMessage={\n SETTINGS.agent.hone_cloud.connection_testing_status\n }\n message={honeCloudCheck.message()}\n />\n </Show>\n <div class=\"flex gap-2 pt-1\">\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={honeCloudCheck.status() === \"checking\"}\n onClick={() => void handleTestHoneCloud()}\n >\n {honeCloudCheck.status() === \"checking\"\n ? SETTINGS.agent.hone_cloud.testing\n : SETTINGS.agent.hone_cloud.test_connection}\n </button>\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--accent)] bg-[color:var(--accent-soft)] px-3 py-1.5 text-xs font-medium text-[color:var(--text-primary)] transition hover:opacity-90 disabled:opacity-50\"\n disabled={agentSaving()}\n onClick={(e) => void submitAgentSettings(e)}\n >\n {agentSaving()\n ? SETTINGS.agent.hone_cloud.saving\n : SETTINGS.agent.hone_cloud.save}\n </button>\n </div>\n </div>\n </div>\n\n {/* ── 卡片 1:OpenAI 协议渠道 ── */}\n <div\n class={[\n \"rounded-xl border p-5 transition cursor-pointer\",\n agentDraft().runner === \"opencode_acp\"\n ? \"border-[color:var(--accent)] bg-[color:var(--accent-soft)]\"\n : \"border-[color:var(--border)] bg-[color:var(--panel)] hover:border-[color:var(--accent)]/50\",\n ].join(\" \")}\n onClick={() => void selectRunner(\"opencode_acp\")}\n >\n <div class=\"flex items-start justify-between gap-3\">\n <div>\n <div class=\"text-sm font-semibold text-[color:var(--text-primary)]\">\n {SETTINGS.agent.openai.name}\n </div>\n <div class=\"mt-0.5 text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.openai.description}\n </div>\n </div>\n <Show when={agentDraft().runner === \"opencode_acp\"}>\n <span class=\"shrink-0 rounded-full border border-[color:var(--accent)] px-2 py-0.5 text-[10px] font-medium text-[color:var(--accent)]\">\n {SETTINGS.agent.current_badge}\n </span>\n </Show>\n </div>\n\n {/* 配置字段区(点击卡片内部不触发 selectRunner) */}\n <div class=\"mt-4 space-y-3\" onClick={(e) => e.stopPropagation()}>\n {/* Base URL */}\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"openai-url\"\n >\n {SETTINGS.agent.openai.base_url_label}\n </label>\n <input\n id=\"openai-url\"\n type=\"url\"\n placeholder=\"https://openrouter.ai/api/v1\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().openaiUrl}\n onInput={(e) =>\n updateAgentDraft({ openaiUrl: e.currentTarget.value })\n }\n />\n </div>\n\n {/* Model */}\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"openai-model\"\n >\n {SETTINGS.agent.openai.model_label}\n </label>\n <input\n id=\"openai-model\"\n type=\"text\"\n placeholder=\"google/gemini-2.5-pro-preview\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().openaiModel}\n onInput={(e) =>\n updateAgentDraft({ openaiModel: e.currentTarget.value })\n }\n />\n </div>\n\n <div class=\"rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-soft)] p-3\">\n <p class=\"text-xs font-medium text-[color:var(--text-primary)]\">\n {SETTINGS.agent.openai.auxiliary_title}\n </p>\n <p class=\"mt-1 text-[11px] text-[color:var(--text-muted)]\">\n {SETTINGS.agent.openai.auxiliary_subtitle}\n </p>\n <div class=\"mt-3 space-y-3\">\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"auxiliary-url\"\n >\n {SETTINGS.agent.openai.auxiliary_url_label}\n </label>\n <input\n id=\"auxiliary-url\"\n type=\"url\"\n placeholder=\"https://api.minimaxi.com/v1\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().auxiliary?.baseUrl ?? \"\"}\n onInput={(e) =>\n updateAuxiliaryDraft({ baseUrl: e.currentTarget.value })\n }\n />\n </div>\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"auxiliary-model\"\n >\n {SETTINGS.agent.openai.auxiliary_model_label}\n </label>\n <input\n id=\"auxiliary-model\"\n type=\"text\"\n placeholder=\"MiniMax-M2.7-highspeed\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().auxiliary?.model ?? \"\"}\n onInput={(e) =>\n updateAuxiliaryDraft({ model: e.currentTarget.value })\n }\n />\n </div>\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"auxiliary-apikey\"\n >\n {SETTINGS.agent.openai.auxiliary_apikey_label}\n </label>\n <div class=\"relative\">\n <input\n id=\"auxiliary-apikey\"\n type={showAuxiliaryKey() ? \"text\" : \"password\"}\n placeholder=\"sk-cp-...\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 pr-16 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().auxiliary?.apiKey ?? \"\"}\n onInput={(e) =>\n updateAuxiliaryDraft({\n apiKey: e.currentTarget.value,\n })\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 rounded px-2 py-0.5 text-xs text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() => setShowAuxiliaryKey((v) => !v)}\n >\n {showAuxiliaryKey()\n ? SETTINGS.agent.openai.hide\n : SETTINGS.agent.openai.show}\n </button>\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"border-t border-[color:var(--border)] pt-4\">\n <p class=\"text-xs font-medium text-[color:var(--text-primary)]\">\n {SETTINGS.agent.openai.profiles_title}\n </p>\n <p class=\"mt-1 text-[11px] text-[color:var(--text-muted)]\">\n {SETTINGS.agent.openai.profiles_subtitle}\n </p>\n\n <div class=\"mt-3 grid gap-2 md:grid-cols-2\">\n <For each={llmProfileBindingRows}>\n {(row) => (\n <label class=\"block\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {row.label}\n </span>\n <select\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().llmProfiles?.[row.key] ?? \"\"}\n onChange={(e) =>\n updateLlmProfileBinding(\n row.key,\n e.currentTarget.value,\n )\n }\n >\n <For each={profileIdOptions()}>\n {(profileId) => (\n <option value={profileId}>{profileId}</option>\n )}\n </For>\n </select>\n </label>\n )}\n </For>\n </div>\n\n <div class=\"mt-4 space-y-3\">\n <For each={agentDraft().llmProfiles?.profiles ?? []}>\n {(profile, index) => (\n <div class=\"rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-soft)] p-3\">\n <div class=\"flex flex-wrap items-center justify-between gap-2\">\n <div class=\"font-mono text-xs font-semibold text-[color:var(--text-primary)]\">\n {profile.id}\n </div>\n <label class=\"inline-flex items-center gap-1.5 text-[11px] text-[color:var(--text-secondary)]\">\n <input\n type=\"checkbox\"\n class=\"h-3.5 w-3.5 rounded border-[color:var(--border)]\"\n checked={profile.responseFormatJson}\n onChange={(e) =>\n updateLlmProfileEntry(index(), {\n responseFormatJson: e.currentTarget.checked,\n })\n }\n />\n {SETTINGS.agent.openai.profiles_json_label}\n </label>\n </div>\n\n <div class=\"mt-3 grid gap-2 md:grid-cols-2\">\n <label class=\"block\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.openai.profiles_provider_label}\n </span>\n <input\n type=\"text\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={profile.provider}\n onInput={(e) =>\n updateLlmProfileEntry(index(), {\n provider: e.currentTarget.value,\n })\n }\n />\n </label>\n <label class=\"block\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.openai.profiles_model_label}\n </span>\n <input\n type=\"text\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={profile.model}\n onInput={(e) =>\n updateLlmProfileEntry(index(), {\n model: e.currentTarget.value,\n })\n }\n />\n </label>\n <label class=\"block\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.openai.profiles_max_tokens_label}\n </span>\n <input\n type=\"number\"\n min=\"1\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={profile.maxTokens ?? \"\"}\n onInput={(e) =>\n updateLlmProfileEntry(index(), {\n maxTokens: optionalNumber(\n e.currentTarget.value,\n ),\n })\n }\n />\n </label>\n <label class=\"block\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.openai.profiles_temperature_label}\n </span>\n <input\n type=\"number\"\n min=\"0\"\n max=\"2\"\n step=\"0.1\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={profile.temperature ?? \"\"}\n onInput={(e) =>\n updateLlmProfileEntry(index(), {\n temperature: optionalNumber(\n e.currentTarget.value,\n ),\n })\n }\n />\n </label>\n <label class=\"block\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.openai.profiles_top_p_label}\n </span>\n <input\n type=\"number\"\n min=\"0\"\n max=\"1\"\n step=\"0.05\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={profile.topP ?? \"\"}\n onInput={(e) =>\n updateLlmProfileEntry(index(), {\n topP: optionalNumber(e.currentTarget.value),\n })\n }\n />\n </label>\n <label class=\"block\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.openai.profiles_reasoning_label}\n </span>\n <select\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={profile.reasoningEffort ?? \"\"}\n onChange={(e) =>\n updateLlmProfileEntry(index(), {\n reasoningEffort:\n e.currentTarget.value || undefined,\n })\n }\n >\n <option value=\"\">default</option>\n <option value=\"low\">low</option>\n <option value=\"medium\">medium</option>\n <option value=\"high\">high</option>\n <option value=\"xhigh\">xhigh</option>\n </select>\n </label>\n <label class=\"block md:col-span-2\">\n <span class=\"mb-1 block text-[11px] font-medium text-[color:var(--text-secondary)]\">\n {\n SETTINGS.agent.openai\n .profiles_reasoning_tokens_label\n }\n </span>\n <input\n type=\"number\"\n min=\"1\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={profile.reasoningMaxTokens ?? \"\"}\n onInput={(e) =>\n updateLlmProfileEntry(index(), {\n reasoningMaxTokens: optionalNumber(\n e.currentTarget.value,\n ),\n })\n }\n />\n </label>\n </div>\n </div>\n )}\n </For>\n </div>\n </div>\n\n {/* API Key */}\n <div>\n <label\n class=\"mb-1 block text-xs font-medium text-[color:var(--text-primary)]\"\n for=\"openai-apikey\"\n >\n {SETTINGS.agent.openai.api_key_label}\n </label>\n <div class=\"relative\">\n <input\n id=\"openai-apikey\"\n type={showOpenaiKey() ? \"text\" : \"password\"}\n placeholder=\"sk-or-...\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 pr-16 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={agentDraft().openaiApiKey}\n onInput={(e) =>\n updateAgentDraft({ openaiApiKey: e.currentTarget.value })\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 rounded px-2 py-0.5 text-xs text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() => setShowOpenaiKey((v) => !v)}\n >\n {showOpenaiKey()\n ? SETTINGS.agent.openai.hide\n : SETTINGS.agent.openai.show}\n </button>\n </div>\n </div>\n\n {/* 测试联通状态 */}\n <Show when={openaiCheck.status() !== \"idle\"}>\n <CheckStatusBanner\n status={openaiCheck.status()}\n checkingMessage={\n SETTINGS.agent.openai.connection_testing_status\n }\n message={openaiCheck.message()}\n showIcon\n />\n </Show>\n\n <Show when={auxiliaryCheck.status() !== \"idle\"}>\n <CheckStatusBanner\n status={auxiliaryCheck.status()}\n checkingMessage={SETTINGS.agent.openai.auxiliary_testing_status}\n message={auxiliaryCheck.message()}\n />\n </Show>\n\n {/* 反馈 */}\n {agentMessage() ? (\n <div class=\"rounded-md border border-emerald-300/40 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-300\">\n {agentMessage()}\n </div>\n ) : null}\n {agentError() ? (\n <div class=\"rounded-md border border-rose-300/40 bg-rose-500/10 px-3 py-2 text-xs text-rose-300\">\n {agentError()}\n </div>\n ) : null}\n\n {/* 操作按钮 */}\n <div class=\"flex gap-2 pt-1\">\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={openaiCheck.status() === \"checking\"}\n onClick={() => void handleTestOpenAi()}\n >\n {openaiCheck.status() === \"checking\"\n ? SETTINGS.agent.openai.testing\n : SETTINGS.agent.openai.test_connection}\n </button>\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={auxiliaryCheck.status() === \"checking\"}\n onClick={() => void handleTestAuxiliary()}\n >\n {auxiliaryCheck.status() === \"checking\"\n ? SETTINGS.agent.openai.testing\n : SETTINGS.agent.openai.test_auxiliary}\n </button>\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--accent)] bg-[color:var(--accent-soft)] px-3 py-1.5 text-xs font-medium text-[color:var(--text-primary)] transition hover:opacity-90 disabled:opacity-50\"\n disabled={agentSaving()}\n onClick={(e) => void submitAgentSettings(e)}\n >\n {agentSaving()\n ? SETTINGS.agent.openai.saving\n : SETTINGS.agent.openai.save}\n </button>\n </div>\n </div>\n </div>\n\n {/* ── 卡片 2:Codex ACP ── */}\n <div\n class={[\n \"rounded-xl border p-5 transition cursor-pointer\",\n agentDraft().runner === \"codex_acp\"\n ? \"border-[color:var(--accent)] bg-[color:var(--accent-soft)]\"\n : \"border-[color:var(--border)] bg-[color:var(--panel)] hover:border-[color:var(--accent)]/50\",\n ].join(\" \")}\n onClick={() => void selectRunner(\"codex_acp\")}\n >\n <div class=\"flex items-start justify-between gap-3\">\n <div>\n <div class=\"text-sm font-semibold text-[color:var(--text-primary)]\">\n {SETTINGS.agent.codex_acp.name}\n </div>\n <div class=\"mt-0.5 text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.codex_acp.description_prefix}\n <code class=\"rounded bg-black/20 px-1\">\n {SETTINGS.agent.codex_acp.description_code}\n </code>\n {SETTINGS.agent.codex_acp.description_suffix}\n </div>\n </div>\n <Show when={agentDraft().runner === \"codex_acp\"}>\n <span class=\"shrink-0 rounded-full border border-[color:var(--accent)] px-2 py-0.5 text-[10px] font-medium text-[color:var(--accent)]\">\n {SETTINGS.agent.current_badge}\n </span>\n </Show>\n </div>\n\n <div class=\"mt-4 space-y-3\" onClick={(e) => e.stopPropagation()}>\n <Show when={codexAcpCheck.status() !== \"idle\"}>\n <CheckStatusBanner\n status={codexAcpCheck.status()}\n checkingMessage={SETTINGS.agent.codex_acp.checking_status}\n message={codexAcpCheck.message()}\n showIcon\n />\n </Show>\n\n <div class=\"rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-3 text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.codex_acp.runtime_note}\n </div>\n\n <div class=\"flex gap-2 pt-1\">\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={codexAcpCheck.status() === \"checking\"}\n onClick={() => void handleCheckCodexAcp()}\n >\n {codexAcpCheck.status() === \"checking\"\n ? SETTINGS.agent.codex_acp.checking\n : SETTINGS.agent.codex_acp.test_connection}\n </button>\n </div>\n </div>\n </div>\n\n {/* ── 卡片 3:Gemini CLI ── */}\n <div\n class={[\n \"rounded-xl border p-5 transition cursor-pointer\",\n agentDraft().runner === \"gemini_cli\"\n ? \"border-[color:var(--accent)] bg-[color:var(--accent-soft)]\"\n : \"border-[color:var(--border)] bg-[color:var(--panel)] hover:border-[color:var(--accent)]/50\",\n ].join(\" \")}\n onClick={() => void selectRunner(\"gemini_cli\")}\n >\n <div class=\"flex items-start justify-between gap-3\">\n <div>\n <div class=\"text-sm font-semibold text-[color:var(--text-primary)]\">\n {SETTINGS.agent.gemini_cli.name}\n </div>\n <div class=\"mt-0.5 text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.agent.gemini_cli.description_prefix}\n <code class=\"rounded bg-black/20 px-1\">\n {SETTINGS.agent.gemini_cli.description_code}\n </code>\n {SETTINGS.agent.gemini_cli.description_suffix}\n </div>\n </div>\n <Show when={agentDraft().runner === \"gemini_cli\"}>\n <span class=\"shrink-0 rounded-full border border-[color:var(--accent)] px-2 py-0.5 text-[10px] font-medium text-[color:var(--accent)]\">\n {SETTINGS.agent.current_badge}\n </span>\n </Show>\n </div>\n\n <div class=\"mt-4 space-y-3\" onClick={(e) => e.stopPropagation()}>\n {/* 检测状态 */}\n <Show when={geminiCheck.status() !== \"idle\"}>\n <CheckStatusBanner\n status={geminiCheck.status()}\n checkingMessage={SETTINGS.agent.gemini_cli.checking_status}\n message={geminiCheck.message()}\n showIcon\n />\n </Show>\n\n <div class=\"flex gap-2 pt-1\">\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={geminiCheck.status() === \"checking\"}\n onClick={() => void handleCheckGemini()}\n >\n {geminiCheck.status() === \"checking\"\n ? SETTINGS.agent.gemini_cli.checking\n : SETTINGS.agent.gemini_cli.test_connection}\n </button>\n </div>\n </div>\n </div>\n </fieldset>\n </div>\n\n <Show when={backend.hasCapability(\"web_invites\")}>\n <div\n id=\"web-invite-settings\"\n classList={{ hidden: !isTab(\"invite\") }}\n class=\"rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6 shadow-sm\"\n >\n <div class=\"flex items-start justify-between gap-4\">\n <div>\n <h1 class=\"text-xl font-semibold text-[color:var(--text-primary)]\">\n {SETTINGS.invite.title}\n </h1>\n <p class=\"mt-2 text-sm text-[color:var(--text-secondary)]\">\n {SETTINGS.invite.subtitle}\n </p>\n </div>\n <div class=\"flex gap-2\">\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-xs text-[color:var(--text-primary)] transition hover:bg-black/5\"\n onClick={() => setWebInvitesReloadToken((value) => value + 1)}\n >\n {SETTINGS.invite.refresh}\n </button>\n </div>\n </div>\n\n <div class=\"mt-4 flex flex-col gap-3 rounded-xl border border-[color:var(--border)] bg-[color:var(--panel)] p-4 lg:flex-row lg:items-end\">\n <label class=\"flex-1\">\n <div class=\"text-xs font-medium uppercase tracking-[0.14em] text-[color:var(--text-muted)]\">\n {SETTINGS.invite.phone_label}\n </div>\n <input\n type=\"tel\"\n value={invitePhoneNumber()}\n onInput={(event) =>\n setInvitePhoneNumber(\n normalizePhoneNumber(event.currentTarget.value),\n )\n }\n placeholder={SETTINGS.invite.phone_placeholder}\n autocomplete=\"tel\"\n class=\"mt-2 w-full rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-sm text-[color:var(--text-primary)] outline-none transition focus:border-[color:var(--accent)]\"\n />\n </label>\n <button\n type=\"button\"\n class=\"rounded-md bg-[color:var(--accent)] px-3 py-2 text-xs font-medium text-white transition hover:opacity-90 disabled:opacity-50\"\n disabled={inviteCreating() || !invitePhoneNumber().trim()}\n onClick={() => void handleCreateInvite()}\n >\n {inviteCreating() ? SETTINGS.invite.creating : SETTINGS.invite.create}\n </button>\n </div>\n\n <Show when={inviteMessage()}>\n <div class=\"mt-4 rounded-md border border-emerald-300/40 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-300\">\n {inviteMessage()}\n </div>\n </Show>\n <Show when={inviteError()}>\n <div class=\"mt-4 rounded-md border border-rose-300/40 bg-rose-500/10 px-3 py-2 text-xs text-rose-300\">\n {inviteError()}\n </div>\n </Show>\n\n <div class=\"mt-6 overflow-hidden rounded-xl border border-[color:var(--border)]\">\n <div class=\"grid grid-cols-[1.2fr_1fr_1.1fr_0.8fr_0.8fr_0.7fr_0.9fr_1fr_auto] gap-3 bg-[color:var(--panel)] px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-[color:var(--text-muted)]\">\n <div>{SETTINGS.invite.table.code}</div>\n <div>{SETTINGS.invite.table.phone}</div>\n <div>{SETTINGS.invite.table.web_user}</div>\n <div>{SETTINGS.invite.table.status}</div>\n <div>{SETTINGS.invite.table.api_key}</div>\n <div>{SETTINGS.invite.table.sessions}</div>\n <div>{SETTINGS.invite.table.remaining}</div>\n <div>{SETTINGS.invite.table.last_login}</div>\n <div>{SETTINGS.invite.table.actions}</div>\n </div>\n <Show\n when={(webInvites() ?? []).length > 0}\n fallback={\n <div class=\"px-4 py-8 text-sm text-[color:var(--text-secondary)]\">\n {SETTINGS.invite.table.empty}\n </div>\n }\n >\n <div class=\"divide-y divide-[color:var(--border)]\">\n <For each={webInvites() ?? []}>\n {(invite) => (\n <div class=\"grid grid-cols-[1.2fr_1fr_1.1fr_0.8fr_0.8fr_0.7fr_0.9fr_1fr_auto] items-center gap-3 px-4 py-3 text-sm\">\n <div class=\"font-mono text-[color:var(--text-primary)]\">\n {invite.invite_code}\n </div>\n <div class=\"font-mono text-[color:var(--text-secondary)]\">\n {invite.phone_number || SETTINGS.invite.table.phone_unbound}\n </div>\n <div class=\"text-[color:var(--text-secondary)]\">\n {invite.user_id}\n </div>\n <div>\n <span\n class={[\n \"inline-flex rounded-full px-2.5 py-1 text-xs font-medium\",\n invite.enabled\n ? \"bg-emerald-500/10 text-emerald-300\"\n : \"bg-rose-500/10 text-rose-300\",\n ].join(\" \")}\n >\n {invite.enabled\n ? SETTINGS.invite.table.enabled\n : SETTINGS.invite.table.disabled}\n </span>\n </div>\n <div class=\"font-mono text-xs text-[color:var(--text-secondary)]\">\n {invite.api_key_prefix || SETTINGS.invite.table.api_key_missing}\n </div>\n <div class=\"text-[color:var(--text-secondary)]\">\n {invite.active_session_count}\n </div>\n <div class=\"text-[color:var(--text-secondary)]\">\n {invite.daily_limit === 0\n ? SETTINGS.invite.table.unlimited\n : `${invite.remaining_today}/${invite.daily_limit}`}\n </div>\n <div class=\"text-[color:var(--text-secondary)]\">\n {invite.last_login_at\n ? new Date(invite.last_login_at).toLocaleString()\n : SETTINGS.invite.table.never_logged_in}\n </div>\n <div class=\"flex flex-wrap justify-end gap-2\">\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] px-2.5 py-1 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60\"\n onClick={() =>\n void copyInviteCode(invite.invite_code)\n }\n >\n {SETTINGS.invite.table.copy}\n </button>\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] px-2.5 py-1 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={isInviteActionRunning(\n invite.user_id,\n \"reset\",\n )}\n onClick={() => void handleResetInvite(invite)}\n >\n {isInviteActionRunning(invite.user_id, \"reset\")\n ? SETTINGS.invite.table.resetting\n : SETTINGS.invite.table.reset}\n </button>\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] px-2.5 py-1 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={isInviteActionRunning(\n invite.user_id,\n \"api-key\",\n )}\n onClick={() => void handleGetInviteApiKey(invite)}\n >\n {isInviteActionRunning(invite.user_id, \"api-key\")\n ? SETTINGS.invite.table.api_key_getting\n : SETTINGS.invite.table.api_key_get}\n </button>\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] px-2.5 py-1 text-xs text-[color:var(--text-primary)] transition hover:border-[color:var(--accent)]/60 disabled:opacity-50\"\n disabled={isInviteActionRunning(\n invite.user_id,\n \"api-key-reset\",\n )}\n onClick={() => void handleResetInviteApiKey(invite)}\n >\n {isInviteActionRunning(invite.user_id, \"api-key-reset\")\n ? SETTINGS.invite.table.api_key_resetting\n : SETTINGS.invite.table.api_key_reset}\n </button>\n <Show\n when={invite.enabled}\n fallback={\n <button\n type=\"button\"\n class=\"rounded-md border border-emerald-500/30 px-2.5 py-1 text-xs text-emerald-300 transition hover:border-emerald-400 disabled:opacity-50\"\n disabled={isInviteActionRunning(\n invite.user_id,\n \"enable\",\n )}\n onClick={() => void handleEnableInvite(invite)}\n >\n {isInviteActionRunning(invite.user_id, \"enable\")\n ? SETTINGS.invite.table.enabling\n : SETTINGS.invite.table.enable}\n </button>\n }\n >\n <button\n type=\"button\"\n class=\"rounded-md border border-rose-500/30 px-2.5 py-1 text-xs text-rose-300 transition hover:border-rose-400 disabled:opacity-50\"\n disabled={isInviteActionRunning(\n invite.user_id,\n \"disable\",\n )}\n onClick={() => void handleDisableInvite(invite)}\n >\n {isInviteActionRunning(invite.user_id, \"disable\")\n ? SETTINGS.invite.table.disabling\n : SETTINGS.invite.table.disable}\n </button>\n </Show>\n </div>\n </div>\n )}\n </For>\n </div>\n </Show>\n </div>\n </div>\n </Show>\n\n {/* ── 2. API 配置 ── */}\n <div\n id=\"api-settings\"\n classList={{ hidden: !isTab(\"data\") }}\n class=\"rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6 shadow-sm\"\n >\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10 text-amber-500 font-bold\">\n <svg\n class=\"h-5 w-5\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n >\n <path d=\"M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z\" />\n <polyline points=\"3.27 6.96 12 12.01 20.73 6.96\" />\n <line x1=\"12\" y1=\"22.08\" x2=\"12\" y2=\"12\" />\n </svg>\n </div>\n <h1 class=\"text-xl font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.data.title}\n </h1>\n </div>\n <p class=\"mt-2 text-sm text-[color:var(--text-secondary)]\">\n {SETTINGS.data.subtitle}\n </p>\n\n <div class=\"mt-8 space-y-6\">\n {/* FMP Subsection */}\n <div class=\"rounded-xl border border-[color:var(--border)] bg-[color:var(--panel)] p-5\">\n <div class=\"flex items-center justify-between\">\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-6 w-6 items-center justify-center rounded bg-emerald-500/10 text-emerald-500 font-extrabold text-[10px]\">\n FMP\n </div>\n <div>\n <div class=\"text-sm font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.data.fmp.name}\n </div>\n <div class=\"mt-0.5 text-[10px] text-[color:var(--text-secondary)]\">\n {SETTINGS.data.fmp.description}\n </div>\n </div>\n </div>\n <input\n type=\"checkbox\"\n checked={true}\n disabled\n class=\"h-3.5 w-3.5 rounded border-[color:var(--border)] text-[color:var(--accent)]\"\n />\n </div>\n <form\n class=\"mt-4 space-y-4\"\n onSubmit={(event) => void submitFmpSettings(event)}\n >\n <fieldset\n disabled={!backend.state.isDesktop || fmpSettingsRes.loading}\n class=\"space-y-3\"\n >\n <Index each={fmpDraft().apiKeys}>\n {(key, index) => (\n <div class=\"flex items-center gap-2\">\n <div class=\"relative flex-1\">\n <input\n type={showFmpKeys()[index] ? \"text\" : \"password\"}\n placeholder={SETTINGS.data.fmp.key_placeholder}\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={key()}\n onInput={(e) =>\n updateApiKeyDraft(\n setFmpDraftState,\n index,\n e.currentTarget.value,\n )\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() =>\n toggleApiKeyDraftVisibility(setFmpDraftState, index)\n }\n >\n <Show\n when={showFmpKeys()[index]}\n fallback={\n <svg\n class=\"h-3.5 w-3.5\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n />\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\"\n />\n </svg>\n }\n >\n <svg\n class=\"h-3.5 w-3.5\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.046m4.596-4.596A9.964 9.964 0 0112 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\"\n />\n </svg>\n </Show>\n </button>\n </div>\n <Show when={fmpDraft().apiKeys.length > 1}>\n <button\n type=\"button\"\n class=\"text-xs text-rose-500 px-2 font-medium\"\n onClick={() =>\n removeApiKeyDraftRow(\n setFmpDraftState,\n index,\n )\n }\n >\n {SETTINGS.data.fmp.remove}\n </button>\n </Show>\n </div>\n )}\n </Index>\n <div class=\"flex items-center justify-between pt-1\">\n <button\n type=\"button\"\n class=\"text-[10px] font-bold text-[color:var(--accent)]\"\n onClick={() => addApiKeyDraftRow(setFmpDraftState)}\n >\n {SETTINGS.data.fmp.add_key}\n </button>\n <button\n type=\"submit\"\n class=\"rounded bg-[color:var(--accent)] px-3 py-1 text-xs font-bold text-white shadow-sm\"\n disabled={fmpSaving()}\n >\n {fmpSaving() ? SETTINGS.data.fmp.saving : SETTINGS.data.fmp.save}\n </button>\n </div>\n </fieldset>\n </form>\n </div>\n\n {/* Tavily Subsection */}\n <div class=\"rounded-xl border border-[color:var(--border)] bg-[color:var(--panel)] p-5\">\n <div class=\"flex items-center justify-between\">\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-6 w-6 items-center justify-center rounded bg-blue-500/10 text-blue-500 font-extrabold text-[10px]\">\n TAV\n </div>\n <div>\n <div class=\"text-sm font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.data.tavily.name}\n </div>\n <div class=\"mt-0.5 text-[10px] text-[color:var(--text-secondary)]\">\n {SETTINGS.data.tavily.description}\n </div>\n </div>\n </div>\n <input\n type=\"checkbox\"\n checked={true}\n disabled\n class=\"h-3.5 w-3.5 rounded border-[color:var(--border)] text-[color:var(--accent)]\"\n />\n </div>\n <form\n class=\"mt-4 space-y-4\"\n onSubmit={(event) => void submitTavilySettings(event)}\n >\n <fieldset\n disabled={!backend.state.isDesktop || tavilySettingsRes.loading}\n class=\"space-y-3\"\n >\n <Index each={tavilyDraft().apiKeys}>\n {(key, index) => (\n <div class=\"flex items-center gap-2\">\n <div class=\"relative flex-1\">\n <input\n type={showTavilyKeys()[index] ? \"text\" : \"password\"}\n placeholder=\"tvly-...\"\n class=\"w-full rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-sm text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={key()}\n onInput={(e) =>\n updateApiKeyDraft(\n setTavilyDraftState,\n index,\n e.currentTarget.value,\n )\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() =>\n toggleApiKeyDraftVisibility(\n setTavilyDraftState,\n index,\n )\n }\n >\n <Show\n when={showTavilyKeys()[index]}\n fallback={\n <svg\n class=\"h-3.5 w-3.5\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"\n />\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\"\n />\n </svg>\n }\n >\n <svg\n class=\"h-3.5 w-3.5\"\n fill=\"none\"\n viewBox=\"0 0 24 24\"\n stroke=\"currentColor\"\n >\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.046m4.596-4.596A9.964 9.964 0 0112 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\"\n />\n </svg>\n </Show>\n </button>\n </div>\n <Show when={tavilyDraft().apiKeys.length > 1}>\n <button\n type=\"button\"\n class=\"text-xs text-rose-500 px-2 font-medium\"\n onClick={() =>\n removeApiKeyDraftRow(\n setTavilyDraftState,\n index,\n )\n }\n >\n {SETTINGS.data.tavily.remove}\n </button>\n </Show>\n </div>\n )}\n </Index>\n <div class=\"flex items-center justify-between pt-1\">\n <button\n type=\"button\"\n class=\"text-[10px] font-bold text-[color:var(--accent)]\"\n onClick={() => addApiKeyDraftRow(setTavilyDraftState)}\n >\n {SETTINGS.data.tavily.add_key}\n </button>\n <button\n type=\"submit\"\n class=\"rounded bg-[color:var(--accent)] px-3 py-1 text-xs font-bold text-white shadow-sm\"\n disabled={tavilySaving()}\n >\n {tavilySaving()\n ? SETTINGS.data.tavily.saving\n : SETTINGS.data.tavily.save}\n </button>\n </div>\n </fieldset>\n </form>\n </div>\n </div>\n </div>\n\n {/* ── 2.5. 通知推送偏好 ── */}\n <div\n id=\"notification-prefs\"\n classList={{ hidden: !isTab(\"notify\") }}\n class=\"rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6 shadow-sm\"\n >\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/10 text-violet-500 font-bold\">\n <svg\n class=\"h-5 w-5\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n >\n <path d=\"M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9\" />\n <path d=\"M13.73 21a2 2 0 01-3.46 0\" />\n </svg>\n </div>\n <div>\n <h1 class=\"text-xl font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.notify.title}\n </h1>\n <p class=\"mt-1 text-sm text-[color:var(--text-secondary)]\">\n {SETTINGS.notify.subtitle}\n </p>\n </div>\n </div>\n <div class=\"mt-6\">\n <NotificationPreferencesCard />\n </div>\n </div>\n\n {/* ── 3. 渠道设置 ── */}\n <div\n id=\"channel-settings\"\n classList={{ hidden: !isTab(\"channel\") }}\n class=\"rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6 shadow-sm\"\n >\n <form onSubmit={(event) => void submitChannels(event)}>\n <fieldset\n disabled={!backend.state.connected || !desktopChannelSettings()}\n class=\"space-y-6 disabled:opacity-60\"\n >\n <div class=\"flex items-start justify-between gap-4\">\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/10 text-sky-500 font-bold\">\n <svg\n class=\"h-5 w-5\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n >\n <path d=\"M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z\" />\n </svg>\n </div>\n <div>\n <h1 class=\"text-xl font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.channel.title}\n </h1>\n <p class=\"mt-1 text-sm text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.subtitle}\n </p>\n </div>\n </div>\n <button\n type=\"button\"\n class=\"rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1.5 text-xs text-[color:var(--text-primary)] transition hover:bg-black/5\"\n onClick={() =>\n setChannelSettingsReloadToken((value) => value + 1)\n }\n >\n {SETTINGS.channel.refresh}\n </button>\n </div>\n\n <div class=\"grid gap-6 md:grid-cols-2\">\n {/* Feishu */}\n <div class=\"space-y-4 rounded-xl border border-[color:var(--border)] bg-[color:var(--panel)] p-5\">\n <div class=\"flex items-center justify-between\">\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-10 w-10 items-center justify-center rounded-full bg-[#3370ff]/10 text-[#3370ff]\">\n <svg\n class=\"h-6 w-6\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n >\n <path d=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.5 9h-9c-.28 0-.5-.22-.5-.5s.22-.5.5-.5h9c.28 0 .5.22.5.5s-.22.5-.5.5zm0 3h-9c-.28 0-.5-.22-.5-.5s.22-.5.5-.5h9c.28 0 .5.22.5.5s-.22.5-.5.5z\" />\n </svg>\n </div>\n <div class=\"font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.channel.feishu.name}\n </div>\n </div>\n <label class=\"relative inline-flex cursor-pointer items-center\">\n <input\n type=\"checkbox\"\n class=\"peer sr-only\"\n checked={channelDraft().feishuEnabled}\n onChange={(e) =>\n updateChannelDraft({\n feishuEnabled: e.currentTarget.checked,\n })\n }\n />\n <div class=\"peer h-5 w-9 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:bg-[color:var(--accent)] peer-checked:after:translate-x-full dark:bg-gray-700\"></div>\n </label>\n </div>\n <Show when={channelDraft().feishuEnabled}>\n <div class=\"space-y-3 pt-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.feishu.app_id_label}\n </label>\n <input\n type=\"text\"\n placeholder={SETTINGS.channel.feishu.app_id_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().feishuAppId || \"\"}\n onInput={(e) =>\n updateChannelDraft({\n feishuAppId: e.currentTarget.value,\n })\n }\n />\n </div>\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.feishu.app_secret_label}\n </label>\n <div class=\"relative\">\n <input\n type={showFeishuSecret() ? \"text\" : \"password\"}\n placeholder={SETTINGS.channel.feishu.app_secret_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 pr-14 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().feishuAppSecret || \"\"}\n onInput={(e) =>\n updateChannelDraft({\n feishuAppSecret: e.currentTarget.value,\n })\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 rounded px-1.5 py-0.5 text-[10px] text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() => setShowFeishuSecret((v) => !v)}\n >\n {showFeishuSecret()\n ? SETTINGS.channel.feishu.hide\n : SETTINGS.channel.feishu.show}\n </button>\n </div>\n </div>\n <div class=\"grid gap-3 md:grid-cols-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.chat_scope_label}\n </label>\n <select\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().feishuChatScope || \"DM_ONLY\"}\n onChange={(e) =>\n updateChannelDraft({\n feishuChatScope: e.currentTarget.value,\n })\n }\n >\n <For each={CHANNEL_CHAT_SCOPES}>\n {(scope) => <option value={scope}>{scope}</option>}\n </For>\n </select>\n </div>\n </div>\n <div class=\"grid gap-3 md:grid-cols-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.allow_emails_label}\n </label>\n <input\n type=\"text\"\n placeholder={SETTINGS.channel.common.csv_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={formatCsv(channelDraft().feishuAllowEmails)}\n onChange={(e) =>\n updateChannelDraft({\n feishuAllowEmails: parseCsv(e.currentTarget.value),\n })\n }\n />\n </div>\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.allow_open_ids_label}\n </label>\n <input\n type=\"text\"\n placeholder={SETTINGS.channel.common.csv_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={formatCsv(channelDraft().feishuAllowOpenIds)}\n onChange={(e) =>\n updateChannelDraft({\n feishuAllowOpenIds: parseCsv(e.currentTarget.value),\n })\n }\n />\n </div>\n </div>\n <div class=\"grid gap-3 md:grid-cols-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.allow_mobiles_label}\n </label>\n <input\n type=\"text\"\n placeholder={SETTINGS.channel.common.csv_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={formatCsv(channelDraft().feishuAllowMobiles)}\n onChange={(e) =>\n updateChannelDraft({\n feishuAllowMobiles: parseCsv(e.currentTarget.value),\n })\n }\n />\n </div>\n </div>\n </div>\n </Show>\n </div>\n\n {/* Discord */}\n <div class=\"space-y-4 rounded-xl border border-[color:var(--border)] bg-[color:var(--panel)] p-5\">\n <div class=\"flex items-center justify-between\">\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-10 w-10 items-center justify-center rounded-full bg-[#5865F2]/10 text-[#5865F2]\">\n <svg\n class=\"h-6 w-6\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n >\n <path d=\"M20.317 4.37c-1.215-.503-2.546-.882-3.932-1.057a.06.06 0 00-.063.03c-.157.28-.344.66-.464.945-1.497-.225-2.991-.225-4.463 0-.12-.285-.312-.665-.472-.945a.061.061 0 00-.063-.03 15.343 15.343 0 00-3.931 1.056.052.052 0 00-.024.02C4.195 7.42 2.91 10.375 3.328 13.25a.066.066 0 00.026.046 15.485 15.485 0 004.757 2.413.064.064 0 00.069-.022c.36-.492.684-1.02.954-1.574a.062.062 0 00-.034-.085c-.504-.19-1.002-.42-1.468-.69a.065.065 0 01-.006-.109c.097-.074.196-.15.291-.228a.063.063 0 01.066-.009 11.2 11.2 0 009.11 0 .063.063 0 01.067.01c.094.077.193.153.29.227a.065.065 0 01-.006.11c-.465.269-.963.499-1.467.689a.061.061 0 00-.034.086c.27.554.594 1.082.955 1.574a.063.063 0 00.068.022 15.441 15.441 0 004.759-2.413.06.06 0 00.026-.046c.491-3.415-.843-6.33-3.11-8.86a.052.052 0 00-.023-.02zM8.02 11.08c-.908 0-1.657-.84-1.657-1.87 0-1.03.731-1.87 1.657-1.87.935 0 1.666.84 1.657 1.87 0 1.03-.731 1.87-1.657 1.87zm7.96 0c-.908 0-1.657-.84-1.657-1.87 0-1.03.731-1.87 1.657-1.87.935 0 1.666.84 1.657 1.87 0 1.03-.732 1.87-1.657 1.87z\" />\n </svg>\n </div>\n <div class=\"font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.channel.discord.name}\n </div>\n </div>\n <label class=\"relative inline-flex cursor-pointer items-center\">\n <input\n type=\"checkbox\"\n class=\"peer sr-only\"\n checked={channelDraft().discordEnabled}\n onChange={(e) =>\n updateChannelDraft({\n discordEnabled: e.currentTarget.checked,\n })\n }\n />\n <div class=\"peer h-5 w-9 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:bg-[color:var(--accent)] peer-checked:after:translate-x-full dark:bg-gray-700\"></div>\n </label>\n </div>\n <Show when={channelDraft().discordEnabled}>\n <div class=\"space-y-1 pt-2\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.discord.bot_token_label}\n </label>\n <div class=\"relative\">\n <input\n type={showDiscordToken() ? \"text\" : \"password\"}\n placeholder={SETTINGS.channel.discord.bot_token_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 pr-14 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().discordBotToken || \"\"}\n onInput={(e) =>\n updateChannelDraft({\n discordBotToken: e.currentTarget.value,\n })\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 rounded px-1.5 py-0.5 text-[10px] text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() => setShowDiscordToken((v) => !v)}\n >\n {showDiscordToken()\n ? SETTINGS.channel.discord.hide\n : SETTINGS.channel.discord.show}\n </button>\n </div>\n <div class=\"grid gap-3 md:grid-cols-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.chat_scope_label}\n </label>\n <select\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().discordChatScope || \"DM_ONLY\"}\n onChange={(e) =>\n updateChannelDraft({\n discordChatScope: e.currentTarget.value,\n })\n }\n >\n <For each={CHANNEL_CHAT_SCOPES}>\n {(scope) => <option value={scope}>{scope}</option>}\n </For>\n </select>\n </div>\n </div>\n <div class=\"grid gap-3 md:grid-cols-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.allow_from_label}\n </label>\n <input\n type=\"text\"\n placeholder={SETTINGS.channel.common.csv_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={formatCsv(channelDraft().discordAllowFrom)}\n onChange={(e) =>\n updateChannelDraft({\n discordAllowFrom: parseCsv(e.currentTarget.value),\n })\n }\n />\n </div>\n </div>\n </div>\n </Show>\n </div>\n\n {/* Telegram */}\n <div class=\"space-y-4 rounded-xl border border-[color:var(--border)] bg-[color:var(--panel)] p-5\">\n <div class=\"flex items-center justify-between\">\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-10 w-10 items-center justify-center rounded-full bg-[#0088cc]/10 text-[#0088cc]\">\n <svg\n class=\"h-6 w-6\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n >\n <path d=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69.01-.03.01-.14-.07-.2-.08-.06-.19-.04-.27-.02-.12.02-1.96 1.25-5.54 3.69-.52.36-1 .54-1.43.53-.48-.01-1.4-.27-2.09-.49-.84-.27-1.51-.42-1.45-.88.03-.24.37-.48 1.02-.73 4-1.74 6.67-2.89 8.01-3.44 3.82-1.58 4.61-1.85 5.13-1.86.11 0 .37.03.54.17.14.12.18.28.2.45-.02.07-.02.13-.03.19z\" />\n </svg>\n </div>\n <div class=\"font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.channel.telegram.name}\n </div>\n </div>\n <label class=\"relative inline-flex cursor-pointer items-center\">\n <input\n type=\"checkbox\"\n class=\"peer sr-only\"\n checked={channelDraft().telegramEnabled}\n onChange={(e) =>\n updateChannelDraft({\n telegramEnabled: e.currentTarget.checked,\n })\n }\n />\n <div class=\"peer h-5 w-9 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:bg-[color:var(--accent)] peer-checked:after:translate-x-full dark:bg-gray-700\"></div>\n </label>\n </div>\n <Show when={channelDraft().telegramEnabled}>\n <div class=\"space-y-1 pt-2\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.telegram.bot_token_label}\n </label>\n <div class=\"relative\">\n <input\n type={showTelegramToken() ? \"text\" : \"password\"}\n placeholder={SETTINGS.channel.telegram.bot_token_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 pr-14 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().telegramBotToken || \"\"}\n onInput={(e) =>\n updateChannelDraft({\n telegramBotToken: e.currentTarget.value,\n })\n }\n />\n <button\n type=\"button\"\n class=\"absolute right-2 top-1/2 -translate-y-1/2 rounded px-1.5 py-0.5 text-[10px] text-[color:var(--text-secondary)] hover:text-[color:var(--text-primary)]\"\n onClick={() => setShowTelegramToken((v) => !v)}\n >\n {showTelegramToken()\n ? SETTINGS.channel.telegram.hide\n : SETTINGS.channel.telegram.show}\n </button>\n </div>\n <div class=\"grid gap-3 md:grid-cols-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.chat_scope_label}\n </label>\n <select\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().telegramChatScope || \"DM_ONLY\"}\n onChange={(e) =>\n updateChannelDraft({\n telegramChatScope: e.currentTarget.value,\n })\n }\n >\n <For each={CHANNEL_CHAT_SCOPES}>\n {(scope) => <option value={scope}>{scope}</option>}\n </For>\n </select>\n </div>\n </div>\n <div class=\"grid gap-3 md:grid-cols-2\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.allow_from_label}\n </label>\n <input\n type=\"text\"\n placeholder={SETTINGS.channel.common.csv_placeholder}\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={formatCsv(channelDraft().telegramAllowFrom)}\n onChange={(e) =>\n updateChannelDraft({\n telegramAllowFrom: parseCsv(e.currentTarget.value),\n })\n }\n />\n </div>\n </div>\n </div>\n </Show>\n </div>\n\n {/* iMessage */}\n <div class=\"group relative overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--panel)] p-5 transition-all\">\n <div class=\"flex items-center justify-between\">\n <div class=\"flex items-center gap-3\">\n <div class=\"flex h-10 w-10 items-center justify-center rounded-full bg-[#34C759]/10 text-[#34C759]\">\n <svg\n class=\"h-6 w-6\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n >\n <path d=\"M12 2C6.48 2 2 5.92 2 10.74c0 2.22 1.03 4.25 2.74 5.75-.12.44-.74 2.1-1.74 3.5 0 0 2.13 0 4.14-1.22.88.24 1.83.37 2.86.37 5.52 0 10-3.92 10-8.74S17.52 2 12 2z\" />\n </svg>\n </div>\n <div>\n <div class=\"font-bold text-[color:var(--text-primary)]\">\n {SETTINGS.channel.imessage.name}\n </div>\n <div class=\"text-[10px] font-bold text-amber-600\">\n {SETTINGS.channel.imessage.warning}\n </div>\n </div>\n </div>\n <label class=\"relative inline-flex cursor-pointer items-center\">\n <input\n type=\"checkbox\"\n class=\"peer sr-only\"\n checked={channelDraft().imessageEnabled}\n onChange={(e) =>\n updateChannelDraft({\n imessageEnabled: e.currentTarget.checked,\n })\n }\n />\n <div class=\"peer h-5 w-9 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:bg-[color:var(--accent)] peer-checked:after:translate-x-full dark:bg-gray-700\"></div>\n </label>\n </div>\n <Show when={channelDraft().imessageEnabled}>\n <div class=\"mt-4 space-y-3\">\n <div class=\"space-y-1\">\n <label class=\"text-[10px] font-bold uppercase tracking-wider text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.common.target_handle_label}\n </label>\n <input\n type=\"text\"\n placeholder=\"+15551234567\"\n class=\"w-full rounded border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs text-[color:var(--text-primary)] outline-none focus:border-[color:var(--accent)]\"\n value={channelDraft().imessageTargetHandle || \"\"}\n onInput={(e) =>\n updateChannelDraft({\n imessageTargetHandle: e.currentTarget.value,\n })\n }\n />\n </div>\n </div>\n </Show>\n </div>\n </div>\n\n <div class=\"mt-8 flex items-center justify-between border-t border-[color:var(--border)] pt-6\">\n <div class=\"text-xs text-[color:var(--text-secondary)]\">\n {SETTINGS.channel.sync_note}\n </div>\n <button\n type=\"submit\"\n class=\"rounded-md bg-[color:var(--accent)] px-6 py-2 text-sm font-bold text-white shadow-sm transition-all hover:opacity-90 active:scale-95 disabled:opacity-50\"\n disabled={backend.state.saving}\n >\n {backend.state.saving\n ? SETTINGS.channel.saving\n : SETTINGS.channel.save}\n </button>\n </div>\n </fieldset>\n </form>\n </div>\n </div>\n </div>\n );\n}\n"}}, "bins/hone-desktop/src/sidecar.rs": {"adf14cae4ada95b61f9ac3b277a864035b231dbb": {"t": 1785674008.0223143, "n": 69473, "h": "2fe4b4a63be3a8f4", "c": "use std::collections::BTreeMap;\nuse std::env;\nuse std::fs;\nuse std::fs::OpenOptions;\nuse std::io::Write;\nuse std::path::{Path, PathBuf};\nuse std::process::Command as StdCommand;\nuse std::sync::Mutex;\nuse std::time::Duration;\n\nuse hone_core::HoneConfig;\nuse reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};\nuse serde::{Deserialize, Serialize};\nuse tauri::async_runtime;\nuse tauri::{AppHandle, Manager, State};\nuse tauri_plugin_shell::{\n ShellExt,\n process::{CommandChild, CommandEvent},\n};\n\n#[cfg(not(test))]\nuse rfd::{MessageButtons, MessageDialog, MessageLevel};\n\nmod processes;\nmod runtime_env;\nmod settings;\n\nuse self::{processes::*, runtime_env::*, settings::*};\n\n#[cfg(test)]\nuse std::sync::Arc;\n#[cfg(test)]\nuse std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};\n\nconst API_VERSION: &str = \"desktop-v1\";\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct BackendConfig {\n mode: String,\n #[serde(default, alias = \"base_url\")]\n base_url: String,\n #[serde(default, alias = \"bearer_token\")]\n bearer_token: String,\n}\n\nimpl Default for BackendConfig {\n fn default() -> Self {\n Self {\n mode: \"bundled\".to_string(),\n base_url: String::new(),\n bearer_token: String::new(),\n }\n }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct DesktopChannelSettings {\n config_path: String,\n imessage_enabled: bool,\n #[serde(default)]\n imessage_target_handle: String,\n feishu_enabled: bool,\n #[serde(default)]\n feishu_app_id: String,\n #[serde(default)]\n feishu_app_secret: String,\n #[serde(default)]\n feishu_chat_scope: String,\n #[serde(default)]\n feishu_allow_emails: Vec<String>,\n #[serde(default)]\n feishu_allow_mobiles: Vec<String>,\n #[serde(default)]\n feishu_allow_open_ids: Vec<String>,\n telegram_enabled: bool,\n #[serde(default)]\n telegram_bot_token: String,\n #[serde(default)]\n telegram_chat_scope: String,\n #[serde(default)]\n telegram_allow_from: Vec<String>,\n discord_enabled: bool,\n #[serde(default)]\n discord_bot_token: String,\n #[serde(default)]\n discord_chat_scope: String,\n #[serde(default)]\n discord_allow_from: Vec<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct DesktopChannelSettingsInput {\n imessage_enabled: bool,\n #[serde(default)]\n imessage_target_handle: String,\n feishu_enabled: bool,\n #[serde(default)]\n feishu_app_id: String,\n #[serde(default)]\n feishu_app_secret: String,\n #[serde(default)]\n feishu_chat_scope: String,\n #[serde(default)]\n feishu_allow_emails: Vec<String>,\n #[serde(default)]\n feishu_allow_mobiles: Vec<String>,\n #[serde(default)]\n feishu_allow_open_ids: Vec<String>,\n telegram_enabled: bool,\n #[serde(default)]\n telegram_bot_token: String,\n #[serde(default)]\n telegram_chat_scope: String,\n #[serde(default)]\n telegram_allow_from: Vec<String>,\n discord_enabled: bool,\n #[serde(default)]\n discord_bot_token: String,\n #[serde(default)]\n discord_chat_scope: String,\n #[serde(default)]\n discord_allow_from: Vec<String>,\n}\n\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct DesktopChannelSettingsUpdateResult {\n settings: DesktopChannelSettings,\n restarted_bundled_backend: bool,\n message: String,\n backend_status: Option<BackendStatusInfo>,\n}\n\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct AgentSettingsUpdateResult {\n settings: AgentSettings,\n restarted_bundled_backend: bool,\n message: String,\n backend_status: Option<BackendStatusInfo>,\n}\n\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct ChannelProcessCleanupEntry {\n channel: String,\n kept_pid: Option<u32>,\n removed_pids: Vec<u32>,\n}\n\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct ChannelProcessCleanupResult {\n entries: Vec<ChannelProcessCleanupEntry>,\n message: String,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct AuxiliarySettings {\n #[serde(default)]\n base_url: String,\n #[serde(default)]\n api_key: String,\n #[serde(default)]\n model: String,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct HoneCloudSettings {\n #[serde(default)]\n base_url: String,\n #[serde(default)]\n api_key: String,\n #[serde(default)]\n model: String,\n}\n\nconst LLM_PROFILE_UI_IDS: &[&str] = &[\n \"main\",\n \"aux\",\n \"news_classifier\",\n \"filing_summary\",\n \"earnings_quality\",\n \"digest_fast\",\n \"digest_strong\",\n \"mainline_short\",\n];\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct LlmProfileEntrySettings {\n id: String,\n #[serde(default)]\n provider: String,\n #[serde(default)]\n model: String,\n #[serde(default)]\n max_tokens: Option<u32>,\n #[serde(default)]\n temperature: Option<f32>,\n #[serde(default)]\n top_p: Option<f32>,\n #[serde(default)]\n reasoning_effort: Option<String>,\n #[serde(default)]\n reasoning_max_tokens: Option<u32>,\n #[serde(default)]\n response_format_json: bool,\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct LlmProfileSettings {\n #[serde(default)]\n default_profile: String,\n #[serde(default)]\n auxiliary_profile: String,\n #[serde(default)]\n polish_profile: String,\n #[serde(default)]\n news_classifier_profile: String,\n #[serde(default)]\n filing_summary_profile: String,\n #[serde(default)]\n earnings_quality_profile: String,\n #[serde(default)]\n digest_pass1_profile: String,\n #[serde(default)]\n digest_pass2_profile: String,\n #[serde(default)]\n digest_event_dedupe_profile: String,\n #[serde(default)]\n mainline_distill_profile: String,\n #[serde(default)]\n profiles: Vec<LlmProfileEntrySettings>,\n}\n\n/// Desktop Agent settings payload; saving writes canonical config.yaml and regenerates effective config.\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct AgentSettings {\n /// gemini_cli | codex_cli | codex_acp | opencode_acp | hone_cloud\n /// gemini_acp remains parseable for legacy config migration but is disabled at runtime.\n runner: String,\n /// codex_cli 专用,其他 provider 忽略\n codex_model: String,\n /// OpenAI 协议渠道 Base URL(agent.opencode.api_base_url)\n #[serde(default)]\n openai_url: String,\n /// OpenAI 协议渠道模型名(agent.opencode.model)\n #[serde(default)]\n openai_model: String,\n /// OpenAI 协议渠道 API Key(agent.opencode.api_key)\n #[serde(default)]\n openai_api_key: String,\n #[serde(default)]\n auxiliary: Option<AuxiliarySettings>,\n #[serde(default)]\n hone_cloud: Option<HoneCloudSettings>,\n #[serde(default)]\n llm_profiles: Option<LlmProfileSettings>,\n}\n\n/// CLI 联通检测结果\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct CliCheckResult {\n ok: bool,\n message: String,\n}\n\n/// OpenRouter API Key 设置(写入 canonical config 的 llm.providers.openrouter.api_keys)\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct OpenRouterSettings {\n /// 多 Key 列表(支持 fallback)\n api_keys: Vec<String>,\n}\n\n/// FMP API Key 设置(写入 canonical config.yaml 的 fmp.api_keys)\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct FmpSettings {\n /// 多 Key 列表(支持 fallback)\n api_keys: Vec<String>,\n}\n\n/// Tavily API Key 设置(写入 canonical config.yaml 的 search.api_keys)\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct TavilySettings {\n /// 多 Key 列表(支持 fallback)\n api_keys: Vec<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct MetaInfo {\n name: String,\n version: String,\n channel: String,\n supports_imessage: bool,\n api_version: String,\n capabilities: Vec<String>,\n deployment_mode: String,\n}\n\nfn desktop_meta_from_config(config: &HoneConfig, deployment_mode: &str) -> MetaInfo {\n let mut capabilities = vec![\n \"channels\".to_string(),\n \"users\".to_string(),\n \"history\".to_string(),\n \"chat\".to_string(),\n \"sse.events\".to_string(),\n \"logs\".to_string(),\n \"skills\".to_string(),\n \"cron_jobs\".to_string(),\n \"portfolio\".to_string(),\n \"company_profiles\".to_string(),\n \"company_profile_transfer\".to_string(),\n \"research\".to_string(),\n \"llm_audit\".to_string(),\n \"web_invites\".to_string(),\n ];\n\n if deployment_mode == \"local\" {\n capabilities.push(\"local_file_proxy\".to_string());\n }\n\n if cfg!(target_os = \"macos\") && config.imessage.enabled {\n capabilities.push(\"imessage\".to_string());\n }\n\n capabilities.sort();\n\n MetaInfo {\n name: \"Hone\".to_string(),\n version: env!(\"CARGO_PKG_VERSION\").to_string(),\n channel: \"imessage\".to_string(),\n supports_imessage: cfg!(target_os = \"macos\"),\n api_version: API_VERSION.to_string(),\n capabilities,\n deployment_mode: deployment_mode.to_string(),\n }\n}\n\n#[derive(Debug, Clone, Serialize)]\npub(crate) struct BackendStatusInfo {\n config: BackendConfig,\n resolved_base_url: Option<String>,\n connected: bool,\n last_error: Option<String>,\n meta: Option<MetaInfo>,\n diagnostics: DiagnosticPaths,\n}\n\n#[derive(Default)]\npub(crate) struct DesktopState {\n desktop_lock: Mutex<Option<hone_core::ProcessLockGuard>>,\n inner: Mutex<DesktopBackendManager>,\n config_write_lock: tokio::sync::Mutex<()>,\n transition_lock: tokio::sync::Mutex<()>,\n}\n\n#[derive(Default)]\nstruct DesktopBackendManager {\n config: BackendConfig,\n resolved_base_url: Option<String>,\n meta: Option<MetaInfo>,\n last_error: Option<String>,\n /// 内嵌 Web API 及其 per-startup 后台任务句柄(bundled 模式)\n web_server_tasks: Vec<tokio::task::JoinHandle<()>>,\n /// bundled 模式下的 hone-console-page 生命周期锁\n bundled_web_lock: Option<hone_core::ProcessLockGuard>,\n /// 各 channel sidecar 子进程(imessage / discord / feishu / telegram)\n channel_children: BTreeMap<String, CommandChild>,\n diagnostics: Option<DiagnosticPaths>,\n}\n\nimpl Drop for DesktopBackendManager {\n fn drop(&mut self) {\n stop_managed_children(self);\n }\n}\n\nstruct RuntimePaths {\n config_path: PathBuf,\n effective_config_path: PathBuf,\n data_dir: PathBuf,\n runtime_dir: PathBuf,\n sandbox_dir: PathBuf,\n skills_dir: PathBuf,\n}\n\n#[derive(Debug, Clone, Serialize)]\nstruct DiagnosticPaths {\n config_dir: String,\n data_dir: String,\n logs_dir: String,\n desktop_log: String,\n sidecar_log: String,\n}\n\nfn bundled_runtime_ready(manager: &DesktopBackendManager) -> bool {\n manager.last_error.is_none()\n && manager.bundled_web_lock.is_some()\n && manager.meta.is_some()\n && manager.resolved_base_url.is_some()\n}\n\nfn mark_bundled_runtime_dirty(manager: &mut DesktopBackendManager, reason: &str) {\n manager.meta = None;\n manager.last_error = Some(reason.to_string());\n}\n\nfn resolved_base_url(config: &BackendConfig, manager: &DesktopBackendManager) -> Option<String> {\n match config.mode.as_str() {\n \"bundled\" => manager.resolved_base_url.clone(),\n \"remote\" => Some(normalize_base_url(&config.base_url)),\n _ => None,\n }\n}\n\nfn backend_status_snapshot(manager: &DesktopBackendManager) -> BackendStatusInfo {\n let resolved = resolved_base_url(&manager.config, manager);\n BackendStatusInfo {\n config: manager.config.clone(),\n resolved_base_url: resolved,\n connected: manager.meta.is_some() && manager.last_error.is_none(),\n last_error: manager.last_error.clone(),\n meta: manager.meta.clone(),\n diagnostics: manager\n .diagnostics\n .clone()\n .unwrap_or_else(DesktopBackendManager::fallback_diagnostics),\n }\n}\n\nfn validate_meta(meta: MetaInfo) -> Result<MetaInfo, String> {\n if meta.api_version == API_VERSION {\n Ok(meta)\n } else {\n Err(format!(\n \"unsupported backend api_version: {} (expected {API_VERSION})\",\n meta.api_version\n ))\n }\n}\n\npub(crate) fn record_startup_error(app: &AppHandle, message: &str) {\n log_desktop(\n app,\n \"ERROR\",\n format!(\"desktop startup blocked before backend bootstrap: {message}\"),\n );\n eprintln!(\"Hone Startup Blocked: {message}\");\n}\n\nfn startup_error_dialog_suppressed_from_env_value(value: Option<&str>) -> bool {\n matches!(\n value.map(|raw| raw.trim().to_ascii_lowercase()),\n Some(value) if matches!(value.as_str(), \"1\" | \"true\" | \"yes\" | \"on\")\n )\n}\n\nfn startup_error_dialog_suppressed() -> bool {\n startup_error_dialog_suppressed_from_env_value(\n env::var(\"HONE_SUPPRESS_STARTUP_DIALOG\").ok().as_deref(),\n ) || startup_error_dialog_suppressed_from_env_value(env::var(\"CI\").ok().as_deref())\n}\n\n#[cfg(not(test))]\nfn spawn_startup_error_dialog(message: String) {\n let _ = std::thread::Builder::new()\n .name(\"hone-startup-error-dialog\".to_string())\n .spawn(move || {\n let _ = MessageDialog::new()\n .set_level(MessageLevel::Error)\n .set_title(\"Hone Startup Blocked\")\n .set_description(&message)\n .set_buttons(MessageButtons::Ok)\n .show();\n });\n}\n\n#[cfg(test)]\nstatic STARTUP_ERROR_DIALOG_SPAWNS: AtomicUsize = AtomicUsize::new(0);\n\n#[cfg(test)]\nfn spawn_startup_error_dialog(_message: String) {\n STARTUP_ERROR_DIALOG_SPAWNS.fetch_add(1, Ordering::SeqCst);\n}\n\npub(crate) fn show_startup_error_dialog(message: &str) {\n if startup_error_dialog_suppressed() {\n return;\n }\n spawn_startup_error_dialog(message.to_string());\n}\n\nimpl DesktopBackendManager {\n fn fallback_diagnostics() -> DiagnosticPaths {\n DiagnosticPaths {\n config_dir: String::new(),\n data_dir: String::new(),\n logs_dir: String::new(),\n desktop_log: String::new(),\n sidecar_log: String::new(),\n }\n }\n}\n\nasync fn probe_meta(base_url: &str, bearer_token: &str) -> Result<MetaInfo, String> {\n let mut headers = HeaderMap::new();\n if !bearer_token.trim().is_empty() {\n let value = HeaderValue::from_str(&format!(\"Bearer {}\", bearer_token.trim()))\n .map_err(|e| e.to_string())?;\n headers.insert(AUTHORIZATION, value);\n }\n\n let client = reqwest::Client::builder()\n .timeout(Duration::from_secs(5))\n .default_headers(headers)\n .build()\n .map_err(|e| format!(\"client build failed: {e}; debug={e:?}\"))?;\n\n let response = client\n .get(format!(\"{}/api/meta\", normalize_base_url(base_url)))\n .send()\n .await\n .map_err(|e| format!(\"request send failed: {e}; debug={e:?}\"))?;\n\n if !response.status().is_success() {\n return Err(format!(\"backend probe failed: {}\", response.status()));\n }\n\n response\n .json::<MetaInfo>()\n .await\n .map_err(|e| format!(\"response json failed: {e}; debug={e:?}\"))\n}\n\nasync fn connect_backend_inner(\n app: &AppHandle,\n desktop: &DesktopState,\n) -> Result<BackendStatusInfo, String> {\n let config = {\n let mut guard = desktop.inner.lock().unwrap();\n let config = load_persisted_config(app)?;\n guard.config = config.clone();\n guard.diagnostics = diagnostic_paths(app).ok();\n config\n };\n\n match config.mode.as_str() {\n \"remote\" => {\n {\n let mut guard = desktop.inner.lock().unwrap();\n stop_managed_children(&mut guard);\n guard.resolved_base_url = Some(normalize_base_url(&config.base_url));\n guard.meta = None;\n guard.last_error = None;\n }\n\n let resolved = normalize_base_url(&config.base_url);\n if resolved.is_empty() {\n let mut guard = desktop.inner.lock().unwrap();\n guard.last_error = Some(\"remote base URL 不能为空\".to_string());\n log_desktop(app, \"ERROR\", \"remote base URL is empty\");\n return Ok(backend_status_snapshot(&guard));\n }\n\n match probe_meta(&resolved, &config.bearer_token)\n .await\n .and_then(validate_meta)\n {\n Ok(meta) => {\n let mut guard = desktop.inner.lock().unwrap();\n guard.meta = Some(meta);\n guard.last_error = None;\n log_desktop(app, \"INFO\", format!(\"remote backend connected: {resolved}\"));\n Ok(backend_status_snapshot(&guard))\n }\n Err(error) => {\n let mut guard = desktop.inner.lock().unwrap();\n guard.meta = None;\n let should_log = guard.last_error.as_deref() != Some(error.as_str());\n guard.last_error = Some(error.clone());\n if should_log {\n log_desktop(\n app,\n \"ERROR\",\n format!(\"remote backend probe failed url={resolved} error={error}\"),\n );\n }\n Ok(backend_status_snapshot(&guard))\n }\n }\n }\n _ => {\n let runtime = ensure_runtime_paths(app)?;\n let diagnostics = diagnostic_paths(app)?;\n let runtime_config =\n HoneConfig::from_file(&runtime.config_path).map_err(|e| e.to_string())?;\n\n {\n let guard = desktop.inner.lock().unwrap();\n if bundled_runtime_ready(&guard) {\n return Ok(backend_status_snapshot(&guard));\n }\n }\n\n // Stop the in-process web API and channel sidecars before lock preflight.\n // Otherwise a dirty bundled runtime can conflict with its own previous\n // console lock and keep the old listener alive until the port bind fails.\n {\n let mut guard = desktop.inner.lock().unwrap();\n stop_managed_children(&mut guard);\n }\n log_desktop(\n app,\n \"INFO\",\n \"bundled runtime previous managed children stopped\",\n );\n\n if let Err(message) = preflight_bundled_runtime_locks(app) {\n log_desktop(app, \"ERROR\", &message);\n show_startup_error_dialog(&message);\n let mut guard = desktop.inner.lock().unwrap();\n stop_managed_children(&mut guard);\n guard.meta = None;\n guard.last_error = Some(message);\n guard.diagnostics = Some(diagnostics);\n return Ok(backend_status_snapshot(&guard));\n }\n log_desktop(app, \"INFO\", \"bundled runtime preflight locks passed\");\n\n // 启动 Axum 服务(port=0,OS 分配可用端口)\n let config_path_str = runtime.config_path.to_string_lossy().to_string();\n let data_dir = runtime.data_dir.clone();\n let skills_dir = runtime.skills_dir.clone();\n let web_lock = match hone_core::acquire_process_lock(\n &runtime.runtime_dir,\n hone_core::PROCESS_LOCK_CONSOLE_PAGE,\n )\n .map_err(|error| {\n hone_core::format_lock_failure_message(\n hone_core::PROCESS_LOCK_CONSOLE_PAGE,\n &hone_core::process_lock_path(\n &runtime.runtime_dir,\n hone_core::PROCESS_LOCK_CONSOLE_PAGE,\n ),\n &error,\n \"Hone bundled runtime\",\n )\n }) {\n Ok(lock) => lock,\n Err(message) => {\n log_desktop(app, \"ERROR\", &message);\n let mut guard = desktop.inner.lock().unwrap();\n guard.meta = None;\n guard.last_error = Some(message);\n guard.diagnostics = Some(diagnostics);\n return Ok(backend_status_snapshot(&guard));\n }\n };\n log_desktop(app, \"INFO\", \"bundled runtime console lock acquired\");\n\n log_desktop(\n app,\n \"INFO\",\n format!(\n \"starting embedded web server data_dir={}\",\n runtime.data_dir.display()\n ),\n );\n\n log_desktop(\n app,\n \"INFO\",\n \"bundled runtime calling hone_web_api::start_server\",\n );\n match hone_web_api::start_server(\n &config_path_str,\n Some(&data_dir),\n Some(&skills_dir),\n \"local\",\n )\n .await\n {\n Ok(started) => {\n let port = started.admin_port;\n log_desktop(\n app,\n \"INFO\",\n format!(\"hone_web_api::start_server returned port={port}\"),\n );\n let base_url = format!(\"http://127.0.0.1:{port}\");\n let meta = desktop_meta_from_config(&runtime_config, \"local\");\n {\n let mut guard = desktop.inner.lock().unwrap();\n guard.resolved_base_url = Some(base_url.clone());\n guard.meta = Some(meta);\n guard.last_error = None;\n guard.diagnostics = Some(diagnostics.clone());\n guard.bundled_web_lock = Some(web_lock);\n guard.web_server_tasks = started.task_handles;\n }\n\n // 对于同进程内嵌服务,绑定成功本身已经足够说明 API 已就绪;\n // 继续自 probe 反而会把短暂的启动抖动放大成误报。\n let mut guard = desktop.inner.lock().unwrap();\n log_desktop(app, \"INFO\", \"starting managed bundled channels\");\n if let Err(e) =\n start_enabled_channels(app, &mut guard, &runtime, &diagnostics, &base_url)\n {\n let message =\n format!(\"bundled channel sidecar startup failed, runtime aborted: {e}\");\n log_desktop(app, \"ERROR\", &message);\n stop_managed_children(&mut guard);\n guard.meta = None;\n guard.last_error = Some(message);\n return Ok(backend_status_snapshot(&guard));\n }\n\n log_desktop(\n app,\n \"INFO\",\n format!(\"embedded web server ready: {base_url}\"),\n );\n Ok(backend_status_snapshot(&guard))\n }\n Err(error) => {\n let mut guard = desktop.inner.lock().unwrap();\n guard.meta = None;\n guard.last_error = Some(error.clone());\n guard.diagnostics = Some(diagnostics);\n log_desktop(\n app,\n \"ERROR\",\n format!(\"embedded web server start failed: {error}\"),\n );\n Ok(backend_status_snapshot(&guard))\n }\n }\n }\n }\n}\n\npub(crate) fn get_backend_config_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n) -> Result<BackendConfig, String> {\n let config = load_persisted_config(&app)?;\n state.inner.lock().unwrap().config = config.clone();\n Ok(config)\n}\n\npub(crate) fn set_backend_config_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n config: BackendConfig,\n) -> Result<(), String> {\n save_persisted_config(&app, &config)?;\n log_desktop(\n &app,\n \"INFO\",\n format!(\n \"saved backend config mode={} base_url={}\",\n config.mode, config.base_url\n ),\n );\n let mut guard = state.inner.lock().unwrap();\n if config.mode == \"remote\" {\n stop_managed_children(&mut guard);\n guard.resolved_base_url = Some(normalize_base_url(&config.base_url));\n guard.meta = None;\n guard.last_error = None;\n }\n guard.config = config;\n Ok(())\n}\n\nasync fn connect_backend_serialized(\n app: &AppHandle,\n state: &DesktopState,\n) -> Result<BackendStatusInfo, String> {\n let _guard = state.transition_lock.lock().await;\n connect_backend_inner(app, state).await\n}\n\nasync fn with_config_write_lock<T, F>(state: &DesktopState, op: F) -> Result<T, String>\nwhere\n F: FnOnce() -> Result<T, String>,\n{\n let _guard = state.config_write_lock.lock().await;\n op()\n}\n\npub(crate) async fn connect_backend_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n) -> Result<BackendStatusInfo, String> {\n match connect_backend_serialized(&app, &state).await {\n Ok(status) => Ok(status),\n Err(error) => {\n log_desktop(\n &app,\n \"ERROR\",\n format!(\"connect_backend command failed: {error}\"),\n );\n Err(error)\n }\n }\n}\n\npub(crate) fn bootstrap_backend_on_startup(app: AppHandle) {\n async_runtime::spawn(async move {\n let state = app.state::<DesktopState>();\n if let Err(error) = connect_backend_serialized(&app, &state).await {\n log_desktop(\n &app,\n \"ERROR\",\n format!(\"startup backend bootstrap failed: {error}\"),\n );\n }\n });\n}\n\npub(crate) fn prepare_desktop_startup(app: AppHandle) -> Result<(), String> {\n let runtime = ensure_runtime_paths(&app)?;\n configure_desktop_runtime_env(&app, &runtime);\n preflight_startup_locks(&app)\n}\n\npub(crate) async fn start_bundled_backend_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n) -> Result<BackendStatusInfo, String> {\n {\n let mut guard = state.inner.lock().unwrap();\n guard.config.mode = \"bundled\".to_string();\n guard.config.base_url.clear();\n save_persisted_config(&app, &guard.config)?;\n }\n connect_backend_serialized(&app, &state).await\n}\n\npub(crate) async fn stop_bundled_backend_impl(\n state: State<'_, DesktopState>,\n) -> Result<BackendStatusInfo, String> {\n let _guard = state.transition_lock.lock().await;\n let mut guard = state.inner.lock().unwrap();\n stop_managed_children(&mut guard);\n guard.meta = None;\n guard.last_error = Some(\"bundled backend stopped\".to_string());\n Ok(backend_status_snapshot(&guard))\n}\n\npub(crate) fn get_channel_settings_impl(app: AppHandle) -> Result<DesktopChannelSettings, String> {\n load_channel_settings(&app)\n}\n\npub(crate) async fn set_channel_settings_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n settings: DesktopChannelSettingsInput,\n) -> Result<DesktopChannelSettingsUpdateResult, String> {\n let saved = with_config_write_lock(&state, || save_channel_settings(&app, &settings)).await?;\n log_desktop(\n &app,\n \"INFO\",\n format!(\n \"saved channel settings imessage={} feishu={} telegram={} discord={}\",\n saved.imessage_enabled,\n saved.feishu_enabled,\n saved.telegram_enabled,\n saved.discord_enabled\n ),\n );\n\n let backend_config = load_persisted_config(&app).unwrap_or_default();\n if backend_config.mode == \"bundled\" {\n {\n let mut guard = state.inner.lock().unwrap();\n mark_bundled_runtime_dirty(\n &mut guard,\n \"channel settings updated; bundled runtime restart required\",\n );\n }\n let status = connect_backend_serialized(&app, &state).await?;\n let message = if status.connected {\n \"已保存渠道设置,并已重启内置后端\".to_string()\n } else {\n format!(\n \"已保存渠道设置,但当前运行时尚未生效:{}\",\n status\n .last_error\n .clone()\n .unwrap_or_else(|| \"内置后端重启后未连接\".to_string())\n )\n };\n return Ok(DesktopChannelSettingsUpdateResult {\n settings: saved,\n restarted_bundled_backend: true,\n message,\n backend_status: Some(status),\n });\n }\n\n Ok(DesktopChannelSettingsUpdateResult {\n settings: saved,\n restarted_bundled_backend: false,\n message: \"已保存渠道设置。当前为远程模式,下次切回内置后端时生效\".to_string(),\n backend_status: None,\n })\n}\n\npub(crate) fn backend_status_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n) -> BackendStatusInfo {\n let mut guard = state.inner.lock().unwrap();\n guard.config = load_persisted_config(&app).unwrap_or_default();\n if guard.diagnostics.is_none() {\n guard.diagnostics = diagnostic_paths(&app).ok();\n }\n backend_status_snapshot(&guard)\n}\n\npub(crate) async fn cleanup_channel_processes_impl(\n state: State<'_, DesktopState>,\n) -> Result<ChannelProcessCleanupResult, String> {\n let _guard = state.transition_lock.lock().await;\n let mut guard = state.inner.lock().unwrap();\n Ok(cleanup_duplicate_channel_processes_inner(&mut guard))\n}\n\n// ── Agent 基础设置 commands ─────────────────────────────────────────────────\n\npub(crate) fn get_agent_settings_impl(app: AppHandle) -> Result<AgentSettings, String> {\n let runtime = ensure_runtime_paths(&app)?;\n let config = HoneConfig::from_file(&runtime.config_path).map_err(|e| e.to_string())?;\n Ok(AgentSettings {\n runner: config.agent.runner.clone(),\n codex_model: config.agent.codex_model.clone(),\n openai_url: config.agent.opencode.api_base_url.clone(),\n openai_model: config.agent.opencode.model.clone(),\n openai_api_key: config.agent.opencode.api_key.clone(),\n auxiliary: Some(seed_auxiliary_settings(&config)),\n hone_cloud: Some(HoneCloudSettings {\n base_url: config.agent.hone_cloud.base_url.clone(),\n api_key: config.agent.hone_cloud.api_key.clone(),\n model: config.agent.hone_cloud.model.clone(),\n }),\n llm_profiles: Some(seed_llm_profile_settings(&config)),\n })\n}\n\nfn setting(path: impl Into<String>, value: serde_yaml::Value) -> (String, serde_yaml::Value) {\n (path.into(), value)\n}\n\nfn build_agent_setting_updates(settings: &AgentSettings) -> Vec<(String, serde_yaml::Value)> {\n let mut updates = vec![\n setting(\n \"agent.runner\",\n serde_yaml::Value::String(settings.runner.clone()),\n ),\n setting(\n \"agent.codex_model\",\n serde_yaml::Value::String(settings.codex_model.clone()),\n ),\n setting(\n \"agent.opencode.api_base_url\",\n serde_yaml::Value::String(settings.openai_url.clone()),\n ),\n setting(\n \"agent.opencode.model\",\n serde_yaml::Value::String(settings.openai_model.clone()),\n ),\n setting(\n \"agent.opencode.api_key\",\n serde_yaml::Value::String(settings.openai_api_key.clone()),\n ),\n ];\n if let Some(hone_cloud) = &settings.hone_cloud {\n updates.extend([\n setting(\n \"agent.hone_cloud.base_url\",\n serde_yaml::Value::String(hone_cloud.base_url.clone()),\n ),\n setting(\n \"agent.hone_cloud.api_key\",\n serde_yaml::Value::String(hone_cloud.api_key.clone()),\n ),\n setting(\n \"agent.hone_cloud.model\",\n serde_yaml::Value::String(hone_cloud.model.clone()),\n ),\n ]);\n }\n if let Some(auxiliary) = &settings.auxiliary {\n updates.extend([\n setting(\n \"llm.auxiliary.base_url\",\n serde_yaml::Value::String(auxiliary.base_url.clone()),\n ),\n setting(\n \"llm.auxiliary.api_key\",\n serde_yaml::Value::String(auxiliary.api_key.clone()),\n ),\n setting(\n \"llm.auxiliary.model\",\n serde_yaml::Value::String(auxiliary.model.clone()),\n ),\n setting(\n \"llm.openrouter.sub_model\",\n serde_yaml::Value::String(auxiliary.model.clone()),\n ),\n ]);\n }\n if let Some(llm_profiles) = &settings.llm_profiles {\n updates.extend(build_llm_profile_setting_updates(llm_profiles));\n }\n updates\n}\n\nfn optional_string_value(value: &Option<String>) -> serde_yaml::Value {\n value\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .map(|value| serde_yaml::Value::String(value.to_string()))\n .unwrap_or(serde_yaml::Value::Null)\n}\n\nfn optional_u32_value(value: Option<u32>) -> serde_yaml::Value {\n value\n .map(|value| serde_yaml::Value::Number(serde_yaml::Number::from(value)))\n .unwrap_or(serde_yaml::Value::Null)\n}\n\nfn optional_f32_value(value: Option<f32>) -> serde_yaml::Value {\n value\n .and_then(|value| serde_yaml::to_value(value).ok())\n .unwrap_or(serde_yaml::Value::Null)\n}\n\nfn build_llm_profile_setting_updates(\n settings: &LlmProfileSettings,\n) -> Vec<(String, serde_yaml::Value)> {\n let mut updates = vec![\n setting(\n \"llm.default_profile\",\n serde_yaml::Value::String(settings.default_profile.clone()),\n ),\n setting(\n \"llm.auxiliary_profile\",\n serde_yaml::Value::String(settings.auxiliary_profile.clone()),\n ),\n setting(\n \"event_engine.renderer.polish_llm\",\n serde_yaml::Value::String(settings.polish_profile.clone()),\n ),\n setting(\n \"event_engine.news_classifier_llm\",\n serde_yaml::Value::String(settings.news_classifier_profile.clone()),\n ),\n setting(\n \"event_engine.sec_filings.enrichment.llm\",\n serde_yaml::Value::String(settings.filing_summary_profile.clone()),\n ),\n setting(\n \"event_engine.earnings.quality_review.llm\",\n serde_yaml::Value::String(settings.earnings_quality_profile.clone()),\n ),\n setting(\n \"event_engine.global_digest.pass1_llm\",\n serde_yaml::Value::String(settings.digest_pass1_profile.clone()),\n ),\n setting(\n \"event_engine.global_digest.pass2_llm\",\n serde_yaml::Value::String(settings.digest_pass2_profile.clone()),\n ),\n setting(\n \"event_engine.global_digest.event_dedupe_llm\",\n serde_yaml::Value::String(settings.digest_event_dedupe_profile.clone()),\n ),\n setting(\n \"event_engine.global_digest.mainline_distill_llm\",\n serde_yaml::Value::String(settings.mainline_distill_profile.clone()),\n ),\n ];\n\n for profile in &settings.profiles {\n let id = profile.id.trim();\n if id.is_empty() {\n continue;\n }\n let prefix = format!(\"llm.profiles.{id}\");\n updates.extend([\n setting(\n format!(\"{prefix}.provider\"),\n serde_yaml::Value::String(profile.provider.clone()),\n ),\n setting(\n format!(\"{prefix}.model\"),\n serde_yaml::Value::String(profile.model.clone()),\n ),\n setting(\n format!(\"{prefix}.params.max_tokens\"),\n optional_u32_value(profile.max_tokens),\n ),\n setting(\n format!(\"{prefix}.params.temperature\"),\n optional_f32_value(profile.temperature),\n ),\n setting(\n format!(\"{prefix}.params.top_p\"),\n optional_f32_value(profile.top_p),\n ),\n setting(\n format!(\"{prefix}.params.reasoning.effort\"),\n optional_string_value(&profile.reasoning_effort),\n ),\n setting(\n format!(\"{prefix}.params.reasoning.max_tokens\"),\n optional_u32_value(profile.reasoning_max_tokens),\n ),\n setting(\n format!(\"{prefix}.params.response_format.type\"),\n if profile.response_format_json {\n serde_yaml::Value::String(\"json_object\".to_string())\n } else {\n serde_yaml::Value::Null\n },\n ),\n ]);\n }\n\n updates\n}\n\nfn agent_settings_require_save(current: &AgentSettings, next: &AgentSettings) -> bool {\n current != next\n}\n\nfn build_agent_settings_update_result(\n settings: AgentSettings,\n backend_status: Option<BackendStatusInfo>,\n) -> AgentSettingsUpdateResult {\n match backend_status {\n Some(status) => {\n let message = if status.connected {\n \"已保存 Agent 设置,并已重启内置后端\".to_string()\n } else {\n format!(\n \"已保存 Agent 设置,但当前运行时尚未生效:{}\",\n status\n .last_error\n .clone()\n .unwrap_or_else(|| \"内置后端重启后未连接\".to_string())\n )\n };\n AgentSettingsUpdateResult {\n settings,\n restarted_bundled_backend: true,\n message,\n backend_status: Some(status),\n }\n }\n None => AgentSettingsUpdateResult {\n settings,\n restarted_bundled_backend: false,\n message: \"已保存 Agent 设置。当前为远程模式,下次切回内置后端时生效\".to_string(),\n backend_status: None,\n },\n }\n}\n\npub(crate) async fn set_agent_settings_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n settings: AgentSettings,\n) -> Result<AgentSettingsUpdateResult, String> {\n let current_settings = get_agent_settings_impl(app.clone())?;\n if !agent_settings_require_save(&current_settings, &settings) {\n log_desktop(\n &app,\n \"INFO\",\n format!(\n \"agent settings unchanged; skip save/restart runner={}\",\n settings.runner\n ),\n );\n return Ok(AgentSettingsUpdateResult {\n settings,\n restarted_bundled_backend: false,\n message: \"Agent 设置未变化\".to_string(),\n backend_status: None,\n });\n }\n let runtime = ensure_runtime_paths(&app)?;\n let updates = build_agent_setting_updates(&settings);\n with_config_write_lock(&state, || {\n apply_setting_updates(\n &runtime.config_path,\n &runtime.effective_config_path,\n updates,\n )\n .map(|_| ())\n })\n .await?;\n log_desktop(\n &app,\n \"INFO\",\n format!(\n \"saved agent settings runner={} codex_model={} openai_url={}\",\n settings.runner, settings.codex_model, settings.openai_url\n ),\n );\n // 内置后端模式下重启以立即生效\n let backend_config = load_persisted_config(&app).unwrap_or_default();\n if backend_config.mode == \"bundled\" {\n {\n let mut guard = state.inner.lock().unwrap();\n mark_bundled_runtime_dirty(\n &mut guard,\n \"agent settings updated; bundled runtime restart required\",\n );\n }\n let status = connect_backend_serialized(&app, &state).await?;\n return Ok(build_agent_settings_update_result(settings, Some(status)));\n }\n Ok(build_agent_settings_update_result(settings, None))\n}\n\n/// 测试 OpenAI 协议渠道连通性:发送一个最小 chat/completions 请求,验证 URL + API Key + 模型是否有效。\npub(crate) async fn test_openai_channel_impl(\n url: String,\n model: String,\n api_key: String,\n) -> Result<CliCheckResult, String> {\n use reqwest::Client;\n use serde_json::json;\n\n let endpoint = resolve_openai_chat_completions_endpoint(&url);\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(15))\n .build()\n .map_err(|e| e.to_string())?;\n\n let body = json!({\n \"model\": model,\n \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}],\n \"max_tokens\": 1\n });\n\n let resp = client\n .post(&endpoint)\n .header(\"Authorization\", format!(\"Bearer {}\", api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await;\n\n match resp {\n Ok(r) => {\n let status = r.status();\n if status.is_success() {\n Ok(CliCheckResult {\n ok: true,\n message: format!(\"连通成功(HTTP {})\", status.as_u16()),\n })\n } else {\n let text = r.text().await.unwrap_or_default();\n let preview: String = text.chars().take(120).collect();\n Ok(CliCheckResult {\n ok: false,\n message: format!(\"HTTP {} — {}\", status.as_u16(), preview),\n })\n }\n }\n Err(e) => Ok(CliCheckResult {\n ok: false,\n message: format!(\"请求失败:{}\", e),\n }),\n }\n}\n\nfn resolve_openai_chat_completions_endpoint(url: &str) -> String {\n let base = url.trim().trim_end_matches('/');\n if base.ends_with(\"/chat/completions\") {\n base.to_string()\n } else {\n format!(\"{base}/chat/completions\")\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n async fn run_with_transition_lock(\n state: Arc<DesktopState>,\n concurrent: Arc<AtomicUsize>,\n peak: Arc<AtomicUsize>,\n ) {\n let _guard = state.transition_lock.lock().await;\n let current = concurrent.fetch_add(1, Ordering::SeqCst) + 1;\n peak.fetch_max(current, Ordering::SeqCst);\n tokio::time::sleep(Duration::from_millis(25)).await;\n concurrent.fetch_sub(1, Ordering::SeqCst);\n }\n\n #[tokio::test]\n async fn backend_transition_lock_serializes_concurrent_calls() {\n let state = Arc::new(DesktopState::default());\n let concurrent = Arc::new(AtomicUsize::new(0));\n let peak = Arc::new(AtomicUsize::new(0));\n\n let mut tasks = Vec::new();\n for _ in 0..3 {\n tasks.push(tokio::spawn(run_with_transition_lock(\n state.clone(),\n concurrent.clone(),\n peak.clone(),\n )));\n }\n\n for task in tasks {\n task.await.expect(\"task should join\");\n }\n\n assert_eq!(\n peak.load(Ordering::SeqCst),\n 1,\n \"backend transition should never run concurrently\"\n );\n }\n\n #[test]\n fn bundled_desktop_meta_exposes_web_api_capabilities() {\n let config = HoneConfig::default();\n let meta = desktop_meta_from_config(&config, \"local\");\n\n for capability in [\n \"company_profiles\",\n \"company_profile_transfer\",\n \"web_invites\",\n ] {\n assert!(\n meta.capabilities.iter().any(|value| value == capability),\n \"bundled desktop meta should expose {capability}\"\n );\n }\n }\n\n #[test]\n fn startup_error_dialog_uses_nonblocking_spawn_path() {\n STARTUP_ERROR_DIALOG_SPAWNS.store(0, Ordering::SeqCst);\n\n show_startup_error_dialog(\"startup failed during smoke test\");\n\n assert_eq!(STARTUP_ERROR_DIALOG_SPAWNS.load(Ordering::SeqCst), 1);\n }\n\n #[test]\n fn startup_error_dialog_suppression_parses_truthy_env_values() {\n for value in [\"1\", \"true\", \"TRUE\", \"yes\", \"on\", \" on \"] {\n assert!(startup_error_dialog_suppressed_from_env_value(Some(value)));\n }\n\n for value in [None, Some(\"\"), Some(\"0\"), Some(\"false\"), Some(\"off\")] {\n assert!(!startup_error_dialog_suppressed_from_env_value(value));\n }\n }\n\n async fn run_with_config_write_lock(\n state: Arc<DesktopState>,\n concurrent: Arc<AtomicUsize>,\n peak: Arc<AtomicUsize>,\n ) {\n let _ = with_config_write_lock(&state, || {\n let current = concurrent.fetch_add(1, Ordering::SeqCst) + 1;\n peak.fetch_max(current, Ordering::SeqCst);\n std::thread::sleep(Duration::from_millis(25));\n concurrent.fetch_sub(1, Ordering::SeqCst);\n Ok(())\n })\n .await;\n }\n\n #[tokio::test]\n async fn config_write_lock_serializes_concurrent_calls() {\n let state = Arc::new(DesktopState::default());\n let concurrent = Arc::new(AtomicUsize::new(0));\n let peak = Arc::new(AtomicUsize::new(0));\n\n let mut tasks = Vec::new();\n for _ in 0..3 {\n tasks.push(tokio::spawn(run_with_config_write_lock(\n state.clone(),\n concurrent.clone(),\n peak.clone(),\n )));\n }\n\n for task in tasks {\n task.await.expect(\"task should join\");\n }\n\n assert_eq!(\n peak.load(Ordering::SeqCst),\n 1,\n \"config writes should never run concurrently\"\n );\n }\n\n #[tokio::test]\n async fn config_write_lock_preserves_updates_from_concurrent_saves() {\n let state = Arc::new(DesktopState::default());\n let dir = std::env::temp_dir().join(format!(\n \"hone-desktop-config-write-lock-{}-{}\",\n std::process::id(),\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .expect(\"system time should be after epoch\")\n .as_nanos()\n ));\n std::fs::create_dir_all(&dir).expect(\"temp test dir should exist\");\n let config_path = dir.join(\"config.yaml\");\n let effective_config_path = dir.join(\"effective-config.yaml\");\n std::fs::write(\n &config_path,\n r#\"\nagent:\n runner: opencode_acp\n codex_model: \"\"\n opencode:\n api_base_url: \"https://openrouter.ai/api/v1\"\n model: \"google/gemini-2.5-pro-preview\"\n api_key: \"\"\nsearch:\n api_keys: []\nfmp:\n api_keys: []\n api_key: \"\"\n\"#,\n )\n .expect(\"seed config should write\");\n\n let first_started = Arc::new(AtomicBool::new(false));\n let state_for_agent = state.clone();\n let config_for_agent = config_path.clone();\n let effective_for_agent = effective_config_path.clone();\n let first_started_for_agent = first_started.clone();\n let agent_task = tokio::spawn(async move {\n with_config_write_lock(&state_for_agent, || {\n first_started_for_agent.store(true, Ordering::SeqCst);\n std::thread::sleep(Duration::from_millis(30));\n apply_setting_updates(\n &config_for_agent,\n &effective_for_agent,\n vec![\n (\n \"agent.runner\",\n serde_yaml::Value::String(\"codex_acp\".to_string()),\n ),\n (\n \"agent.codex_model\",\n serde_yaml::Value::String(\"ignored-model\".to_string()),\n ),\n ],\n )\n .map(|_| ())\n })\n .await\n });\n\n while !first_started.load(Ordering::SeqCst) {\n tokio::task::yield_now().await;\n }\n\n let state_for_fmp = state.clone();\n let config_for_fmp = config_path.clone();\n let effective_for_fmp = effective_config_path.clone();\n let fmp_task = tokio::spawn(async move {\n with_config_write_lock(&state_for_fmp, || {\n apply_setting_updates(\n &config_for_fmp,\n &effective_for_fmp,\n vec![\n (\n \"fmp.api_keys\",\n serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(\n \"fmp-key-1\".to_string(),\n )]),\n ),\n (\"fmp.api_key\", serde_yaml::Value::String(String::new())),\n ],\n )\n .map(|_| ())\n })\n .await\n });\n\n agent_task\n .await\n .expect(\"agent task should join\")\n .expect(\"agent save should succeed\");\n fmp_task\n .await\n .expect(\"fmp task should join\")\n .expect(\"fmp save should succeed\");\n\n let config = HoneConfig::from_file(&config_path).expect(\"final config should load\");\n assert_eq!(config.agent.runner, \"codex_acp\");\n assert_eq!(config.agent.codex_model, \"ignored-model\");\n assert_eq!(config.fmp.api_keys, vec![\"fmp-key-1\".to_string()]);\n }\n\n #[test]\n fn seed_auxiliary_settings_prefers_explicit_auxiliary_config() {\n let mut config = HoneConfig::default();\n config.llm.auxiliary.base_url = \"https://api.minimaxi.com/v1\".to_string();\n config.llm.auxiliary.api_key = \"sk-cp-aux\".to_string();\n config.llm.auxiliary.model = \"MiniMax-M2.7-highspeed\".to_string();\n\n let seeded = seed_auxiliary_settings(&config);\n assert_eq!(seeded.base_url, \"https://api.minimaxi.com/v1\");\n assert_eq!(seeded.api_key, \"sk-cp-aux\");\n assert_eq!(seeded.model, \"MiniMax-M2.7-highspeed\");\n }\n\n #[test]\n fn build_agent_setting_updates_persists_llm_profile_bindings_and_params() {\n let settings = AgentSettings {\n runner: \"hone_cloud\".to_string(),\n codex_model: String::new(),\n openai_url: String::new(),\n openai_model: String::new(),\n openai_api_key: String::new(),\n auxiliary: None,\n hone_cloud: None,\n llm_profiles: Some(LlmProfileSettings {\n default_profile: \"main\".to_string(),\n auxiliary_profile: \"aux\".to_string(),\n polish_profile: \"aux\".to_string(),\n news_classifier_profile: \"news_classifier\".to_string(),\n filing_summary_profile: \"filing_summary\".to_string(),\n earnings_quality_profile: \"earnings_quality\".to_string(),\n digest_pass1_profile: \"digest_fast\".to_string(),\n digest_pass2_profile: \"digest_strong\".to_string(),\n digest_event_dedupe_profile: \"digest_strong\".to_string(),\n mainline_distill_profile: \"mainline_short\".to_string(),\n profiles: vec![LlmProfileEntrySettings {\n id: \"digest_strong\".to_string(),\n provider: \"openrouter\".to_string(),\n model: \"x-ai/grok-4.1-fast\".to_string(),\n max_tokens: Some(1600),\n temperature: Some(0.2),\n top_p: None,\n reasoning_effort: Some(\"low\".to_string()),\n reasoning_max_tokens: Some(512),\n response_format_json: true,\n }],\n }),\n };\n\n let updates = build_agent_setting_updates(&settings);\n let update_map = updates\n .into_iter()\n .map(|(path, value)| (path, value))\n .collect::<std::collections::HashMap<_, _>>();\n\n assert_eq!(\n update_map\n .get(\"event_engine.global_digest.pass2_llm\")\n .and_then(serde_yaml::Value::as_str),\n Some(\"digest_strong\")\n );\n assert_eq!(\n update_map\n .get(\"llm.profiles.digest_strong.model\")\n .and_then(serde_yaml::Value::as_str),\n Some(\"x-ai/grok-4.1-fast\")\n );\n assert_eq!(\n update_map\n .get(\"llm.profiles.digest_strong.params.reasoning.effort\")\n .and_then(serde_yaml::Value::as_str),\n Some(\"low\")\n );\n assert_eq!(\n update_map\n .get(\"llm.profiles.digest_strong.params.response_format.type\")\n .and_then(serde_yaml::Value::as_str),\n Some(\"json_object\")\n );\n }\n\n #[test]\n fn build_agent_setting_updates_persists_auxiliary_key_without_env_field() {\n let settings = AgentSettings {\n runner: \"opencode_acp\".to_string(),\n codex_model: String::new(),\n openai_url: String::new(),\n openai_model: String::new(),\n openai_api_key: String::new(),\n auxiliary: Some(AuxiliarySettings {\n base_url: \"https://api.minimaxi.com/v1\".to_string(),\n api_key: \"sk-cp-aux\".to_string(),\n model: \"MiniMax-M2.7-highspeed\".to_string(),\n }),\n hone_cloud: None,\n llm_profiles: None,\n };\n\n let updates = build_agent_setting_updates(&settings);\n assert!(updates.iter().any(|(path, value)| {\n path == \"llm.auxiliary.api_key\" && value.as_str() == Some(\"sk-cp-aux\")\n }));\n assert!(\n updates\n .iter()\n .all(|(path, _)| path != \"llm.auxiliary.api_key_env\")\n );\n }\n\n #[test]\n fn agent_settings_require_save_skips_identical_runner_payloads() {\n let settings = AgentSettings {\n runner: \"opencode_acp\".to_string(),\n codex_model: String::new(),\n openai_url: \"https://openrouter.ai/api/v1\".to_string(),\n openai_model: \"google/gemini-2.5-pro-preview\".to_string(),\n openai_api_key: \"sk-or\".to_string(),\n auxiliary: Some(AuxiliarySettings {\n base_url: \"https://api.minimaxi.com/v1\".to_string(),\n api_key: \"sk-cp-aux\".to_string(),\n model: \"MiniMax-M2.7-highspeed\".to_string(),\n }),\n hone_cloud: Some(HoneCloudSettings {\n base_url: \"https://hone-claw.com\".to_string(),\n api_key: \"hck_test\".to_string(),\n model: \"hone-cloud\".to_string(),\n }),\n llm_profiles: None,\n };\n\n assert!(\n !agent_settings_require_save(&settings, &settings.clone()),\n \"unchanged settings should not trigger a fresh save/restart\"\n );\n\n let changed = AgentSettings {\n runner: \"hone_cloud\".to_string(),\n ..settings.clone()\n };\n assert!(\n agent_settings_require_save(&settings, &changed),\n \"changing the runner should still trigger save/restart\"\n );\n }\n\n #[test]\n fn bundled_agent_settings_update_result_surfaces_runtime_not_applied() {\n let settings = AgentSettings {\n runner: \"codex_acp\".to_string(),\n codex_model: String::new(),\n openai_url: \"https://openrouter.ai/api/v1\".to_string(),\n openai_model: \"google/gemini-2.5-pro-preview\".to_string(),\n openai_api_key: String::new(),\n auxiliary: None,\n hone_cloud: None,\n llm_profiles: None,\n };\n let status = BackendStatusInfo {\n config: BackendConfig {\n mode: \"bundled\".to_string(),\n base_url: String::new(),\n bearer_token: String::new(),\n },\n resolved_base_url: Some(\"http://127.0.0.1:8077\".to_string()),\n connected: false,\n last_error: Some(\"bundle restart failed\".to_string()),\n meta: None,\n diagnostics: DiagnosticPaths {\n config_dir: \"/tmp/config\".to_string(),\n data_dir: \"/tmp/data\".to_string(),\n logs_dir: \"/tmp/logs\".to_string(),\n desktop_log: \"/tmp/logs/desktop.log\".to_string(),\n sidecar_log: \"/tmp/logs/sidecar.log\".to_string(),\n },\n };\n\n let result = build_agent_settings_update_result(settings.clone(), Some(status));\n\n assert!(result.restarted_bundled_backend);\n assert_eq!(result.settings.runner, \"codex_acp\");\n assert!(\n result.message.contains(\"当前运行时尚未生效\"),\n \"should explicitly surface that runtime did not apply the new runner\"\n );\n assert_eq!(\n result\n .backend_status\n .as_ref()\n .and_then(|status| status.last_error.as_deref()),\n Some(\"bundle restart failed\")\n );\n }\n\n #[test]\n fn desktop_channel_settings_input_accepts_secret_fields_from_camel_case_payload() {\n let input: DesktopChannelSettingsInput = serde_json::from_value(serde_json::json!({\n \"imessageEnabled\": true,\n \"feishuEnabled\": true,\n \"feishuAppId\": \"cli_test\",\n \"feishuAppSecret\": \"secret-value\",\n \"telegramEnabled\": true,\n \"telegramBotToken\": \"tg-token\",\n \"discordEnabled\": true,\n \"discordBotToken\": \"discord-token\"\n }))\n .expect(\"desktop channel payload should deserialize\");\n\n assert!(input.imessage_enabled);\n assert_eq!(input.imessage_target_handle, \"\");\n assert!(input.feishu_enabled);\n assert_eq!(input.feishu_app_id, \"cli_test\");\n assert_eq!(input.feishu_app_secret, \"secret-value\");\n assert_eq!(input.feishu_chat_scope, \"\");\n assert!(input.telegram_enabled);\n assert_eq!(input.telegram_bot_token, \"tg-token\");\n assert_eq!(input.telegram_allow_from, Vec::<String>::new());\n assert!(input.discord_enabled);\n assert_eq!(input.discord_bot_token, \"discord-token\");\n assert_eq!(input.discord_allow_from, Vec::<String>::new());\n }\n\n #[test]\n fn desktop_channel_settings_serializes_secret_fields_to_camel_case_payload() {\n let settings = DesktopChannelSettings {\n config_path: \"/tmp/config.yaml\".to_string(),\n imessage_enabled: false,\n imessage_target_handle: \"+15551234567\".to_string(),\n feishu_enabled: true,\n feishu_app_id: \"cli_test\".to_string(),\n feishu_app_secret: \"secret-value\".to_string(),\n feishu_chat_scope: \"ALL\".to_string(),\n feishu_allow_emails: vec![\"admin@example.com\".to_string()],\n feishu_allow_mobiles: vec![],\n feishu_allow_open_ids: vec![\"ou_abc\".to_string()],\n telegram_enabled: true,\n telegram_bot_token: \"tg-token\".to_string(),\n telegram_chat_scope: \"DM_ONLY\".to_string(),\n telegram_allow_from: vec![\"123\".to_string()],\n discord_enabled: true,\n discord_bot_token: \"discord-token\".to_string(),\n discord_chat_scope: \"GROUPCHAT_ONLY\".to_string(),\n discord_allow_from: vec![\"456\".to_string()],\n };\n\n let json =\n serde_json::to_value(&settings).expect(\"desktop channel settings should serialize\");\n assert_eq!(json[\"feishuAppId\"], \"cli_test\");\n assert_eq!(json[\"feishuAppSecret\"], \"secret-value\");\n assert_eq!(json[\"imessageTargetHandle\"], \"+15551234567\");\n assert_eq!(json[\"feishuAllowOpenIds\"][0], \"ou_abc\");\n assert_eq!(json[\"telegramBotToken\"], \"tg-token\");\n assert_eq!(json[\"telegramAllowFrom\"][0], \"123\");\n assert_eq!(json[\"discordBotToken\"], \"discord-token\");\n assert_eq!(json[\"discordAllowFrom\"][0], \"456\");\n }\n\n #[test]\n fn mark_bundled_runtime_dirty_clears_cached_meta_and_sets_restart_reason() {\n let mut manager = DesktopBackendManager::default();\n manager.resolved_base_url = Some(\"http://127.0.0.1:3000\".to_string());\n manager.meta = Some(MetaInfo {\n name: \"Hone\".to_string(),\n version: \"0.1.13\".to_string(),\n channel: \"imessage\".to_string(),\n supports_imessage: true,\n api_version: API_VERSION.to_string(),\n capabilities: vec![\"chat\".to_string()],\n deployment_mode: \"local\".to_string(),\n });\n manager.last_error = None;\n\n mark_bundled_runtime_dirty(&mut manager, \"channel settings updated\");\n\n assert!(manager.meta.is_none());\n assert_eq!(\n manager.last_error.as_deref(),\n Some(\"channel settings updated\")\n );\n assert_eq!(\n manager.resolved_base_url.as_deref(),\n Some(\"http://127.0.0.1:3000\")\n );\n }\n\n #[tokio::test]\n async fn stop_managed_children_clears_web_server_tasks() {\n let mut manager = DesktopBackendManager::default();\n manager\n .web_server_tasks\n .push(tokio::spawn(async { std::future::pending::<()>().await }));\n\n stop_managed_children(&mut manager);\n\n assert!(\n manager.web_server_tasks.is_empty(),\n \"bundled restart must not leave old web-api task handles behind\"\n );\n }\n}\n\n/// 检测 runner 对应的本机 CLI 二进制是否可用。\n/// 仅运行 `AgentRunnerKind::cli_probe()` 提供的轻量参数;不发送真实请求,也不启动 runner。\npub(crate) async fn check_agent_cli_impl(runner: String) -> Result<CliCheckResult, String> {\n let probe = hone_core::config::AgentRunnerKind::from_config_value(&runner)\n .cli_probe()\n .ok_or_else(|| format!(\"不支持的 runner: {runner}\"))?;\n let binary = probe.binary;\n\n let mut command = tokio::process::Command::new(binary);\n command.arg(probe.arg);\n let result = tokio::time::timeout(std::time::Duration::from_secs(8), command.output()).await;\n\n match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();\n let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();\n let version_hint = if !stdout.is_empty() {\n stdout\n } else if !stderr.is_empty() {\n stderr\n } else {\n \"(无版本输出)\".to_string()\n };\n Ok(CliCheckResult {\n ok: true,\n message: format!(\"{binary} 已就绪 {version_hint}\"),\n })\n }\n Ok(Err(e)) => Ok(CliCheckResult {\n ok: false,\n message: format!(\"找不到 {binary} 命令:{e}\"),\n }),\n Err(_) => Ok(CliCheckResult {\n ok: false,\n message: format!(\"{binary} 检测超时(8 秒)\"),\n }),\n }\n}\n\n/// 读取 config.yaml 中的 OpenRouter API Key 设置(多 Key)\npub(crate) fn get_openrouter_settings_impl(app: AppHandle) -> Result<OpenRouterSettings, String> {\n let runtime = ensure_runtime_paths(&app)?;\n let config = HoneConfig::from_file(&runtime.config_path).map_err(|e| e.to_string())?;\n let pool = config.llm.openrouter_key_pool();\n Ok(OpenRouterSettings {\n api_keys: pool.keys().to_vec(),\n })\n}\n\n/// 保存 OpenRouter API Keys 到 config.yaml,并重启内置后端立即生效\npub(crate) async fn set_openrouter_settings_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n settings: OpenRouterSettings,\n) -> Result<(), String> {\n let runtime = ensure_runtime_paths(&app)?;\n let valid_keys: Vec<String> = settings\n .api_keys\n .into_iter()\n .filter(|k| !k.trim().is_empty())\n .collect();\n with_config_write_lock(&state, || {\n apply_setting_updates(\n &runtime.config_path,\n &runtime.effective_config_path,\n vec![\n (\n \"llm.providers.openrouter.api_keys\",\n serde_yaml::Value::Sequence(\n valid_keys\n .iter()\n .cloned()\n .map(serde_yaml::Value::String)\n .collect(),\n ),\n ),\n (\n \"llm.providers.openrouter.api_key\",\n serde_yaml::Value::String(String::new()),\n ),\n (\n \"llm.openrouter.api_keys\",\n serde_yaml::Value::Sequence(Vec::new()),\n ),\n (\n \"llm.openrouter.api_key\",\n serde_yaml::Value::String(String::new()),\n ),\n ],\n )\n .map(|_| ())\n })\n .await?;\n log_desktop(\n &app,\n \"INFO\",\n format!(\"saved openrouter settings keys_count={}\", valid_keys.len()),\n );\n // 内置后端模式下重启以立即生效\n let backend_config = load_persisted_config(&app).unwrap_or_default();\n if backend_config.mode == \"bundled\" {\n {\n let mut guard = state.inner.lock().unwrap();\n mark_bundled_runtime_dirty(\n &mut guard,\n \"openrouter settings updated; bundled runtime restart required\",\n );\n }\n let _ = connect_backend_serialized(&app, &state).await;\n }\n Ok(())\n}\n\n/// 读取 canonical config.yaml 中的 FMP API Key 设置(多 Key)\npub(crate) fn get_fmp_settings_impl(app: AppHandle) -> Result<FmpSettings, String> {\n let runtime = ensure_runtime_paths(&app)?;\n let config = HoneConfig::from_file(&runtime.config_path).map_err(|e| e.to_string())?;\n let pool = config.fmp.effective_key_pool();\n Ok(FmpSettings {\n api_keys: pool.keys().to_vec(),\n })\n}\n\n/// 保存 FMP API Keys 到 canonical config.yaml,并重启内置后端立即生效\npub(crate) async fn set_fmp_settings_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n settings: FmpSettings,\n) -> Result<(), String> {\n let runtime = ensure_runtime_paths(&app)?;\n let valid_keys: Vec<String> = settings\n .api_keys\n .into_iter()\n .filter(|k| !k.trim().is_empty())\n .collect();\n with_config_write_lock(&state, || {\n apply_setting_updates(\n &runtime.config_path,\n &runtime.effective_config_path,\n vec![\n (\n \"fmp.api_keys\",\n serde_yaml::Value::Sequence(\n valid_keys\n .iter()\n .cloned()\n .map(serde_yaml::Value::String)\n .collect(),\n ),\n ),\n (\"fmp.api_key\", serde_yaml::Value::String(String::new())),\n ],\n )\n .map(|_| ())\n })\n .await?;\n log_desktop(\n &app,\n \"INFO\",\n format!(\"saved fmp settings keys_count={}\", valid_keys.len()),\n );\n let backend_config = load_persisted_config(&app).unwrap_or_default();\n if backend_config.mode == \"bundled\" {\n {\n let mut guard = state.inner.lock().unwrap();\n mark_bundled_runtime_dirty(\n &mut guard,\n \"fmp settings updated; bundled runtime restart required\",\n );\n }\n let _ = connect_backend_serialized(&app, &state).await;\n }\n Ok(())\n}\n\n/// 读取 canonical config.yaml 中的 Tavily API Key 设置(多 Key)\npub(crate) fn get_tavily_settings_impl(app: AppHandle) -> Result<TavilySettings, String> {\n let runtime = ensure_runtime_paths(&app)?;\n let config = HoneConfig::from_file(&runtime.config_path).map_err(|e| e.to_string())?;\n // 过滤空 key\n let valid_keys: Vec<String> = config\n .search\n .api_keys\n .into_iter()\n .filter(|k| !k.trim().is_empty())\n .collect();\n Ok(TavilySettings {\n api_keys: valid_keys,\n })\n}\n\n/// 保存 Tavily API Keys 到 canonical config.yaml,并重启内置后端立即生效\npub(crate) async fn set_tavily_settings_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n settings: TavilySettings,\n) -> Result<(), String> {\n let runtime = ensure_runtime_paths(&app)?;\n let valid_keys: Vec<String> = settings\n .api_keys\n .into_iter()\n .filter(|k| !k.trim().is_empty())\n .collect();\n with_config_write_lock(&state, || {\n apply_setting_updates(\n &runtime.config_path,\n &runtime.effective_config_path,\n vec![(\n \"search.api_keys\",\n serde_yaml::Value::Sequence(\n valid_keys\n .iter()\n .cloned()\n .map(serde_yaml::Value::String)\n .collect(),\n ),\n )],\n )\n .map(|_| ())\n })\n .await?;\n log_desktop(\n &app,\n \"INFO\",\n format!(\"saved tavily settings keys_count={}\", valid_keys.len()),\n );\n let backend_config = load_persisted_config(&app).unwrap_or_default();\n if backend_config.mode == \"bundled\" {\n {\n let mut guard = state.inner.lock().unwrap();\n mark_bundled_runtime_dirty(\n &mut guard,\n \"tavily settings updated; bundled runtime restart required\",\n );\n }\n let _ = connect_backend_serialized(&app, &state).await;\n }\n Ok(())\n}\n\npub(crate) fn run_desktop_app() {\n crate::commands::run_desktop_app();\n}\n"}, "4a01a0163f6fe4ced0b3566a40543e14b4b3bb75": {"t": 1785846811.1645622, "n": 69473, "h": "2fe4b4a63be3a8f4", "c": "use std::collections::BTreeMap;\nuse std::env;\nuse std::fs;\nuse std::fs::OpenOptions;\nuse std::io::Write;\nuse std::path::{Path, PathBuf};\nuse std::process::Command as StdCommand;\nuse std::sync::Mutex;\nuse std::time::Duration;\n\nuse hone_core::HoneConfig;\nuse reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};\nuse serde::{Deserialize, Serialize};\nuse tauri::async_runtime;\nuse tauri::{AppHandle, Manager, State};\nuse tauri_plugin_shell::{\n ShellExt,\n process::{CommandChild, CommandEvent},\n};\n\n#[cfg(not(test))]\nuse rfd::{MessageButtons, MessageDialog, MessageLevel};\n\nmod processes;\nmod runtime_env;\nmod settings;\n\nuse self::{processes::*, runtime_env::*, settings::*};\n\n#[cfg(test)]\nuse std::sync::Arc;\n#[cfg(test)]\nuse std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};\n\nconst API_VERSION: &str = \"desktop-v1\";\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct BackendConfig {\n mode: String,\n #[serde(default, alias = \"base_url\")]\n base_url: String,\n #[serde(default, alias = \"bearer_token\")]\n bearer_token: String,\n}\n\nimpl Default for BackendConfig {\n fn default() -> Self {\n Self {\n mode: \"bundled\".to_string(),\n base_url: String::new(),\n bearer_token: String::new(),\n }\n }\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct DesktopChannelSettings {\n config_path: String,\n imessage_enabled: bool,\n #[serde(default)]\n imessage_target_handle: String,\n feishu_enabled: bool,\n #[serde(default)]\n feishu_app_id: String,\n #[serde(default)]\n feishu_app_secret: String,\n #[serde(default)]\n feishu_chat_scope: String,\n #[serde(default)]\n feishu_allow_emails: Vec<String>,\n #[serde(default)]\n feishu_allow_mobiles: Vec<String>,\n #[serde(default)]\n feishu_allow_open_ids: Vec<String>,\n telegram_enabled: bool,\n #[serde(default)]\n telegram_bot_token: String,\n #[serde(default)]\n telegram_chat_scope: String,\n #[serde(default)]\n telegram_allow_from: Vec<String>,\n discord_enabled: bool,\n #[serde(default)]\n discord_bot_token: String,\n #[serde(default)]\n discord_chat_scope: String,\n #[serde(default)]\n discord_allow_from: Vec<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct DesktopChannelSettingsInput {\n imessage_enabled: bool,\n #[serde(default)]\n imessage_target_handle: String,\n feishu_enabled: bool,\n #[serde(default)]\n feishu_app_id: String,\n #[serde(default)]\n feishu_app_secret: String,\n #[serde(default)]\n feishu_chat_scope: String,\n #[serde(default)]\n feishu_allow_emails: Vec<String>,\n #[serde(default)]\n feishu_allow_mobiles: Vec<String>,\n #[serde(default)]\n feishu_allow_open_ids: Vec<String>,\n telegram_enabled: bool,\n #[serde(default)]\n telegram_bot_token: String,\n #[serde(default)]\n telegram_chat_scope: String,\n #[serde(default)]\n telegram_allow_from: Vec<String>,\n discord_enabled: bool,\n #[serde(default)]\n discord_bot_token: String,\n #[serde(default)]\n discord_chat_scope: String,\n #[serde(default)]\n discord_allow_from: Vec<String>,\n}\n\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct DesktopChannelSettingsUpdateResult {\n settings: DesktopChannelSettings,\n restarted_bundled_backend: bool,\n message: String,\n backend_status: Option<BackendStatusInfo>,\n}\n\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct AgentSettingsUpdateResult {\n settings: AgentSettings,\n restarted_bundled_backend: bool,\n message: String,\n backend_status: Option<BackendStatusInfo>,\n}\n\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct ChannelProcessCleanupEntry {\n channel: String,\n kept_pid: Option<u32>,\n removed_pids: Vec<u32>,\n}\n\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct ChannelProcessCleanupResult {\n entries: Vec<ChannelProcessCleanupEntry>,\n message: String,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct AuxiliarySettings {\n #[serde(default)]\n base_url: String,\n #[serde(default)]\n api_key: String,\n #[serde(default)]\n model: String,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct HoneCloudSettings {\n #[serde(default)]\n base_url: String,\n #[serde(default)]\n api_key: String,\n #[serde(default)]\n model: String,\n}\n\nconst LLM_PROFILE_UI_IDS: &[&str] = &[\n \"main\",\n \"aux\",\n \"news_classifier\",\n \"filing_summary\",\n \"earnings_quality\",\n \"digest_fast\",\n \"digest_strong\",\n \"mainline_short\",\n];\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct LlmProfileEntrySettings {\n id: String,\n #[serde(default)]\n provider: String,\n #[serde(default)]\n model: String,\n #[serde(default)]\n max_tokens: Option<u32>,\n #[serde(default)]\n temperature: Option<f32>,\n #[serde(default)]\n top_p: Option<f32>,\n #[serde(default)]\n reasoning_effort: Option<String>,\n #[serde(default)]\n reasoning_max_tokens: Option<u32>,\n #[serde(default)]\n response_format_json: bool,\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct LlmProfileSettings {\n #[serde(default)]\n default_profile: String,\n #[serde(default)]\n auxiliary_profile: String,\n #[serde(default)]\n polish_profile: String,\n #[serde(default)]\n news_classifier_profile: String,\n #[serde(default)]\n filing_summary_profile: String,\n #[serde(default)]\n earnings_quality_profile: String,\n #[serde(default)]\n digest_pass1_profile: String,\n #[serde(default)]\n digest_pass2_profile: String,\n #[serde(default)]\n digest_event_dedupe_profile: String,\n #[serde(default)]\n mainline_distill_profile: String,\n #[serde(default)]\n profiles: Vec<LlmProfileEntrySettings>,\n}\n\n/// Desktop Agent settings payload; saving writes canonical config.yaml and regenerates effective config.\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct AgentSettings {\n /// gemini_cli | codex_cli | codex_acp | opencode_acp | hone_cloud\n /// gemini_acp remains parseable for legacy config migration but is disabled at runtime.\n runner: String,\n /// codex_cli 专用,其他 provider 忽略\n codex_model: String,\n /// OpenAI 协议渠道 Base URL(agent.opencode.api_base_url)\n #[serde(default)]\n openai_url: String,\n /// OpenAI 协议渠道模型名(agent.opencode.model)\n #[serde(default)]\n openai_model: String,\n /// OpenAI 协议渠道 API Key(agent.opencode.api_key)\n #[serde(default)]\n openai_api_key: String,\n #[serde(default)]\n auxiliary: Option<AuxiliarySettings>,\n #[serde(default)]\n hone_cloud: Option<HoneCloudSettings>,\n #[serde(default)]\n llm_profiles: Option<LlmProfileSettings>,\n}\n\n/// CLI 联通检测结果\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct CliCheckResult {\n ok: bool,\n message: String,\n}\n\n/// OpenRouter API Key 设置(写入 canonical config 的 llm.providers.openrouter.api_keys)\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct OpenRouterSettings {\n /// 多 Key 列表(支持 fallback)\n api_keys: Vec<String>,\n}\n\n/// FMP API Key 设置(写入 canonical config.yaml 的 fmp.api_keys)\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct FmpSettings {\n /// 多 Key 列表(支持 fallback)\n api_keys: Vec<String>,\n}\n\n/// Tavily API Key 设置(写入 canonical config.yaml 的 search.api_keys)\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct TavilySettings {\n /// 多 Key 列表(支持 fallback)\n api_keys: Vec<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\nstruct MetaInfo {\n name: String,\n version: String,\n channel: String,\n supports_imessage: bool,\n api_version: String,\n capabilities: Vec<String>,\n deployment_mode: String,\n}\n\nfn desktop_meta_from_config(config: &HoneConfig, deployment_mode: &str) -> MetaInfo {\n let mut capabilities = vec![\n \"channels\".to_string(),\n \"users\".to_string(),\n \"history\".to_string(),\n \"chat\".to_string(),\n \"sse.events\".to_string(),\n \"logs\".to_string(),\n \"skills\".to_string(),\n \"cron_jobs\".to_string(),\n \"portfolio\".to_string(),\n \"company_profiles\".to_string(),\n \"company_profile_transfer\".to_string(),\n \"research\".to_string(),\n \"llm_audit\".to_string(),\n \"web_invites\".to_string(),\n ];\n\n if deployment_mode == \"local\" {\n capabilities.push(\"local_file_proxy\".to_string());\n }\n\n if cfg!(target_os = \"macos\") && config.imessage.enabled {\n capabilities.push(\"imessage\".to_string());\n }\n\n capabilities.sort();\n\n MetaInfo {\n name: \"Hone\".to_string(),\n version: env!(\"CARGO_PKG_VERSION\").to_string(),\n channel: \"imessage\".to_string(),\n supports_imessage: cfg!(target_os = \"macos\"),\n api_version: API_VERSION.to_string(),\n capabilities,\n deployment_mode: deployment_mode.to_string(),\n }\n}\n\n#[derive(Debug, Clone, Serialize)]\npub(crate) struct BackendStatusInfo {\n config: BackendConfig,\n resolved_base_url: Option<String>,\n connected: bool,\n last_error: Option<String>,\n meta: Option<MetaInfo>,\n diagnostics: DiagnosticPaths,\n}\n\n#[derive(Default)]\npub(crate) struct DesktopState {\n desktop_lock: Mutex<Option<hone_core::ProcessLockGuard>>,\n inner: Mutex<DesktopBackendManager>,\n config_write_lock: tokio::sync::Mutex<()>,\n transition_lock: tokio::sync::Mutex<()>,\n}\n\n#[derive(Default)]\nstruct DesktopBackendManager {\n config: BackendConfig,\n resolved_base_url: Option<String>,\n meta: Option<MetaInfo>,\n last_error: Option<String>,\n /// 内嵌 Web API 及其 per-startup 后台任务句柄(bundled 模式)\n web_server_tasks: Vec<tokio::task::JoinHandle<()>>,\n /// bundled 模式下的 hone-console-page 生命周期锁\n bundled_web_lock: Option<hone_core::ProcessLockGuard>,\n /// 各 channel sidecar 子进程(imessage / discord / feishu / telegram)\n channel_children: BTreeMap<String, CommandChild>,\n diagnostics: Option<DiagnosticPaths>,\n}\n\nimpl Drop for DesktopBackendManager {\n fn drop(&mut self) {\n stop_managed_children(self);\n }\n}\n\nstruct RuntimePaths {\n config_path: PathBuf,\n effective_config_path: PathBuf,\n data_dir: PathBuf,\n runtime_dir: PathBuf,\n sandbox_dir: PathBuf,\n skills_dir: PathBuf,\n}\n\n#[derive(Debug, Clone, Serialize)]\nstruct DiagnosticPaths {\n config_dir: String,\n data_dir: String,\n logs_dir: String,\n desktop_log: String,\n sidecar_log: String,\n}\n\nfn bundled_runtime_ready(manager: &DesktopBackendManager) -> bool {\n manager.last_error.is_none()\n && manager.bundled_web_lock.is_some()\n && manager.meta.is_some()\n && manager.resolved_base_url.is_some()\n}\n\nfn mark_bundled_runtime_dirty(manager: &mut DesktopBackendManager, reason: &str) {\n manager.meta = None;\n manager.last_error = Some(reason.to_string());\n}\n\nfn resolved_base_url(config: &BackendConfig, manager: &DesktopBackendManager) -> Option<String> {\n match config.mode.as_str() {\n \"bundled\" => manager.resolved_base_url.clone(),\n \"remote\" => Some(normalize_base_url(&config.base_url)),\n _ => None,\n }\n}\n\nfn backend_status_snapshot(manager: &DesktopBackendManager) -> BackendStatusInfo {\n let resolved = resolved_base_url(&manager.config, manager);\n BackendStatusInfo {\n config: manager.config.clone(),\n resolved_base_url: resolved,\n connected: manager.meta.is_some() && manager.last_error.is_none(),\n last_error: manager.last_error.clone(),\n meta: manager.meta.clone(),\n diagnostics: manager\n .diagnostics\n .clone()\n .unwrap_or_else(DesktopBackendManager::fallback_diagnostics),\n }\n}\n\nfn validate_meta(meta: MetaInfo) -> Result<MetaInfo, String> {\n if meta.api_version == API_VERSION {\n Ok(meta)\n } else {\n Err(format!(\n \"unsupported backend api_version: {} (expected {API_VERSION})\",\n meta.api_version\n ))\n }\n}\n\npub(crate) fn record_startup_error(app: &AppHandle, message: &str) {\n log_desktop(\n app,\n \"ERROR\",\n format!(\"desktop startup blocked before backend bootstrap: {message}\"),\n );\n eprintln!(\"Hone Startup Blocked: {message}\");\n}\n\nfn startup_error_dialog_suppressed_from_env_value(value: Option<&str>) -> bool {\n matches!(\n value.map(|raw| raw.trim().to_ascii_lowercase()),\n Some(value) if matches!(value.as_str(), \"1\" | \"true\" | \"yes\" | \"on\")\n )\n}\n\nfn startup_error_dialog_suppressed() -> bool {\n startup_error_dialog_suppressed_from_env_value(\n env::var(\"HONE_SUPPRESS_STARTUP_DIALOG\").ok().as_deref(),\n ) || startup_error_dialog_suppressed_from_env_value(env::var(\"CI\").ok().as_deref())\n}\n\n#[cfg(not(test))]\nfn spawn_startup_error_dialog(message: String) {\n let _ = std::thread::Builder::new()\n .name(\"hone-startup-error-dialog\".to_string())\n .spawn(move || {\n let _ = MessageDialog::new()\n .set_level(MessageLevel::Error)\n .set_title(\"Hone Startup Blocked\")\n .set_description(&message)\n .set_buttons(MessageButtons::Ok)\n .show();\n });\n}\n\n#[cfg(test)]\nstatic STARTUP_ERROR_DIALOG_SPAWNS: AtomicUsize = AtomicUsize::new(0);\n\n#[cfg(test)]\nfn spawn_startup_error_dialog(_message: String) {\n STARTUP_ERROR_DIALOG_SPAWNS.fetch_add(1, Ordering::SeqCst);\n}\n\npub(crate) fn show_startup_error_dialog(message: &str) {\n if startup_error_dialog_suppressed() {\n return;\n }\n spawn_startup_error_dialog(message.to_string());\n}\n\nimpl DesktopBackendManager {\n fn fallback_diagnostics() -> DiagnosticPaths {\n DiagnosticPaths {\n config_dir: String::new(),\n data_dir: String::new(),\n logs_dir: String::new(),\n desktop_log: String::new(),\n sidecar_log: String::new(),\n }\n }\n}\n\nasync fn probe_meta(base_url: &str, bearer_token: &str) -> Result<MetaInfo, String> {\n let mut headers = HeaderMap::new();\n if !bearer_token.trim().is_empty() {\n let value = HeaderValue::from_str(&format!(\"Bearer {}\", bearer_token.trim()))\n .map_err(|e| e.to_string())?;\n headers.insert(AUTHORIZATION, value);\n }\n\n let client = reqwest::Client::builder()\n .timeout(Duration::from_secs(5))\n .default_headers(headers)\n .build()\n .map_err(|e| format!(\"client build failed: {e}; debug={e:?}\"))?;\n\n let response = client\n .get(format!(\"{}/api/meta\", normalize_base_url(base_url)))\n .send()\n .await\n .map_err(|e| format!(\"request send failed: {e}; debug={e:?}\"))?;\n\n if !response.status().is_success() {\n return Err(format!(\"backend probe failed: {}\", response.status()));\n }\n\n response\n .json::<MetaInfo>()\n .await\n .map_err(|e| format!(\"response json failed: {e}; debug={e:?}\"))\n}\n\nasync fn connect_backend_inner(\n app: &AppHandle,\n desktop: &DesktopState,\n) -> Result<BackendStatusInfo, String> {\n let config = {\n let mut guard = desktop.inner.lock().unwrap();\n let config = load_persisted_config(app)?;\n guard.config = config.clone();\n guard.diagnostics = diagnostic_paths(app).ok();\n config\n };\n\n match config.mode.as_str() {\n \"remote\" => {\n {\n let mut guard = desktop.inner.lock().unwrap();\n stop_managed_children(&mut guard);\n guard.resolved_base_url = Some(normalize_base_url(&config.base_url));\n guard.meta = None;\n guard.last_error = None;\n }\n\n let resolved = normalize_base_url(&config.base_url);\n if resolved.is_empty() {\n let mut guard = desktop.inner.lock().unwrap();\n guard.last_error = Some(\"remote base URL 不能为空\".to_string());\n log_desktop(app, \"ERROR\", \"remote base URL is empty\");\n return Ok(backend_status_snapshot(&guard));\n }\n\n match probe_meta(&resolved, &config.bearer_token)\n .await\n .and_then(validate_meta)\n {\n Ok(meta) => {\n let mut guard = desktop.inner.lock().unwrap();\n guard.meta = Some(meta);\n guard.last_error = None;\n log_desktop(app, \"INFO\", format!(\"remote backend connected: {resolved}\"));\n Ok(backend_status_snapshot(&guard))\n }\n Err(error) => {\n let mut guard = desktop.inner.lock().unwrap();\n guard.meta = None;\n let should_log = guard.last_error.as_deref() != Some(error.as_str());\n guard.last_error = Some(error.clone());\n if should_log {\n log_desktop(\n app,\n \"ERROR\",\n format!(\"remote backend probe failed url={resolved} error={error}\"),\n );\n }\n Ok(backend_status_snapshot(&guard))\n }\n }\n }\n _ => {\n let runtime = ensure_runtime_paths(app)?;\n let diagnostics = diagnostic_paths(app)?;\n let runtime_config =\n HoneConfig::from_file(&runtime.config_path).map_err(|e| e.to_string())?;\n\n {\n let guard = desktop.inner.lock().unwrap();\n if bundled_runtime_ready(&guard) {\n return Ok(backend_status_snapshot(&guard));\n }\n }\n\n // Stop the in-process web API and channel sidecars before lock preflight.\n // Otherwise a dirty bundled runtime can conflict with its own previous\n // console lock and keep the old listener alive until the port bind fails.\n {\n let mut guard = desktop.inner.lock().unwrap();\n stop_managed_children(&mut guard);\n }\n log_desktop(\n app,\n \"INFO\",\n \"bundled runtime previous managed children stopped\",\n );\n\n if let Err(message) = preflight_bundled_runtime_locks(app) {\n log_desktop(app, \"ERROR\", &message);\n show_startup_error_dialog(&message);\n let mut guard = desktop.inner.lock().unwrap();\n stop_managed_children(&mut guard);\n guard.meta = None;\n guard.last_error = Some(message);\n guard.diagnostics = Some(diagnostics);\n return Ok(backend_status_snapshot(&guard));\n }\n log_desktop(app, \"INFO\", \"bundled runtime preflight locks passed\");\n\n // 启动 Axum 服务(port=0,OS 分配可用端口)\n let config_path_str = runtime.config_path.to_string_lossy().to_string();\n let data_dir = runtime.data_dir.clone();\n let skills_dir = runtime.skills_dir.clone();\n let web_lock = match hone_core::acquire_process_lock(\n &runtime.runtime_dir,\n hone_core::PROCESS_LOCK_CONSOLE_PAGE,\n )\n .map_err(|error| {\n hone_core::format_lock_failure_message(\n hone_core::PROCESS_LOCK_CONSOLE_PAGE,\n &hone_core::process_lock_path(\n &runtime.runtime_dir,\n hone_core::PROCESS_LOCK_CONSOLE_PAGE,\n ),\n &error,\n \"Hone bundled runtime\",\n )\n }) {\n Ok(lock) => lock,\n Err(message) => {\n log_desktop(app, \"ERROR\", &message);\n let mut guard = desktop.inner.lock().unwrap();\n guard.meta = None;\n guard.last_error = Some(message);\n guard.diagnostics = Some(diagnostics);\n return Ok(backend_status_snapshot(&guard));\n }\n };\n log_desktop(app, \"INFO\", \"bundled runtime console lock acquired\");\n\n log_desktop(\n app,\n \"INFO\",\n format!(\n \"starting embedded web server data_dir={}\",\n runtime.data_dir.display()\n ),\n );\n\n log_desktop(\n app,\n \"INFO\",\n \"bundled runtime calling hone_web_api::start_server\",\n );\n match hone_web_api::start_server(\n &config_path_str,\n Some(&data_dir),\n Some(&skills_dir),\n \"local\",\n )\n .await\n {\n Ok(started) => {\n let port = started.admin_port;\n log_desktop(\n app,\n \"INFO\",\n format!(\"hone_web_api::start_server returned port={port}\"),\n );\n let base_url = format!(\"http://127.0.0.1:{port}\");\n let meta = desktop_meta_from_config(&runtime_config, \"local\");\n {\n let mut guard = desktop.inner.lock().unwrap();\n guard.resolved_base_url = Some(base_url.clone());\n guard.meta = Some(meta);\n guard.last_error = None;\n guard.diagnostics = Some(diagnostics.clone());\n guard.bundled_web_lock = Some(web_lock);\n guard.web_server_tasks = started.task_handles;\n }\n\n // 对于同进程内嵌服务,绑定成功本身已经足够说明 API 已就绪;\n // 继续自 probe 反而会把短暂的启动抖动放大成误报。\n let mut guard = desktop.inner.lock().unwrap();\n log_desktop(app, \"INFO\", \"starting managed bundled channels\");\n if let Err(e) =\n start_enabled_channels(app, &mut guard, &runtime, &diagnostics, &base_url)\n {\n let message =\n format!(\"bundled channel sidecar startup failed, runtime aborted: {e}\");\n log_desktop(app, \"ERROR\", &message);\n stop_managed_children(&mut guard);\n guard.meta = None;\n guard.last_error = Some(message);\n return Ok(backend_status_snapshot(&guard));\n }\n\n log_desktop(\n app,\n \"INFO\",\n format!(\"embedded web server ready: {base_url}\"),\n );\n Ok(backend_status_snapshot(&guard))\n }\n Err(error) => {\n let mut guard = desktop.inner.lock().unwrap();\n guard.meta = None;\n guard.last_error = Some(error.clone());\n guard.diagnostics = Some(diagnostics);\n log_desktop(\n app,\n \"ERROR\",\n format!(\"embedded web server start failed: {error}\"),\n );\n Ok(backend_status_snapshot(&guard))\n }\n }\n }\n }\n}\n\npub(crate) fn get_backend_config_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n) -> Result<BackendConfig, String> {\n let config = load_persisted_config(&app)?;\n state.inner.lock().unwrap().config = config.clone();\n Ok(config)\n}\n\npub(crate) fn set_backend_config_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n config: BackendConfig,\n) -> Result<(), String> {\n save_persisted_config(&app, &config)?;\n log_desktop(\n &app,\n \"INFO\",\n format!(\n \"saved backend config mode={} base_url={}\",\n config.mode, config.base_url\n ),\n );\n let mut guard = state.inner.lock().unwrap();\n if config.mode == \"remote\" {\n stop_managed_children(&mut guard);\n guard.resolved_base_url = Some(normalize_base_url(&config.base_url));\n guard.meta = None;\n guard.last_error = None;\n }\n guard.config = config;\n Ok(())\n}\n\nasync fn connect_backend_serialized(\n app: &AppHandle,\n state: &DesktopState,\n) -> Result<BackendStatusInfo, String> {\n let _guard = state.transition_lock.lock().await;\n connect_backend_inner(app, state).await\n}\n\nasync fn with_config_write_lock<T, F>(state: &DesktopState, op: F) -> Result<T, String>\nwhere\n F: FnOnce() -> Result<T, String>,\n{\n let _guard = state.config_write_lock.lock().await;\n op()\n}\n\npub(crate) async fn connect_backend_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n) -> Result<BackendStatusInfo, String> {\n match connect_backend_serialized(&app, &state).await {\n Ok(status) => Ok(status),\n Err(error) => {\n log_desktop(\n &app,\n \"ERROR\",\n format!(\"connect_backend command failed: {error}\"),\n );\n Err(error)\n }\n }\n}\n\npub(crate) fn bootstrap_backend_on_startup(app: AppHandle) {\n async_runtime::spawn(async move {\n let state = app.state::<DesktopState>();\n if let Err(error) = connect_backend_serialized(&app, &state).await {\n log_desktop(\n &app,\n \"ERROR\",\n format!(\"startup backend bootstrap failed: {error}\"),\n );\n }\n });\n}\n\npub(crate) fn prepare_desktop_startup(app: AppHandle) -> Result<(), String> {\n let runtime = ensure_runtime_paths(&app)?;\n configure_desktop_runtime_env(&app, &runtime);\n preflight_startup_locks(&app)\n}\n\npub(crate) async fn start_bundled_backend_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n) -> Result<BackendStatusInfo, String> {\n {\n let mut guard = state.inner.lock().unwrap();\n guard.config.mode = \"bundled\".to_string();\n guard.config.base_url.clear();\n save_persisted_config(&app, &guard.config)?;\n }\n connect_backend_serialized(&app, &state).await\n}\n\npub(crate) async fn stop_bundled_backend_impl(\n state: State<'_, DesktopState>,\n) -> Result<BackendStatusInfo, String> {\n let _guard = state.transition_lock.lock().await;\n let mut guard = state.inner.lock().unwrap();\n stop_managed_children(&mut guard);\n guard.meta = None;\n guard.last_error = Some(\"bundled backend stopped\".to_string());\n Ok(backend_status_snapshot(&guard))\n}\n\npub(crate) fn get_channel_settings_impl(app: AppHandle) -> Result<DesktopChannelSettings, String> {\n load_channel_settings(&app)\n}\n\npub(crate) async fn set_channel_settings_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n settings: DesktopChannelSettingsInput,\n) -> Result<DesktopChannelSettingsUpdateResult, String> {\n let saved = with_config_write_lock(&state, || save_channel_settings(&app, &settings)).await?;\n log_desktop(\n &app,\n \"INFO\",\n format!(\n \"saved channel settings imessage={} feishu={} telegram={} discord={}\",\n saved.imessage_enabled,\n saved.feishu_enabled,\n saved.telegram_enabled,\n saved.discord_enabled\n ),\n );\n\n let backend_config = load_persisted_config(&app).unwrap_or_default();\n if backend_config.mode == \"bundled\" {\n {\n let mut guard = state.inner.lock().unwrap();\n mark_bundled_runtime_dirty(\n &mut guard,\n \"channel settings updated; bundled runtime restart required\",\n );\n }\n let status = connect_backend_serialized(&app, &state).await?;\n let message = if status.connected {\n \"已保存渠道设置,并已重启内置后端\".to_string()\n } else {\n format!(\n \"已保存渠道设置,但当前运行时尚未生效:{}\",\n status\n .last_error\n .clone()\n .unwrap_or_else(|| \"内置后端重启后未连接\".to_string())\n )\n };\n return Ok(DesktopChannelSettingsUpdateResult {\n settings: saved,\n restarted_bundled_backend: true,\n message,\n backend_status: Some(status),\n });\n }\n\n Ok(DesktopChannelSettingsUpdateResult {\n settings: saved,\n restarted_bundled_backend: false,\n message: \"已保存渠道设置。当前为远程模式,下次切回内置后端时生效\".to_string(),\n backend_status: None,\n })\n}\n\npub(crate) fn backend_status_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n) -> BackendStatusInfo {\n let mut guard = state.inner.lock().unwrap();\n guard.config = load_persisted_config(&app).unwrap_or_default();\n if guard.diagnostics.is_none() {\n guard.diagnostics = diagnostic_paths(&app).ok();\n }\n backend_status_snapshot(&guard)\n}\n\npub(crate) async fn cleanup_channel_processes_impl(\n state: State<'_, DesktopState>,\n) -> Result<ChannelProcessCleanupResult, String> {\n let _guard = state.transition_lock.lock().await;\n let mut guard = state.inner.lock().unwrap();\n Ok(cleanup_duplicate_channel_processes_inner(&mut guard))\n}\n\n// ── Agent 基础设置 commands ─────────────────────────────────────────────────\n\npub(crate) fn get_agent_settings_impl(app: AppHandle) -> Result<AgentSettings, String> {\n let runtime = ensure_runtime_paths(&app)?;\n let config = HoneConfig::from_file(&runtime.config_path).map_err(|e| e.to_string())?;\n Ok(AgentSettings {\n runner: config.agent.runner.clone(),\n codex_model: config.agent.codex_model.clone(),\n openai_url: config.agent.opencode.api_base_url.clone(),\n openai_model: config.agent.opencode.model.clone(),\n openai_api_key: config.agent.opencode.api_key.clone(),\n auxiliary: Some(seed_auxiliary_settings(&config)),\n hone_cloud: Some(HoneCloudSettings {\n base_url: config.agent.hone_cloud.base_url.clone(),\n api_key: config.agent.hone_cloud.api_key.clone(),\n model: config.agent.hone_cloud.model.clone(),\n }),\n llm_profiles: Some(seed_llm_profile_settings(&config)),\n })\n}\n\nfn setting(path: impl Into<String>, value: serde_yaml::Value) -> (String, serde_yaml::Value) {\n (path.into(), value)\n}\n\nfn build_agent_setting_updates(settings: &AgentSettings) -> Vec<(String, serde_yaml::Value)> {\n let mut updates = vec![\n setting(\n \"agent.runner\",\n serde_yaml::Value::String(settings.runner.clone()),\n ),\n setting(\n \"agent.codex_model\",\n serde_yaml::Value::String(settings.codex_model.clone()),\n ),\n setting(\n \"agent.opencode.api_base_url\",\n serde_yaml::Value::String(settings.openai_url.clone()),\n ),\n setting(\n \"agent.opencode.model\",\n serde_yaml::Value::String(settings.openai_model.clone()),\n ),\n setting(\n \"agent.opencode.api_key\",\n serde_yaml::Value::String(settings.openai_api_key.clone()),\n ),\n ];\n if let Some(hone_cloud) = &settings.hone_cloud {\n updates.extend([\n setting(\n \"agent.hone_cloud.base_url\",\n serde_yaml::Value::String(hone_cloud.base_url.clone()),\n ),\n setting(\n \"agent.hone_cloud.api_key\",\n serde_yaml::Value::String(hone_cloud.api_key.clone()),\n ),\n setting(\n \"agent.hone_cloud.model\",\n serde_yaml::Value::String(hone_cloud.model.clone()),\n ),\n ]);\n }\n if let Some(auxiliary) = &settings.auxiliary {\n updates.extend([\n setting(\n \"llm.auxiliary.base_url\",\n serde_yaml::Value::String(auxiliary.base_url.clone()),\n ),\n setting(\n \"llm.auxiliary.api_key\",\n serde_yaml::Value::String(auxiliary.api_key.clone()),\n ),\n setting(\n \"llm.auxiliary.model\",\n serde_yaml::Value::String(auxiliary.model.clone()),\n ),\n setting(\n \"llm.openrouter.sub_model\",\n serde_yaml::Value::String(auxiliary.model.clone()),\n ),\n ]);\n }\n if let Some(llm_profiles) = &settings.llm_profiles {\n updates.extend(build_llm_profile_setting_updates(llm_profiles));\n }\n updates\n}\n\nfn optional_string_value(value: &Option<String>) -> serde_yaml::Value {\n value\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .map(|value| serde_yaml::Value::String(value.to_string()))\n .unwrap_or(serde_yaml::Value::Null)\n}\n\nfn optional_u32_value(value: Option<u32>) -> serde_yaml::Value {\n value\n .map(|value| serde_yaml::Value::Number(serde_yaml::Number::from(value)))\n .unwrap_or(serde_yaml::Value::Null)\n}\n\nfn optional_f32_value(value: Option<f32>) -> serde_yaml::Value {\n value\n .and_then(|value| serde_yaml::to_value(value).ok())\n .unwrap_or(serde_yaml::Value::Null)\n}\n\nfn build_llm_profile_setting_updates(\n settings: &LlmProfileSettings,\n) -> Vec<(String, serde_yaml::Value)> {\n let mut updates = vec![\n setting(\n \"llm.default_profile\",\n serde_yaml::Value::String(settings.default_profile.clone()),\n ),\n setting(\n \"llm.auxiliary_profile\",\n serde_yaml::Value::String(settings.auxiliary_profile.clone()),\n ),\n setting(\n \"event_engine.renderer.polish_llm\",\n serde_yaml::Value::String(settings.polish_profile.clone()),\n ),\n setting(\n \"event_engine.news_classifier_llm\",\n serde_yaml::Value::String(settings.news_classifier_profile.clone()),\n ),\n setting(\n \"event_engine.sec_filings.enrichment.llm\",\n serde_yaml::Value::String(settings.filing_summary_profile.clone()),\n ),\n setting(\n \"event_engine.earnings.quality_review.llm\",\n serde_yaml::Value::String(settings.earnings_quality_profile.clone()),\n ),\n setting(\n \"event_engine.global_digest.pass1_llm\",\n serde_yaml::Value::String(settings.digest_pass1_profile.clone()),\n ),\n setting(\n \"event_engine.global_digest.pass2_llm\",\n serde_yaml::Value::String(settings.digest_pass2_profile.clone()),\n ),\n setting(\n \"event_engine.global_digest.event_dedupe_llm\",\n serde_yaml::Value::String(settings.digest_event_dedupe_profile.clone()),\n ),\n setting(\n \"event_engine.global_digest.mainline_distill_llm\",\n serde_yaml::Value::String(settings.mainline_distill_profile.clone()),\n ),\n ];\n\n for profile in &settings.profiles {\n let id = profile.id.trim();\n if id.is_empty() {\n continue;\n }\n let prefix = format!(\"llm.profiles.{id}\");\n updates.extend([\n setting(\n format!(\"{prefix}.provider\"),\n serde_yaml::Value::String(profile.provider.clone()),\n ),\n setting(\n format!(\"{prefix}.model\"),\n serde_yaml::Value::String(profile.model.clone()),\n ),\n setting(\n format!(\"{prefix}.params.max_tokens\"),\n optional_u32_value(profile.max_tokens),\n ),\n setting(\n format!(\"{prefix}.params.temperature\"),\n optional_f32_value(profile.temperature),\n ),\n setting(\n format!(\"{prefix}.params.top_p\"),\n optional_f32_value(profile.top_p),\n ),\n setting(\n format!(\"{prefix}.params.reasoning.effort\"),\n optional_string_value(&profile.reasoning_effort),\n ),\n setting(\n format!(\"{prefix}.params.reasoning.max_tokens\"),\n optional_u32_value(profile.reasoning_max_tokens),\n ),\n setting(\n format!(\"{prefix}.params.response_format.type\"),\n if profile.response_format_json {\n serde_yaml::Value::String(\"json_object\".to_string())\n } else {\n serde_yaml::Value::Null\n },\n ),\n ]);\n }\n\n updates\n}\n\nfn agent_settings_require_save(current: &AgentSettings, next: &AgentSettings) -> bool {\n current != next\n}\n\nfn build_agent_settings_update_result(\n settings: AgentSettings,\n backend_status: Option<BackendStatusInfo>,\n) -> AgentSettingsUpdateResult {\n match backend_status {\n Some(status) => {\n let message = if status.connected {\n \"已保存 Agent 设置,并已重启内置后端\".to_string()\n } else {\n format!(\n \"已保存 Agent 设置,但当前运行时尚未生效:{}\",\n status\n .last_error\n .clone()\n .unwrap_or_else(|| \"内置后端重启后未连接\".to_string())\n )\n };\n AgentSettingsUpdateResult {\n settings,\n restarted_bundled_backend: true,\n message,\n backend_status: Some(status),\n }\n }\n None => AgentSettingsUpdateResult {\n settings,\n restarted_bundled_backend: false,\n message: \"已保存 Agent 设置。当前为远程模式,下次切回内置后端时生效\".to_string(),\n backend_status: None,\n },\n }\n}\n\npub(crate) async fn set_agent_settings_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n settings: AgentSettings,\n) -> Result<AgentSettingsUpdateResult, String> {\n let current_settings = get_agent_settings_impl(app.clone())?;\n if !agent_settings_require_save(&current_settings, &settings) {\n log_desktop(\n &app,\n \"INFO\",\n format!(\n \"agent settings unchanged; skip save/restart runner={}\",\n settings.runner\n ),\n );\n return Ok(AgentSettingsUpdateResult {\n settings,\n restarted_bundled_backend: false,\n message: \"Agent 设置未变化\".to_string(),\n backend_status: None,\n });\n }\n let runtime = ensure_runtime_paths(&app)?;\n let updates = build_agent_setting_updates(&settings);\n with_config_write_lock(&state, || {\n apply_setting_updates(\n &runtime.config_path,\n &runtime.effective_config_path,\n updates,\n )\n .map(|_| ())\n })\n .await?;\n log_desktop(\n &app,\n \"INFO\",\n format!(\n \"saved agent settings runner={} codex_model={} openai_url={}\",\n settings.runner, settings.codex_model, settings.openai_url\n ),\n );\n // 内置后端模式下重启以立即生效\n let backend_config = load_persisted_config(&app).unwrap_or_default();\n if backend_config.mode == \"bundled\" {\n {\n let mut guard = state.inner.lock().unwrap();\n mark_bundled_runtime_dirty(\n &mut guard,\n \"agent settings updated; bundled runtime restart required\",\n );\n }\n let status = connect_backend_serialized(&app, &state).await?;\n return Ok(build_agent_settings_update_result(settings, Some(status)));\n }\n Ok(build_agent_settings_update_result(settings, None))\n}\n\n/// 测试 OpenAI 协议渠道连通性:发送一个最小 chat/completions 请求,验证 URL + API Key + 模型是否有效。\npub(crate) async fn test_openai_channel_impl(\n url: String,\n model: String,\n api_key: String,\n) -> Result<CliCheckResult, String> {\n use reqwest::Client;\n use serde_json::json;\n\n let endpoint = resolve_openai_chat_completions_endpoint(&url);\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(15))\n .build()\n .map_err(|e| e.to_string())?;\n\n let body = json!({\n \"model\": model,\n \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}],\n \"max_tokens\": 1\n });\n\n let resp = client\n .post(&endpoint)\n .header(\"Authorization\", format!(\"Bearer {}\", api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await;\n\n match resp {\n Ok(r) => {\n let status = r.status();\n if status.is_success() {\n Ok(CliCheckResult {\n ok: true,\n message: format!(\"连通成功(HTTP {})\", status.as_u16()),\n })\n } else {\n let text = r.text().await.unwrap_or_default();\n let preview: String = text.chars().take(120).collect();\n Ok(CliCheckResult {\n ok: false,\n message: format!(\"HTTP {} — {}\", status.as_u16(), preview),\n })\n }\n }\n Err(e) => Ok(CliCheckResult {\n ok: false,\n message: format!(\"请求失败:{}\", e),\n }),\n }\n}\n\nfn resolve_openai_chat_completions_endpoint(url: &str) -> String {\n let base = url.trim().trim_end_matches('/');\n if base.ends_with(\"/chat/completions\") {\n base.to_string()\n } else {\n format!(\"{base}/chat/completions\")\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n async fn run_with_transition_lock(\n state: Arc<DesktopState>,\n concurrent: Arc<AtomicUsize>,\n peak: Arc<AtomicUsize>,\n ) {\n let _guard = state.transition_lock.lock().await;\n let current = concurrent.fetch_add(1, Ordering::SeqCst) + 1;\n peak.fetch_max(current, Ordering::SeqCst);\n tokio::time::sleep(Duration::from_millis(25)).await;\n concurrent.fetch_sub(1, Ordering::SeqCst);\n }\n\n #[tokio::test]\n async fn backend_transition_lock_serializes_concurrent_calls() {\n let state = Arc::new(DesktopState::default());\n let concurrent = Arc::new(AtomicUsize::new(0));\n let peak = Arc::new(AtomicUsize::new(0));\n\n let mut tasks = Vec::new();\n for _ in 0..3 {\n tasks.push(tokio::spawn(run_with_transition_lock(\n state.clone(),\n concurrent.clone(),\n peak.clone(),\n )));\n }\n\n for task in tasks {\n task.await.expect(\"task should join\");\n }\n\n assert_eq!(\n peak.load(Ordering::SeqCst),\n 1,\n \"backend transition should never run concurrently\"\n );\n }\n\n #[test]\n fn bundled_desktop_meta_exposes_web_api_capabilities() {\n let config = HoneConfig::default();\n let meta = desktop_meta_from_config(&config, \"local\");\n\n for capability in [\n \"company_profiles\",\n \"company_profile_transfer\",\n \"web_invites\",\n ] {\n assert!(\n meta.capabilities.iter().any(|value| value == capability),\n \"bundled desktop meta should expose {capability}\"\n );\n }\n }\n\n #[test]\n fn startup_error_dialog_uses_nonblocking_spawn_path() {\n STARTUP_ERROR_DIALOG_SPAWNS.store(0, Ordering::SeqCst);\n\n show_startup_error_dialog(\"startup failed during smoke test\");\n\n assert_eq!(STARTUP_ERROR_DIALOG_SPAWNS.load(Ordering::SeqCst), 1);\n }\n\n #[test]\n fn startup_error_dialog_suppression_parses_truthy_env_values() {\n for value in [\"1\", \"true\", \"TRUE\", \"yes\", \"on\", \" on \"] {\n assert!(startup_error_dialog_suppressed_from_env_value(Some(value)));\n }\n\n for value in [None, Some(\"\"), Some(\"0\"), Some(\"false\"), Some(\"off\")] {\n assert!(!startup_error_dialog_suppressed_from_env_value(value));\n }\n }\n\n async fn run_with_config_write_lock(\n state: Arc<DesktopState>,\n concurrent: Arc<AtomicUsize>,\n peak: Arc<AtomicUsize>,\n ) {\n let _ = with_config_write_lock(&state, || {\n let current = concurrent.fetch_add(1, Ordering::SeqCst) + 1;\n peak.fetch_max(current, Ordering::SeqCst);\n std::thread::sleep(Duration::from_millis(25));\n concurrent.fetch_sub(1, Ordering::SeqCst);\n Ok(())\n })\n .await;\n }\n\n #[tokio::test]\n async fn config_write_lock_serializes_concurrent_calls() {\n let state = Arc::new(DesktopState::default());\n let concurrent = Arc::new(AtomicUsize::new(0));\n let peak = Arc::new(AtomicUsize::new(0));\n\n let mut tasks = Vec::new();\n for _ in 0..3 {\n tasks.push(tokio::spawn(run_with_config_write_lock(\n state.clone(),\n concurrent.clone(),\n peak.clone(),\n )));\n }\n\n for task in tasks {\n task.await.expect(\"task should join\");\n }\n\n assert_eq!(\n peak.load(Ordering::SeqCst),\n 1,\n \"config writes should never run concurrently\"\n );\n }\n\n #[tokio::test]\n async fn config_write_lock_preserves_updates_from_concurrent_saves() {\n let state = Arc::new(DesktopState::default());\n let dir = std::env::temp_dir().join(format!(\n \"hone-desktop-config-write-lock-{}-{}\",\n std::process::id(),\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .expect(\"system time should be after epoch\")\n .as_nanos()\n ));\n std::fs::create_dir_all(&dir).expect(\"temp test dir should exist\");\n let config_path = dir.join(\"config.yaml\");\n let effective_config_path = dir.join(\"effective-config.yaml\");\n std::fs::write(\n &config_path,\n r#\"\nagent:\n runner: opencode_acp\n codex_model: \"\"\n opencode:\n api_base_url: \"https://openrouter.ai/api/v1\"\n model: \"google/gemini-2.5-pro-preview\"\n api_key: \"\"\nsearch:\n api_keys: []\nfmp:\n api_keys: []\n api_key: \"\"\n\"#,\n )\n .expect(\"seed config should write\");\n\n let first_started = Arc::new(AtomicBool::new(false));\n let state_for_agent = state.clone();\n let config_for_agent = config_path.clone();\n let effective_for_agent = effective_config_path.clone();\n let first_started_for_agent = first_started.clone();\n let agent_task = tokio::spawn(async move {\n with_config_write_lock(&state_for_agent, || {\n first_started_for_agent.store(true, Ordering::SeqCst);\n std::thread::sleep(Duration::from_millis(30));\n apply_setting_updates(\n &config_for_agent,\n &effective_for_agent,\n vec![\n (\n \"agent.runner\",\n serde_yaml::Value::String(\"codex_acp\".to_string()),\n ),\n (\n \"agent.codex_model\",\n serde_yaml::Value::String(\"ignored-model\".to_string()),\n ),\n ],\n )\n .map(|_| ())\n })\n .await\n });\n\n while !first_started.load(Ordering::SeqCst) {\n tokio::task::yield_now().await;\n }\n\n let state_for_fmp = state.clone();\n let config_for_fmp = config_path.clone();\n let effective_for_fmp = effective_config_path.clone();\n let fmp_task = tokio::spawn(async move {\n with_config_write_lock(&state_for_fmp, || {\n apply_setting_updates(\n &config_for_fmp,\n &effective_for_fmp,\n vec![\n (\n \"fmp.api_keys\",\n serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(\n \"fmp-key-1\".to_string(),\n )]),\n ),\n (\"fmp.api_key\", serde_yaml::Value::String(String::new())),\n ],\n )\n .map(|_| ())\n })\n .await\n });\n\n agent_task\n .await\n .expect(\"agent task should join\")\n .expect(\"agent save should succeed\");\n fmp_task\n .await\n .expect(\"fmp task should join\")\n .expect(\"fmp save should succeed\");\n\n let config = HoneConfig::from_file(&config_path).expect(\"final config should load\");\n assert_eq!(config.agent.runner, \"codex_acp\");\n assert_eq!(config.agent.codex_model, \"ignored-model\");\n assert_eq!(config.fmp.api_keys, vec![\"fmp-key-1\".to_string()]);\n }\n\n #[test]\n fn seed_auxiliary_settings_prefers_explicit_auxiliary_config() {\n let mut config = HoneConfig::default();\n config.llm.auxiliary.base_url = \"https://api.minimaxi.com/v1\".to_string();\n config.llm.auxiliary.api_key = \"sk-cp-aux\".to_string();\n config.llm.auxiliary.model = \"MiniMax-M2.7-highspeed\".to_string();\n\n let seeded = seed_auxiliary_settings(&config);\n assert_eq!(seeded.base_url, \"https://api.minimaxi.com/v1\");\n assert_eq!(seeded.api_key, \"sk-cp-aux\");\n assert_eq!(seeded.model, \"MiniMax-M2.7-highspeed\");\n }\n\n #[test]\n fn build_agent_setting_updates_persists_llm_profile_bindings_and_params() {\n let settings = AgentSettings {\n runner: \"hone_cloud\".to_string(),\n codex_model: String::new(),\n openai_url: String::new(),\n openai_model: String::new(),\n openai_api_key: String::new(),\n auxiliary: None,\n hone_cloud: None,\n llm_profiles: Some(LlmProfileSettings {\n default_profile: \"main\".to_string(),\n auxiliary_profile: \"aux\".to_string(),\n polish_profile: \"aux\".to_string(),\n news_classifier_profile: \"news_classifier\".to_string(),\n filing_summary_profile: \"filing_summary\".to_string(),\n earnings_quality_profile: \"earnings_quality\".to_string(),\n digest_pass1_profile: \"digest_fast\".to_string(),\n digest_pass2_profile: \"digest_strong\".to_string(),\n digest_event_dedupe_profile: \"digest_strong\".to_string(),\n mainline_distill_profile: \"mainline_short\".to_string(),\n profiles: vec![LlmProfileEntrySettings {\n id: \"digest_strong\".to_string(),\n provider: \"openrouter\".to_string(),\n model: \"x-ai/grok-4.1-fast\".to_string(),\n max_tokens: Some(1600),\n temperature: Some(0.2),\n top_p: None,\n reasoning_effort: Some(\"low\".to_string()),\n reasoning_max_tokens: Some(512),\n response_format_json: true,\n }],\n }),\n };\n\n let updates = build_agent_setting_updates(&settings);\n let update_map = updates\n .into_iter()\n .map(|(path, value)| (path, value))\n .collect::<std::collections::HashMap<_, _>>();\n\n assert_eq!(\n update_map\n .get(\"event_engine.global_digest.pass2_llm\")\n .and_then(serde_yaml::Value::as_str),\n Some(\"digest_strong\")\n );\n assert_eq!(\n update_map\n .get(\"llm.profiles.digest_strong.model\")\n .and_then(serde_yaml::Value::as_str),\n Some(\"x-ai/grok-4.1-fast\")\n );\n assert_eq!(\n update_map\n .get(\"llm.profiles.digest_strong.params.reasoning.effort\")\n .and_then(serde_yaml::Value::as_str),\n Some(\"low\")\n );\n assert_eq!(\n update_map\n .get(\"llm.profiles.digest_strong.params.response_format.type\")\n .and_then(serde_yaml::Value::as_str),\n Some(\"json_object\")\n );\n }\n\n #[test]\n fn build_agent_setting_updates_persists_auxiliary_key_without_env_field() {\n let settings = AgentSettings {\n runner: \"opencode_acp\".to_string(),\n codex_model: String::new(),\n openai_url: String::new(),\n openai_model: String::new(),\n openai_api_key: String::new(),\n auxiliary: Some(AuxiliarySettings {\n base_url: \"https://api.minimaxi.com/v1\".to_string(),\n api_key: \"sk-cp-aux\".to_string(),\n model: \"MiniMax-M2.7-highspeed\".to_string(),\n }),\n hone_cloud: None,\n llm_profiles: None,\n };\n\n let updates = build_agent_setting_updates(&settings);\n assert!(updates.iter().any(|(path, value)| {\n path == \"llm.auxiliary.api_key\" && value.as_str() == Some(\"sk-cp-aux\")\n }));\n assert!(\n updates\n .iter()\n .all(|(path, _)| path != \"llm.auxiliary.api_key_env\")\n );\n }\n\n #[test]\n fn agent_settings_require_save_skips_identical_runner_payloads() {\n let settings = AgentSettings {\n runner: \"opencode_acp\".to_string(),\n codex_model: String::new(),\n openai_url: \"https://openrouter.ai/api/v1\".to_string(),\n openai_model: \"google/gemini-2.5-pro-preview\".to_string(),\n openai_api_key: \"sk-or\".to_string(),\n auxiliary: Some(AuxiliarySettings {\n base_url: \"https://api.minimaxi.com/v1\".to_string(),\n api_key: \"sk-cp-aux\".to_string(),\n model: \"MiniMax-M2.7-highspeed\".to_string(),\n }),\n hone_cloud: Some(HoneCloudSettings {\n base_url: \"https://hone-claw.com\".to_string(),\n api_key: \"hck_test\".to_string(),\n model: \"hone-cloud\".to_string(),\n }),\n llm_profiles: None,\n };\n\n assert!(\n !agent_settings_require_save(&settings, &settings.clone()),\n \"unchanged settings should not trigger a fresh save/restart\"\n );\n\n let changed = AgentSettings {\n runner: \"hone_cloud\".to_string(),\n ..settings.clone()\n };\n assert!(\n agent_settings_require_save(&settings, &changed),\n \"changing the runner should still trigger save/restart\"\n );\n }\n\n #[test]\n fn bundled_agent_settings_update_result_surfaces_runtime_not_applied() {\n let settings = AgentSettings {\n runner: \"codex_acp\".to_string(),\n codex_model: String::new(),\n openai_url: \"https://openrouter.ai/api/v1\".to_string(),\n openai_model: \"google/gemini-2.5-pro-preview\".to_string(),\n openai_api_key: String::new(),\n auxiliary: None,\n hone_cloud: None,\n llm_profiles: None,\n };\n let status = BackendStatusInfo {\n config: BackendConfig {\n mode: \"bundled\".to_string(),\n base_url: String::new(),\n bearer_token: String::new(),\n },\n resolved_base_url: Some(\"http://127.0.0.1:8077\".to_string()),\n connected: false,\n last_error: Some(\"bundle restart failed\".to_string()),\n meta: None,\n diagnostics: DiagnosticPaths {\n config_dir: \"/tmp/config\".to_string(),\n data_dir: \"/tmp/data\".to_string(),\n logs_dir: \"/tmp/logs\".to_string(),\n desktop_log: \"/tmp/logs/desktop.log\".to_string(),\n sidecar_log: \"/tmp/logs/sidecar.log\".to_string(),\n },\n };\n\n let result = build_agent_settings_update_result(settings.clone(), Some(status));\n\n assert!(result.restarted_bundled_backend);\n assert_eq!(result.settings.runner, \"codex_acp\");\n assert!(\n result.message.contains(\"当前运行时尚未生效\"),\n \"should explicitly surface that runtime did not apply the new runner\"\n );\n assert_eq!(\n result\n .backend_status\n .as_ref()\n .and_then(|status| status.last_error.as_deref()),\n Some(\"bundle restart failed\")\n );\n }\n\n #[test]\n fn desktop_channel_settings_input_accepts_secret_fields_from_camel_case_payload() {\n let input: DesktopChannelSettingsInput = serde_json::from_value(serde_json::json!({\n \"imessageEnabled\": true,\n \"feishuEnabled\": true,\n \"feishuAppId\": \"cli_test\",\n \"feishuAppSecret\": \"secret-value\",\n \"telegramEnabled\": true,\n \"telegramBotToken\": \"tg-token\",\n \"discordEnabled\": true,\n \"discordBotToken\": \"discord-token\"\n }))\n .expect(\"desktop channel payload should deserialize\");\n\n assert!(input.imessage_enabled);\n assert_eq!(input.imessage_target_handle, \"\");\n assert!(input.feishu_enabled);\n assert_eq!(input.feishu_app_id, \"cli_test\");\n assert_eq!(input.feishu_app_secret, \"secret-value\");\n assert_eq!(input.feishu_chat_scope, \"\");\n assert!(input.telegram_enabled);\n assert_eq!(input.telegram_bot_token, \"tg-token\");\n assert_eq!(input.telegram_allow_from, Vec::<String>::new());\n assert!(input.discord_enabled);\n assert_eq!(input.discord_bot_token, \"discord-token\");\n assert_eq!(input.discord_allow_from, Vec::<String>::new());\n }\n\n #[test]\n fn desktop_channel_settings_serializes_secret_fields_to_camel_case_payload() {\n let settings = DesktopChannelSettings {\n config_path: \"/tmp/config.yaml\".to_string(),\n imessage_enabled: false,\n imessage_target_handle: \"+15551234567\".to_string(),\n feishu_enabled: true,\n feishu_app_id: \"cli_test\".to_string(),\n feishu_app_secret: \"secret-value\".to_string(),\n feishu_chat_scope: \"ALL\".to_string(),\n feishu_allow_emails: vec![\"admin@example.com\".to_string()],\n feishu_allow_mobiles: vec![],\n feishu_allow_open_ids: vec![\"ou_abc\".to_string()],\n telegram_enabled: true,\n telegram_bot_token: \"tg-token\".to_string(),\n telegram_chat_scope: \"DM_ONLY\".to_string(),\n telegram_allow_from: vec![\"123\".to_string()],\n discord_enabled: true,\n discord_bot_token: \"discord-token\".to_string(),\n discord_chat_scope: \"GROUPCHAT_ONLY\".to_string(),\n discord_allow_from: vec![\"456\".to_string()],\n };\n\n let json =\n serde_json::to_value(&settings).expect(\"desktop channel settings should serialize\");\n assert_eq!(json[\"feishuAppId\"], \"cli_test\");\n assert_eq!(json[\"feishuAppSecret\"], \"secret-value\");\n assert_eq!(json[\"imessageTargetHandle\"], \"+15551234567\");\n assert_eq!(json[\"feishuAllowOpenIds\"][0], \"ou_abc\");\n assert_eq!(json[\"telegramBotToken\"], \"tg-token\");\n assert_eq!(json[\"telegramAllowFrom\"][0], \"123\");\n assert_eq!(json[\"discordBotToken\"], \"discord-token\");\n assert_eq!(json[\"discordAllowFrom\"][0], \"456\");\n }\n\n #[test]\n fn mark_bundled_runtime_dirty_clears_cached_meta_and_sets_restart_reason() {\n let mut manager = DesktopBackendManager::default();\n manager.resolved_base_url = Some(\"http://127.0.0.1:3000\".to_string());\n manager.meta = Some(MetaInfo {\n name: \"Hone\".to_string(),\n version: \"0.1.13\".to_string(),\n channel: \"imessage\".to_string(),\n supports_imessage: true,\n api_version: API_VERSION.to_string(),\n capabilities: vec![\"chat\".to_string()],\n deployment_mode: \"local\".to_string(),\n });\n manager.last_error = None;\n\n mark_bundled_runtime_dirty(&mut manager, \"channel settings updated\");\n\n assert!(manager.meta.is_none());\n assert_eq!(\n manager.last_error.as_deref(),\n Some(\"channel settings updated\")\n );\n assert_eq!(\n manager.resolved_base_url.as_deref(),\n Some(\"http://127.0.0.1:3000\")\n );\n }\n\n #[tokio::test]\n async fn stop_managed_children_clears_web_server_tasks() {\n let mut manager = DesktopBackendManager::default();\n manager\n .web_server_tasks\n .push(tokio::spawn(async { std::future::pending::<()>().await }));\n\n stop_managed_children(&mut manager);\n\n assert!(\n manager.web_server_tasks.is_empty(),\n \"bundled restart must not leave old web-api task handles behind\"\n );\n }\n}\n\n/// 检测 runner 对应的本机 CLI 二进制是否可用。\n/// 仅运行 `AgentRunnerKind::cli_probe()` 提供的轻量参数;不发送真实请求,也不启动 runner。\npub(crate) async fn check_agent_cli_impl(runner: String) -> Result<CliCheckResult, String> {\n let probe = hone_core::config::AgentRunnerKind::from_config_value(&runner)\n .cli_probe()\n .ok_or_else(|| format!(\"不支持的 runner: {runner}\"))?;\n let binary = probe.binary;\n\n let mut command = tokio::process::Command::new(binary);\n command.arg(probe.arg);\n let result = tokio::time::timeout(std::time::Duration::from_secs(8), command.output()).await;\n\n match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();\n let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();\n let version_hint = if !stdout.is_empty() {\n stdout\n } else if !stderr.is_empty() {\n stderr\n } else {\n \"(无版本输出)\".to_string()\n };\n Ok(CliCheckResult {\n ok: true,\n message: format!(\"{binary} 已就绪 {version_hint}\"),\n })\n }\n Ok(Err(e)) => Ok(CliCheckResult {\n ok: false,\n message: format!(\"找不到 {binary} 命令:{e}\"),\n }),\n Err(_) => Ok(CliCheckResult {\n ok: false,\n message: format!(\"{binary} 检测超时(8 秒)\"),\n }),\n }\n}\n\n/// 读取 config.yaml 中的 OpenRouter API Key 设置(多 Key)\npub(crate) fn get_openrouter_settings_impl(app: AppHandle) -> Result<OpenRouterSettings, String> {\n let runtime = ensure_runtime_paths(&app)?;\n let config = HoneConfig::from_file(&runtime.config_path).map_err(|e| e.to_string())?;\n let pool = config.llm.openrouter_key_pool();\n Ok(OpenRouterSettings {\n api_keys: pool.keys().to_vec(),\n })\n}\n\n/// 保存 OpenRouter API Keys 到 config.yaml,并重启内置后端立即生效\npub(crate) async fn set_openrouter_settings_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n settings: OpenRouterSettings,\n) -> Result<(), String> {\n let runtime = ensure_runtime_paths(&app)?;\n let valid_keys: Vec<String> = settings\n .api_keys\n .into_iter()\n .filter(|k| !k.trim().is_empty())\n .collect();\n with_config_write_lock(&state, || {\n apply_setting_updates(\n &runtime.config_path,\n &runtime.effective_config_path,\n vec![\n (\n \"llm.providers.openrouter.api_keys\",\n serde_yaml::Value::Sequence(\n valid_keys\n .iter()\n .cloned()\n .map(serde_yaml::Value::String)\n .collect(),\n ),\n ),\n (\n \"llm.providers.openrouter.api_key\",\n serde_yaml::Value::String(String::new()),\n ),\n (\n \"llm.openrouter.api_keys\",\n serde_yaml::Value::Sequence(Vec::new()),\n ),\n (\n \"llm.openrouter.api_key\",\n serde_yaml::Value::String(String::new()),\n ),\n ],\n )\n .map(|_| ())\n })\n .await?;\n log_desktop(\n &app,\n \"INFO\",\n format!(\"saved openrouter settings keys_count={}\", valid_keys.len()),\n );\n // 内置后端模式下重启以立即生效\n let backend_config = load_persisted_config(&app).unwrap_or_default();\n if backend_config.mode == \"bundled\" {\n {\n let mut guard = state.inner.lock().unwrap();\n mark_bundled_runtime_dirty(\n &mut guard,\n \"openrouter settings updated; bundled runtime restart required\",\n );\n }\n let _ = connect_backend_serialized(&app, &state).await;\n }\n Ok(())\n}\n\n/// 读取 canonical config.yaml 中的 FMP API Key 设置(多 Key)\npub(crate) fn get_fmp_settings_impl(app: AppHandle) -> Result<FmpSettings, String> {\n let runtime = ensure_runtime_paths(&app)?;\n let config = HoneConfig::from_file(&runtime.config_path).map_err(|e| e.to_string())?;\n let pool = config.fmp.effective_key_pool();\n Ok(FmpSettings {\n api_keys: pool.keys().to_vec(),\n })\n}\n\n/// 保存 FMP API Keys 到 canonical config.yaml,并重启内置后端立即生效\npub(crate) async fn set_fmp_settings_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n settings: FmpSettings,\n) -> Result<(), String> {\n let runtime = ensure_runtime_paths(&app)?;\n let valid_keys: Vec<String> = settings\n .api_keys\n .into_iter()\n .filter(|k| !k.trim().is_empty())\n .collect();\n with_config_write_lock(&state, || {\n apply_setting_updates(\n &runtime.config_path,\n &runtime.effective_config_path,\n vec![\n (\n \"fmp.api_keys\",\n serde_yaml::Value::Sequence(\n valid_keys\n .iter()\n .cloned()\n .map(serde_yaml::Value::String)\n .collect(),\n ),\n ),\n (\"fmp.api_key\", serde_yaml::Value::String(String::new())),\n ],\n )\n .map(|_| ())\n })\n .await?;\n log_desktop(\n &app,\n \"INFO\",\n format!(\"saved fmp settings keys_count={}\", valid_keys.len()),\n );\n let backend_config = load_persisted_config(&app).unwrap_or_default();\n if backend_config.mode == \"bundled\" {\n {\n let mut guard = state.inner.lock().unwrap();\n mark_bundled_runtime_dirty(\n &mut guard,\n \"fmp settings updated; bundled runtime restart required\",\n );\n }\n let _ = connect_backend_serialized(&app, &state).await;\n }\n Ok(())\n}\n\n/// 读取 canonical config.yaml 中的 Tavily API Key 设置(多 Key)\npub(crate) fn get_tavily_settings_impl(app: AppHandle) -> Result<TavilySettings, String> {\n let runtime = ensure_runtime_paths(&app)?;\n let config = HoneConfig::from_file(&runtime.config_path).map_err(|e| e.to_string())?;\n // 过滤空 key\n let valid_keys: Vec<String> = config\n .search\n .api_keys\n .into_iter()\n .filter(|k| !k.trim().is_empty())\n .collect();\n Ok(TavilySettings {\n api_keys: valid_keys,\n })\n}\n\n/// 保存 Tavily API Keys 到 canonical config.yaml,并重启内置后端立即生效\npub(crate) async fn set_tavily_settings_impl(\n app: AppHandle,\n state: State<'_, DesktopState>,\n settings: TavilySettings,\n) -> Result<(), String> {\n let runtime = ensure_runtime_paths(&app)?;\n let valid_keys: Vec<String> = settings\n .api_keys\n .into_iter()\n .filter(|k| !k.trim().is_empty())\n .collect();\n with_config_write_lock(&state, || {\n apply_setting_updates(\n &runtime.config_path,\n &runtime.effective_config_path,\n vec![(\n \"search.api_keys\",\n serde_yaml::Value::Sequence(\n valid_keys\n .iter()\n .cloned()\n .map(serde_yaml::Value::String)\n .collect(),\n ),\n )],\n )\n .map(|_| ())\n })\n .await?;\n log_desktop(\n &app,\n \"INFO\",\n format!(\"saved tavily settings keys_count={}\", valid_keys.len()),\n );\n let backend_config = load_persisted_config(&app).unwrap_or_default();\n if backend_config.mode == \"bundled\" {\n {\n let mut guard = state.inner.lock().unwrap();\n mark_bundled_runtime_dirty(\n &mut guard,\n \"tavily settings updated; bundled runtime restart required\",\n );\n }\n let _ = connect_backend_serialized(&app, &state).await;\n }\n Ok(())\n}\n\npub(crate) fn run_desktop_app() {\n crate::commands::run_desktop_app();\n}\n"}}}}