feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道

Refs #225

Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
ddCat
2026-08-15 12:45:29 +08:00
parent 8f2232feee
commit 57e1820f41
179 changed files with 49746 additions and 1897 deletions

View File

@@ -1,4 +1,4 @@
import { App, Button, Form, Input, Popconfirm, Segmented, Select, Tooltip } from "antd";
import { App, Button, Form, Input, Popconfirm, Segmented, Select, Switch, Tooltip } from "antd";
import { ChevronDown, ChevronUp, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useState } from "react";
@@ -6,6 +6,7 @@ import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/chann
import { WorkspaceState } from "@/components/layout/workspace-state";
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
import { modelProtocolCapability, protocolForModelCatalog } from "@/lib/model-protocols";
import { desktopLocalChannelFormState, desktopLocalChannelPayloadValue, DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL } from "@/lib/desktop-local-channel";
import { fetchChannelModels, type ChannelModelCatalogItem } from "@/services/api/image";
import {
createModelChannel,
@@ -17,6 +18,7 @@ import {
type ModelChannel,
} from "@/stores/use-config-store";
import { ChannelModelSettings } from "./channel-video-pricing";
import { useUserStore } from "@/stores/use-user-store";
type UserChannelConnection = "openai" | "gemini";
type ChannelModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
@@ -27,6 +29,8 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
const replaceConfig = useConfigStore((state) => state.replaceConfig);
const [loadingChannelIds, setLoadingChannelIds] = useState<string[]>([]);
const [collapsedChannelIds, setCollapsedChannelIds] = useState<Set<string>>(new Set());
const desktopLocalChannelsEnabled = useUserStore((state) => state.features.desktopLocalChannelsEnabled);
const desktopLocalChannelHostname = typeof window === "undefined" ? "" : window.location.hostname;
const userChannels = config.channels.filter((channel) => channel.scope !== "system");
const updateChannels = (channels: ModelChannel[], baseConfig = config) => {
@@ -90,7 +94,8 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
}
setChannelLoading(channel.id, true);
try {
const result = await fetchChannelModels(channel, true);
const projectedChannel = { ...channel, allowLocalChannel: userLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).payloadValue };
const result = await fetchChannelModels(projectedChannel, true);
if (!result.models.length) {
message.warning(`${channel.name || "当前渠道"}未返回模型,已保留现有手工模型`);
return;
@@ -127,7 +132,8 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
const results = await Promise.all(
runnable.map(async (channel) => {
try {
const result = await fetchChannelModels(channel, true);
const projectedChannel = { ...channel, allowLocalChannel: userLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).payloadValue };
const result = await fetchChannelModels(projectedChannel, true);
return { channel, result, error: "" };
} catch (error) {
return { channel, result: { models: [], catalog: [] }, error: error instanceof Error ? error.message : "读取失败" };
@@ -209,7 +215,14 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
<div className="settings-field-group-label lg:col-span-12">连接信息</div>
<Form.Item label="渠道名称" htmlFor={`channel-${channel.id}-name`} className="mb-0 lg:col-span-3"><Input id={`channel-${channel.id}-name`} value={channel.name} placeholder="例如:我的 NewAPI" onChange={(event) => updateChannel(channel.id, { name: event.target.value })} onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })} /></Form.Item>
<Form.Item label="渠道连接类型" className="mb-0 lg:col-span-3" extra="仅用于拉取模型目录;模型能力和请求协议在下方统一配置。"><Segmented<UserChannelConnection> block value={channelConnectionMode(channel)} options={[{ label: "OpenAI 兼容", value: "openai" }, { label: "Gemini 原生", value: "gemini" }]} onChange={(value) => updateChannelConnection(channel, value)} /></Form.Item>
<Form.Item label="Base URL" htmlFor={`channel-${channel.id}-base-url`} className="mb-0 lg:col-span-6"><Input id={`channel-${channel.id}-base-url`} inputMode="url" value={channel.baseUrl} placeholder="填写渠道 Base URL" onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })} onBlur={(event) => updateChannel(channel.id, { baseUrl: event.target.value.trim().replace(/\/+$/, "") })} /></Form.Item>
<UserLocalChannelFields
channel={channel}
visible={userLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).visible}
checked={userLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).checked}
desktopLocalChannelsEnabled={desktopLocalChannelsEnabled}
hostname={desktopLocalChannelHostname}
updateChannel={updateChannel}
/>
<Form.Item label="API Key" htmlFor={`channel-${channel.id}-api-key`} className="mb-0 lg:col-span-5"><Input.Password id={`channel-${channel.id}-api-key`} autoComplete="new-password" value={channel.apiKey} placeholder={channel.apiFormat === "gemini" ? "填写 Gemini API Key" : "填写当前渠道 API Key"} onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })} onBlur={(event) => updateChannel(channel.id, { apiKey: event.target.value.trim() })} /></Form.Item>
<Form.Item label="Secret Key(可选)" htmlFor={`channel-${channel.id}-secret-key`} className="mb-0 lg:col-span-5" extra="即梦等 AK/SK 协议需要;其他协议留空。"><Input.Password id={`channel-${channel.id}-secret-key`} autoComplete="new-password" value={channel.secretKey || ""} placeholder="填写 Secret Key" onChange={(event) => updateChannel(channel.id, { secretKey: event.target.value })} onBlur={(event) => updateChannel(channel.id, { secretKey: event.target.value.trim() })} /></Form.Item>
<div className="settings-field-group-label lg:col-span-12">模型与能力</div>
@@ -227,6 +240,35 @@ export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void
);
}
export function userLocalChannelFormOwner(desktopLocalChannelsEnabled: boolean, hostname: string, requestedAllowLocalChannel?: boolean) {
const state = desktopLocalChannelFormState(desktopLocalChannelsEnabled, hostname, requestedAllowLocalChannel);
return { ...state, payloadValue: desktopLocalChannelPayloadValue(desktopLocalChannelsEnabled, hostname, requestedAllowLocalChannel) };
}
export function UserLocalChannelSwitch({ visible, checked, onChange }: { visible: boolean; checked: boolean; onChange: (checked: boolean) => void }) {
if (!visible) return null;
return (
<Form.Item label="允许本机渠道" className="mb-0 lg:col-span-12" extra={`仅放行精确 localhost 或 127.0.0.1;示例:${DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL}`}>
<Switch checked={checked} onChange={onChange} />
</Form.Item>
);
}
export function UserLocalChannelFields({ channel, visible, checked, desktopLocalChannelsEnabled, hostname, updateChannel }: { channel: ModelChannel; visible: boolean; checked: boolean; desktopLocalChannelsEnabled: boolean; hostname: string; updateChannel: (id: string, patch: Partial<ModelChannel>) => void }) {
return (
<>
<Form.Item label="Base URL" htmlFor={`channel-${channel.id}-base-url`} className="mb-0 lg:col-span-6">
<Input id={`channel-${channel.id}-base-url`} inputMode="url" value={channel.baseUrl} placeholder={checked ? DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL : "填写渠道 Base URL"} onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })} onBlur={(event) => updateChannel(channel.id, { baseUrl: event.target.value.trim().replace(/\/+$/, "") })} />
</Form.Item>
<UserLocalChannelSwitch visible={visible} checked={checked} onChange={(value) => updateChannel(channel.id, userLocalChannelChangePatch(desktopLocalChannelsEnabled, hostname, value))} />
</>
);
}
export function userLocalChannelChangePatch(desktopLocalChannelsEnabled: boolean, hostname: string, checked: boolean) {
return { allowLocalChannel: userLocalChannelFormOwner(desktopLocalChannelsEnabled, hostname, checked).payloadValue };
}
export function channelValidationError(channel: ModelChannel) {
return channelConnectionError(channel) || validateChannelHeaders(channel.headers) || (!channel.models.length ? "请添加至少一个模型" : "");
}
@@ -316,7 +358,7 @@ function channelConnectionError(channel: ModelChannel) {
}
function channelConnectionSignature(channel: ModelChannel) {
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.secretKey?.trim() || "", channel.apiFormat, channel.interfaceType || "auto", JSON.stringify(channel.headers || [])].join("\n");
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.secretKey?.trim() || "", channel.apiFormat, channel.interfaceType || "auto", channel.allowLocalChannel === true ? "local" : "remote", JSON.stringify(channel.headers || [])].join("\n");
}
function channelProtocolLabel(channel: ModelChannel) {

View File

@@ -1,20 +1,23 @@
import { App, Button, Form, Input, InputNumber, Select } from "antd";
import { ArrowLeft, Boxes, Cloud, MessageSquareText, RadioTower, SlidersHorizontal } from "lucide-react";
import { ArrowLeft, Boxes, Cloud, MessageSquareText, RadioTower, SlidersHorizontal, SquareTerminal } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { UserOSSSettingsForm } from "@/components/layout/user-oss-settings-form";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
import { refreshSystemChannels } from "@/lib/user-session";
import { defaultConfig, useConfigStore } from "@/stores/use-config-store";
import { defaultConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import { ChannelSettingsPane, channelValidationError, focusInvalidChannelField, isChannelReady } from "./channel-settings-pane";
export { UserLocalChannelFields, UserLocalChannelSwitch, userLocalChannelChangePatch, userLocalChannelFormOwner } from "./channel-settings-pane";
import { ModelDefaultGrid } from "./model-default-grid";
import { LocalCliSettings } from "./local-cli-settings";
import { PromptPreferencesPane } from "./prompt-preferences-pane";
type ConfigSectionKey = "channels" | "models" | "preferences" | "prompts" | "storage";
type ConfigSectionKey = "local-cli" | "channels" | "models" | "preferences" | "prompts" | "storage";
const configSections: Array<{ key: ConfigSectionKey; label: string; description: string; icon: ReactNode }> = [
{ key: "local-cli", label: "本机工具", description: "连接 Runtime 与官方 CLI", icon: <SquareTerminal className="size-4" /> },
{ key: "channels", label: "自定义渠道", description: "连接你自己的模型服务", icon: <RadioTower className="size-4" /> },
{ key: "models", label: "模型选择", description: "按领域选择默认模型", icon: <Boxes className="size-4" /> },
{ key: "preferences", label: "生成偏好", description: "画布、视频与音频默认值", icon: <SlidersHorizontal className="size-4" /> },
@@ -22,7 +25,7 @@ const configSections: Array<{ key: ConfigSectionKey; label: string; description:
{ key: "storage", label: "我的对象存储", description: "管理个人媒体存储", icon: <Cloud className="size-4" /> },
];
function isConfigSection(value: string | null): value is ConfigSectionKey {
export function isConfigSection(value: string | null): value is ConfigSectionKey {
return configSections.some((section) => section.key === value);
}
@@ -33,6 +36,7 @@ export default function SettingsPage() {
const requestedSection = searchParams.get("section");
const [activeTab, setActiveTab] = useState<ConfigSectionKey>(isConfigSection(requestedSection) ? requestedSection : "channels");
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveConfig();
const updateConfig = useConfigStore((state) => state.updateConfig);
const shouldPromptContinue = searchParams.get("continue") === "1";
const userId = useUserStore((state) => state.user?.id);
@@ -68,7 +72,8 @@ export default function SettingsPage() {
focusInvalidChannelField(invalidChannel);
return;
}
if (!config.channels.some(isChannelReady)) {
const hasReadyLocalRuntime = effectiveConfig.channels.some((channel) => channel.transport === "local-runtime" && channel.enabled !== false && Boolean(channel.localModels?.length));
if (!config.channels.some(isChannelReady) && !hasReadyLocalRuntime) {
selectSection("channels");
message.error(shouldPromptContinue ? "请先完成至少一个渠道的 Base URL、API Key 和模型配置" : "当前没有可用渠道,请先完成连接信息和模型配置");
return;
@@ -78,6 +83,7 @@ export default function SettingsPage() {
};
const panes: Record<ConfigSectionKey, ReactNode> = {
"local-cli": <SettingsPane><LocalCliSettings /></SettingsPane>,
channels: <SettingsPane><ChannelSettingsPane onOpenModels={() => selectSection("models")} /></SettingsPane>,
models: (
<SettingsPane>
@@ -88,7 +94,7 @@ export default function SettingsPage() {
</div>
</div>
<div className="settings-section">
<ModelDefaultGrid config={config} onChange={(key, model) => updateConfig(key, model)} />
<ModelDefaultGrid config={effectiveConfig} onChange={(key, model) => updateConfig(key, model)} />
</div>
</SettingsPane>
),

View File

@@ -0,0 +1,285 @@
import { App, Button, Popconfirm, Tag, Typography } from "antd";
import { CheckCircle2, Copy, ExternalLink, LogIn, LogOut, RefreshCw, Server, SquareTerminal } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { DreaminaAgentError, getDreaminaStatus, loginDreamina, logoutDreamina, type DreaminaCliStatus } from "@/services/local-dreamina-cli";
import { getLocalRuntimeSessionClient, useLocalRuntimeStore, type LocalRuntimeConnectionState } from "@/stores/use-local-runtime-store";
type PendingAction = "refresh" | "login" | "logout" | "";
type PresentationAction = "refresh" | "login" | "open_verification" | "logout" | null;
type Presentation = {
label: string;
tone: "success" | "processing" | "warning" | "error" | "default";
action: PresentationAction;
actionLabel?: string;
creditLabel?: string;
creditObservedAtLabel?: string;
};
export const LOCAL_CLI_SETTINGS_COPY = {
runtimeTitle: "本机连接",
runtimeConnected: "本机服务已连接,CLI 状态会自动同步。",
runtimeDetecting: "正在检测本机服务;请确认已启动当前版本。",
runtimeReconnect: "重新连接",
runtimeSafety: "官方 CLI 登录资料保存在本机;本页面不读取或上传 Cookie、浏览器 Profile 或登录令牌。",
runtimeRefresh: "刷新状态",
dreaminaDescription: "直接读取当前 Windows 用户的官方即梦 CLI 登录状态。",
dreaminaDisconnected: "连接本机服务后自动检测",
dreaminaDisconnectedMessage: "重新连接本机服务后,将自动读取官方 CLI 状态。",
dreaminaMembership: "账号生成权限:未知。当前页面只确认本机适配器支持与登录状态;具体账号是否可生成,以官方最终结果为准。",
dreaminaConsistency: "任务状态通过后台轮询最终同步,不是实时推送;关闭页面不会停止已经提交的官方任务。",
dreaminaCancel: "官方 Dreamina CLI 当前不提供取消命令;官方已接受的任务只能转入后台继续同步,不能伪装成已取消。",
dreaminaAccountSwitch: "本机任务运行期间,请不要在其他程序中切换 Dreamina CLI 账号;外部换号无法被本页面实时感知。",
dreaminaRefresh: "刷新状态",
} as const;
export function localCliSettingsPresentation(input: { connection: string; moduleAvailable: boolean; dreamina?: DreaminaCliStatus; timeZone?: string }): { runtime: Presentation; dreamina: Presentation } {
const runtime = runtimePresentation(input.connection as LocalRuntimeConnectionState);
if (input.connection !== "connected") {
return { runtime, dreamina: { label: LOCAL_CLI_SETTINGS_COPY.dreaminaDisconnected, tone: "default", action: null } };
}
if (!input.moduleAvailable) {
return { runtime, dreamina: { label: "模块未加载", tone: "error", action: "refresh" } };
}
const status = input.dreamina;
if (!status) return { runtime, dreamina: { label: "正在检测", tone: "processing", action: "refresh" } };
const creditObservedAt = formatCreditObservedAt(status.creditObservedAt, input.timeZone);
const hasScopedCredit = status.totalCredit !== undefined && Boolean(status.accountBinding) && status.sessionEpoch !== undefined && Boolean(creditObservedAt);
if (status.state === "missing") return { runtime, dreamina: { label: "未安装", tone: "error", action: "refresh" } };
if (status.state === "login_pending") return { runtime, dreamina: { label: "等待官方授权", tone: "processing", action: "open_verification" } };
if (status.authenticated)
return {
runtime,
dreamina: {
label: "已登录",
tone: "success",
action: "logout",
...(!hasScopedCredit
? {}
: {
creditLabel: `即梦积分 ${new Intl.NumberFormat("zh-CN").format(status.totalCredit!)}`,
}),
...(creditObservedAt ? { creditObservedAtLabel: `上次刷新积分 ${creditObservedAt}` } : {}),
},
};
if (status.state === "installed") return { runtime, dreamina: { label: "未登录", tone: "warning", action: "login" } };
return { runtime, dreamina: { label: "检测失败", tone: "error", action: "refresh" } };
}
export function formatCreditObservedAt(value: unknown, timeZone?: string) {
if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) return undefined;
try {
const parts = new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
...(timeZone ? { timeZone } : {}),
}).formatToParts(new Date(value));
const hour = parts.find((part) => part.type === "hour")?.value;
const minute = parts.find((part) => part.type === "minute")?.value;
return hour && minute ? `${hour}:${minute}` : undefined;
} catch {
return undefined;
}
}
export function LocalCliSettings() {
const { message } = App.useApp();
const connection = useLocalRuntimeStore((state) => state.connection);
const connecting = useLocalRuntimeStore((state) => state.connecting);
const modules = useLocalRuntimeStore((state) => state.modules);
const runtimeError = useLocalRuntimeStore((state) => state.error);
const connect = useLocalRuntimeStore((state) => state.connect);
const moduleAvailable = modules.some((module) => module.id === "dreamina");
const [status, setStatus] = useState<DreaminaCliStatus>();
const [pending, setPending] = useState<PendingAction>("");
const lifecycle = useRef<{ revision: number; controller: AbortController | null }>({
revision: 0,
controller: null,
});
const presentation = localCliSettingsPresentation({ connection, moduleAvailable, dreamina: status });
const refreshRuntime = useCallback(() => {
const controller = new AbortController();
void connect(controller.signal);
}, [connect]);
const runDreamina = useCallback(
async (action: Exclude<PendingAction, "">) => {
if (connection !== "connected" || !moduleAvailable) {
message.warning("请先连接已加载 Dreamina 的本机运行时");
return;
}
lifecycle.current.controller?.abort();
const revision = ++lifecycle.current.revision;
const controller = new AbortController();
lifecycle.current.controller = controller;
setPending(action);
try {
const client = getLocalRuntimeSessionClient();
const options = { signal: controller.signal };
const next = action === "login" ? await loginDreamina(client, options) : action === "logout" ? await logoutDreamina(client, options) : await getDreaminaStatus(client, options);
if (revision !== lifecycle.current.revision || controller.signal.aborted) return;
setStatus(next);
if (action === "logout") message.success("Dreamina CLI 已退出登录");
if (action === "login" && next.state === "login_pending") {
message.info("请打开官方验证页完成授权,再刷新状态");
}
} catch (error) {
if (revision !== lifecycle.current.revision || controller.signal.aborted) return;
setStatus(undefined);
message.error(error instanceof DreaminaAgentError ? error.message : "Dreamina CLI 操作失败");
} finally {
if (revision === lifecycle.current.revision) {
lifecycle.current.controller = null;
setPending("");
}
}
},
[connection, message, moduleAvailable],
);
useEffect(() => {
if (connection !== "connected" || !moduleAvailable) {
lifecycle.current.revision++;
lifecycle.current.controller?.abort();
lifecycle.current.controller = null;
setStatus(undefined);
setPending("");
return;
}
const timer = window.setTimeout(() => {
void runDreamina("refresh");
}, 0);
return () => {
window.clearTimeout(timer);
lifecycle.current.revision++;
lifecycle.current.controller?.abort();
lifecycle.current.controller = null;
};
}, [connection, moduleAvailable, runDreamina]);
const openVerification = () => {
if (status?.verificationUri) window.open(status.verificationUri, "_blank", "noopener,noreferrer");
};
return (
<div className="space-y-4">
<section aria-labelledby="local-runtime-title" className="rounded-md border border-border bg-background px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
<Server className="size-4 shrink-0 text-foreground/60" />
<h2 id="local-runtime-title" className="text-base font-semibold">
{LOCAL_CLI_SETTINGS_COPY.runtimeTitle}
</h2>
<Tag color={presentation.runtime.tone} className="m-0">
{presentation.runtime.label}
</Tag>
<p className="min-w-0 text-sm text-foreground/60">{connection === "connected" ? LOCAL_CLI_SETTINGS_COPY.runtimeConnected : runtimeError || LOCAL_CLI_SETTINGS_COPY.runtimeDetecting}</p>
<p className="basis-full text-xs leading-5 text-foreground/55">{LOCAL_CLI_SETTINGS_COPY.runtimeSafety}</p>
</div>
<Button icon={<RefreshCw className="size-4" />} loading={connecting} onClick={refreshRuntime}>
{presentation.runtime.actionLabel || LOCAL_CLI_SETTINGS_COPY.runtimeRefresh}
</Button>
</div>
</section>
<section aria-labelledby="dreamina-cli-title" className="rounded-md border border-border bg-background p-4 sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border/70 pb-4">
<div className="flex min-w-0 items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-[var(--r-lg)] bg-foreground/5">
<SquareTerminal className="size-5" />
</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 id="dreamina-cli-title" className="text-base font-semibold">
Dreamina CLI
</h2>
<Tag color={presentation.dreamina.tone} className="m-0">
{presentation.dreamina.label}
</Tag>
{status?.version ? <Tag className="m-0">v{status.version}</Tag> : null}
{presentation.dreamina.creditLabel ? (
<Tag color="blue" className="m-0">
{presentation.dreamina.creditLabel}
</Tag>
) : null}
</div>
<p className="mt-1 text-sm text-foreground/60">{LOCAL_CLI_SETTINGS_COPY.dreaminaDescription}</p>
{presentation.dreamina.creditObservedAtLabel ? <p className="mt-1 text-xs text-foreground/50">{presentation.dreamina.creditObservedAtLabel}</p> : null}
</div>
</div>
<Button icon={<RefreshCw className="size-4" />} loading={pending === "refresh"} disabled={connection !== "connected" || !moduleAvailable || Boolean(pending && pending !== "refresh")} onClick={() => void runDreamina("refresh")}>
{LOCAL_CLI_SETTINGS_COPY.dreaminaRefresh}
</Button>
</div>
<div className="grid gap-4 pt-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div className="space-y-3 text-sm">
<p className="text-foreground/75">{status?.message || dreaminaEmptyMessage(connection, moduleAvailable)}</p>
<p className="text-xs leading-6 text-foreground/60">{LOCAL_CLI_SETTINGS_COPY.dreaminaMembership}</p>
<p className="text-xs leading-6 text-foreground/55">{LOCAL_CLI_SETTINGS_COPY.dreaminaConsistency}</p>
<p className="text-xs leading-6 text-foreground/55">{LOCAL_CLI_SETTINGS_COPY.dreaminaCancel}</p>
<p className="text-xs leading-6 text-foreground/55">{LOCAL_CLI_SETTINGS_COPY.dreaminaAccountSwitch}</p>
{status?.state === "missing" ? (
<p className="rounded-md bg-foreground/[0.035] p-3 text-xs leading-6 text-foreground/65">未检测到官方 Dreamina CLI。请按官方说明安装并确保命令在 PATH 中可用,安装后点击“刷新状态”。当前版本不内置未经核实的安装源。</p>
) : null}
{status?.state === "login_pending" ? (
<div className="rounded-md border border-border/70 p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-foreground/60">官方验证用户码</span>
<Typography.Text
strong
copyable={{
text: status.userCode,
icon: [<Copy className="size-3.5" key="copy" />, <CheckCircle2 className="size-3.5" key="done" />],
}}
>
{status.userCode}
</Typography.Text>
</div>
{status.expiresAt ? <p className="mt-1 text-xs text-foreground/50">有效期至 {new Date(status.expiresAt).toLocaleTimeString()}</p> : null}
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2 lg:justify-end">
{status?.state === "installed" ? (
<Button type="primary" icon={<LogIn className="size-4" />} loading={pending === "login"} disabled={Boolean(pending && pending !== "login")} onClick={() => void runDreamina("login")}>
登录
</Button>
) : null}
{status?.state === "login_pending" ? (
<Button type="primary" icon={<ExternalLink className="size-4" />} onClick={openVerification}>
打开官方验证页
</Button>
) : null}
{status?.authenticated ? (
<Popconfirm title="退出 Dreamina CLI?" description="只清除当前 OS 用户的官方 CLI 登录状态。" okText="退出" cancelText="取消" onConfirm={() => void runDreamina("logout")}>
<Button danger icon={<LogOut className="size-4" />} loading={pending === "logout"}>
退出登录
</Button>
</Popconfirm>
) : null}
</div>
</div>
</section>
</div>
);
}
function runtimePresentation(connection: LocalRuntimeConnectionState): Presentation {
if (connection === "connected") return { label: "已连接", tone: "success", action: "refresh" };
if (connection === "connecting") return { label: "正在检测", tone: "processing", action: null };
if (connection === "origin_not_trusted") return { label: "需要重新连接", tone: "error", action: "refresh", actionLabel: LOCAL_CLI_SETTINGS_COPY.runtimeReconnect };
if (connection === "unreachable") return { label: "未发现", tone: "error", action: "refresh" };
if (connection === "incompatible") return { label: "版本不兼容", tone: "error", action: "refresh" };
if (connection === "runtime_error") return { label: "运行时错误", tone: "error", action: "refresh" };
return { label: "尚未检测", tone: "default", action: "refresh" };
}
function dreaminaEmptyMessage(connection: LocalRuntimeConnectionState, moduleAvailable: boolean) {
if (connection !== "connected") return LOCAL_CLI_SETTINGS_COPY.dreaminaDisconnectedMessage;
if (!moduleAvailable) return "当前 Runtime 未加载 Dreamina 模块,请更新并重启 Runtime。";
return "正在检测 Dreamina CLI…";
}