feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道
Refs #225 Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
285
web/src/pages/settings/local-cli-settings.tsx
Normal file
285
web/src/pages/settings/local-cli-settings.tsx
Normal 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…";
|
||||
}
|
||||
Reference in New Issue
Block a user