1 line
385 KiB
JSON
1 line
385 KiB
JSON
{"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(¤t_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(¤t_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"}}}} |