Commit c591100b authored by edy's avatar edy

feat(settings): improve secret reveal and config copying

parent c9ab97c3
......@@ -8,6 +8,7 @@ import {
type ChatAttachment,
type ChatMessage,
type ChatStreamEvent,
type ConfigSecretId,
type DesktopApi,
type GatewayStatus,
type PluginSummary,
......@@ -669,6 +670,62 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return applySecretStateToConfig(await configService.load());
};
const revealConfigSecret = async (secretId: ConfigSecretId): Promise<string | null> => {
let value: string | undefined;
switch (secretId) {
case "lobsterKey":
value = await secretManager.getApiKey();
break;
case "copywritingModelApiKey":
value = await secretManager.getCopywritingModelApiKey();
break;
case "imageModelApiKey":
value = await secretManager.getImageModelApiKey();
break;
case "videoModelApiKey":
value = await secretManager.getVideoModelApiKey();
break;
case "digitalHumanVolcAccessKey":
value = await secretManager.getDigitalHumanVolcAccessKey();
break;
case "digitalHumanVolcSecretKey":
value = await secretManager.getDigitalHumanVolcSecretKey();
break;
case "digitalHumanQiniuAccessKey":
value = await secretManager.getDigitalHumanQiniuAccessKey();
break;
case "digitalHumanQiniuSecretKey":
value = await secretManager.getDigitalHumanQiniuSecretKey();
break;
case "videoAnalyzerApiKey":
value = await secretManager.getVideoAnalyzerApiKey();
break;
case "replicationBriefApiKey":
value = await secretManager.getReplicationBriefApiKey();
break;
case "vectcutApiKey":
value = await secretManager.getVectCutApiKey();
break;
case "xhsFeishuAppId":
value = await secretManager.getXhsFeishuAppId();
break;
case "xhsFeishuAppSecret":
value = await secretManager.getXhsFeishuAppSecret();
break;
case "xhsFeishuAppToken":
value = await secretManager.getXhsFeishuAppToken();
break;
case "xhsFeishuTableId":
value = await secretManager.getXhsFeishuTableId();
break;
default:
throw new Error("Unsupported config secret id.");
}
return value ? value : null;
};
const prepareProjectModelRuntime = async (projectId: string, projectRoot: string): Promise<Record<string, string>> => {
const config = await configService.load();
const [
......@@ -2498,6 +2555,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return pickWorkspaceDirectory(BrowserWindow.fromWebContents(event.sender), currentPath);
});
ipcMain.handle(IPC_CHANNELS.configSave, async (_event, input: SaveConfigInput) => saveAppConfig(input));
ipcMain.handle(IPC_CHANNELS.configRevealSecret, async (_event, secretId: ConfigSecretId) => revealConfigSecret(secretId));
ipcMain.handle(IPC_CHANNELS.authGetSession, async () => authClient.getSessionSummary());
ipcMain.handle(IPC_CHANNELS.authSignIn, async (_event, input: SignInInput) => {
......@@ -2638,7 +2696,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
config: {
load: () => getEffectiveConfig(),
pickWorkspaceDirectory: (currentPath?: string) => pickWorkspaceDirectory(null, currentPath),
save: (input: SaveConfigInput) => saveAppConfig(input)
save: (input: SaveConfigInput) => saveAppConfig(input),
revealSecret: (secretId: ConfigSecretId) => revealConfigSecret(secretId)
},
projects: {
list: () => projectStore.listProjects(),
......
......@@ -3,6 +3,7 @@ import {
IPC_CHANNELS,
type ChatAttachment,
type ChatStreamListener,
type ConfigSecretId,
type DesktopApi,
type RuntimeCloudFetchAction,
type SaveConfigInput,
......@@ -45,7 +46,8 @@ const desktopApi: DesktopApi = {
config: {
load: () => ipcRenderer.invoke(IPC_CHANNELS.configLoad),
pickWorkspaceDirectory: (currentPath?: string) => ipcRenderer.invoke(IPC_CHANNELS.configPickWorkspaceDirectory, currentPath),
save: (input: SaveConfigInput) => ipcRenderer.invoke(IPC_CHANNELS.configSave, input)
save: (input: SaveConfigInput) => ipcRenderer.invoke(IPC_CHANNELS.configSave, input),
revealSecret: (secretId: ConfigSecretId) => ipcRenderer.invoke(IPC_CHANNELS.configRevealSecret, secretId)
},
projects: {
list: () => ipcRenderer.invoke(IPC_CHANNELS.projectsList),
......
......@@ -6,6 +6,7 @@ import type {
ChatAttachment,
ChatLaunchState,
ChatMessage,
ConfigSecretId,
ExpertEntryMode,
GatewayHealth,
GatewayStatus,
......@@ -1385,6 +1386,19 @@ export default function App() {
const settingsStatusHint = showSettingsStatusHint
? <div className={"inline-hint settings-runtime-hint" + (chatLaunchState === "error" ? " error" : "")}>{startupMessage}</div>
: null;
const revealConfigSecret = useCallback(async (secretId: ConfigSecretId) => {
setErrorText("");
try {
const secretValue = await desktopApi.config.revealSecret(secretId);
if (!secretValue) {
setErrorText("本机未保存该密钥。");
}
return secretValue;
} catch (error) {
setErrorText(err(error));
return null;
}
}, []);
const settingsPanelsProps = {
config,
workspaceApiKeyConfigured: Boolean(workspace?.apiKeyConfigured),
......@@ -1458,6 +1472,7 @@ export default function App() {
onResetDigitalHumanConfig: resetDigitalHumanSettingsDrafts,
onSaveDouyinRuntimeConfig: () => void saveDouyinRuntimeConfig(),
onResetDouyinRuntimeConfig: resetDouyinRuntimeSettingsDrafts,
onRevealSecret: revealConfigSecret,
pickWorkspaceDirectory,
exportDiagnostics
} satisfies ComponentProps<typeof SettingsPanels>;
......
import { useState } from "react"
import type { AppConfig } from "@qjclaw/shared-types"
import { useState, type KeyboardEvent } from "react"
import type { AppConfig, ConfigSecretId } from "@qjclaw/shared-types"
import { Tabs, type TabItem } from "../../components/ui/Tabs"
type SetDraft = (value: string) => void
type SettingsActionHandler = () => void | Promise<void>
type SecretRevealState = Partial<Record<ConfigSecretId, boolean>>
type SecretValueState = Partial<Record<ConfigSecretId, string>>
interface SettingsDrafts {
lobsterKey: string
......@@ -84,6 +86,7 @@ interface SettingsPanelsProps {
onResetDigitalHumanConfig: SettingsActionHandler
onSaveDouyinRuntimeConfig: SettingsActionHandler
onResetDouyinRuntimeConfig: SettingsActionHandler
onRevealSecret: (secretId: ConfigSecretId) => Promise<string | null>
onConfigSourceChange?: (source: SettingsConfigSource) => void | Promise<void>
loadCloudConfig?: () => void | Promise<void>
pickWorkspaceDirectory: () => void | Promise<void>
......@@ -110,6 +113,16 @@ const settingsTabs: TabItem[] = [
{ id: "douyinRuntime", label: "抖音运行时" }
]
function SecretVisibilityIcon({ visible }: { visible: boolean }) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2.8 12s3.4-5.8 9.2-5.8 9.2 5.8 9.2 5.8-3.4 5.8-9.2 5.8S2.8 12 2.8 12Z" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12 9.1a2.9 2.9 0 1 1 0 5.8 2.9 2.9 0 0 1 0-5.8Z" fill="none" stroke="currentColor" strokeWidth="1.7" />
{visible ? <path d="m4.5 19.5 15-15" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /> : null}
</svg>
)
}
export function SettingsPanels({
config,
workspaceApiKeyConfigured,
......@@ -139,6 +152,7 @@ export function SettingsPanels({
onResetDigitalHumanConfig,
onSaveDouyinRuntimeConfig,
onResetDouyinRuntimeConfig,
onRevealSecret,
onConfigSourceChange,
loadCloudConfig,
pickWorkspaceDirectory,
......@@ -146,6 +160,9 @@ export function SettingsPanels({
}: SettingsPanelsProps) {
const [activeTab, setActiveTab] = useState<SettingsTabId>("basic")
const [configSource, setConfigSource] = useState<SettingsConfigSource>("local")
const [revealedSecrets, setRevealedSecrets] = useState<SecretRevealState>({})
const [revealedSecretValues, setRevealedSecretValues] = useState<SecretValueState>({})
const [copiedConfigValue, setCopiedConfigValue] = useState<string | null>(null)
const handleConfigSourceChange = (source: SettingsConfigSource) => {
setConfigSource(source)
......@@ -176,6 +193,122 @@ export function SettingsPanels({
</div>
)
const handleSecretRevealToggle = async (secretId: ConfigSecretId, value: string, setValue: SetDraft) => {
if (revealedSecrets[secretId]) {
setRevealedSecrets((current) => ({ ...current, [secretId]: false }))
if (revealedSecretValues[secretId] && value === revealedSecretValues[secretId]) {
setValue("")
}
return
}
const secretValue = await onRevealSecret(secretId)
if (!secretValue) {
return
}
setValue(secretValue)
setRevealedSecretValues((current) => ({ ...current, [secretId]: secretValue }))
setRevealedSecrets((current) => ({ ...current, [secretId]: true }))
}
const renderSecretInput = ({
secretId,
configured,
label,
value,
setValue,
placeholder,
inputClassName,
labelClassName
}: {
secretId: ConfigSecretId
configured: boolean
label?: string
value: string
setValue: SetDraft
placeholder: string
inputClassName?: string
labelClassName?: string
}) => {
const visible = configured && Boolean(revealedSecrets[secretId])
const secretPlaceholder = configured && !value && !visible ? "••••••••••••" : placeholder
return (
<label className={["settings-input-label", labelClassName].filter(Boolean).join(" ")}>
{label ? <span className="settings-input-label-text">{label}</span> : null}
<div className={["settings-secret-input-row", configured ? "has-reveal" : ""].filter(Boolean).join(" ")}>
<input
className={["settings-secret-value-input", inputClassName].filter(Boolean).join(" ")}
type={visible ? "text" : "password"}
value={value}
title={value || undefined}
placeholder={secretPlaceholder}
onChange={(event) => setValue(event.target.value)}
/>
{configured ? (
<button
type="button"
className="settings-secret-reveal-button settings-secret-reveal-button-adornment"
aria-label={visible ? "隐藏密钥" : "显示密钥"}
title={visible ? "隐藏密钥" : "显示密钥"}
disabled={saving}
onClick={() => void handleSecretRevealToggle(secretId, value, setValue)}
>
<SecretVisibilityIcon visible={visible} />
</button>
) : null}
</div>
</label>
)
}
const copyReadonlyConfigValue = (value?: string) => {
if (!value) {
return
}
setCopiedConfigValue(value)
window.setTimeout(() => {
setCopiedConfigValue((current) => current === value ? null : current)
}, 1200)
if (navigator.clipboard) {
void navigator.clipboard.writeText(value).catch(() => undefined)
}
}
const handleReadonlyConfigKeyDown = (event: KeyboardEvent<HTMLDivElement>, value?: string) => {
if (event.key !== "Enter" && event.key !== " ") {
return
}
event.preventDefault()
copyReadonlyConfigValue(value)
}
const renderReadonlyConfigValue = (label: string, value?: string) => {
const copied = Boolean(value && copiedConfigValue === value)
return (
<div className="settings-input-label settings-readonly-config-field">
<span className="settings-input-label-text">{label}</span>
<div
className={["settings-readonly-config-value", copied ? "is-copied" : ""].filter(Boolean).join(" ")}
role="button"
tabIndex={value ? 0 : -1}
aria-disabled={!value}
aria-label={value ? `${label} ${copied ? "已复制" : "复制"}` : label}
title={copied ? "✅已复制" : value || undefined}
onClick={() => copyReadonlyConfigValue(value)}
onKeyDown={(event) => handleReadonlyConfigKeyDown(event, value)}
>
<span className="settings-readonly-config-text">{value || "-"}</span>
{copied ? <span className="settings-readonly-config-copy-feedback">✅已复制</span> : null}
</div>
</div>
)
}
return (
<div className="settings-tabs-layout">
<div className="settings-tabs-toolbar">
......@@ -213,17 +346,16 @@ export function SettingsPanels({
<div className="settings-section-card settings-section-card-compact settings-basic-config-card">
<div className="settings-basic-config-form">
<div className="settings-basic-config-row settings-basic-config-row-key">
<label className="settings-input-label settings-basic-config-field">
<span className="settings-input-label-text">龙虾密钥</span>
<input
className="settings-truncated-input"
type="password"
value={drafts.lobsterKey}
title={drafts.lobsterKey || undefined}
placeholder={workspaceApiKeyConfigured ? "输入新的龙虾密钥或更新绑定" : "请输入龙虾密钥"}
onChange={(event) => setters.setLobsterKey(event.target.value)}
/>
</label>
{renderSecretInput({
secretId: "lobsterKey",
configured: workspaceApiKeyConfigured,
label: "龙虾密钥",
value: drafts.lobsterKey,
setValue: setters.setLobsterKey,
inputClassName: "settings-truncated-input",
labelClassName: "settings-basic-config-field",
placeholder: workspaceApiKeyConfigured ? "输入新的龙虾密钥或更新绑定" : "请输入龙虾密钥"
})}
<div className="settings-actions-row settings-actions-row-inline-save">
<button
type="button"
......@@ -279,22 +411,38 @@ export function SettingsPanels({
<div className="settings-section-card settings-section-card-compact-body-actions">
<div className="settings-xhs-feishu-form">
<div className="settings-field-grid settings-field-grid-xhs-feishu">
<label className="settings-input-label">
<span className="settings-input-label-text">FEISHU_APP_ID</span>
<input type="password" value={drafts.xhsFeishuAppId} placeholder={config?.xhsFeishuConfig.appIdConfigured ? "留空则保持当前已保存密钥" : "请输入 App ID"} onChange={(event) => setters.setXhsFeishuAppId(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">FEISHU_APP_SECRET</span>
<input type="password" value={drafts.xhsFeishuAppSecret} placeholder={config?.xhsFeishuConfig.appSecretConfigured ? "留空则保持当前已保存密钥" : "请输入 App Secret"} onChange={(event) => setters.setXhsFeishuAppSecret(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">FEISHU_APP_TOKEN</span>
<input type="password" value={drafts.xhsFeishuAppToken} placeholder={config?.xhsFeishuConfig.appTokenConfigured ? "留空则保持当前已保存密钥" : "请输入 App Token"} onChange={(event) => setters.setXhsFeishuAppToken(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">FEISHU_TABLE_ID</span>
<input type="password" value={drafts.xhsFeishuTableId} placeholder={config?.xhsFeishuConfig.tableIdConfigured ? "留空则保持当前已保存密钥" : "请输入 Table ID"} onChange={(event) => setters.setXhsFeishuTableId(event.target.value)} />
</label>
{renderSecretInput({
secretId: "xhsFeishuAppId",
configured: Boolean(config?.xhsFeishuConfig.appIdConfigured),
label: "FEISHU_APP_ID",
value: drafts.xhsFeishuAppId,
setValue: setters.setXhsFeishuAppId,
placeholder: config?.xhsFeishuConfig.appIdConfigured ? "留空则保持当前已保存密钥" : "请输入 App ID"
})}
{renderSecretInput({
secretId: "xhsFeishuAppSecret",
configured: Boolean(config?.xhsFeishuConfig.appSecretConfigured),
label: "FEISHU_APP_SECRET",
value: drafts.xhsFeishuAppSecret,
setValue: setters.setXhsFeishuAppSecret,
placeholder: config?.xhsFeishuConfig.appSecretConfigured ? "留空则保持当前已保存密钥" : "请输入 App Secret"
})}
{renderSecretInput({
secretId: "xhsFeishuAppToken",
configured: Boolean(config?.xhsFeishuConfig.appTokenConfigured),
label: "FEISHU_APP_TOKEN",
value: drafts.xhsFeishuAppToken,
setValue: setters.setXhsFeishuAppToken,
placeholder: config?.xhsFeishuConfig.appTokenConfigured ? "留空则保持当前已保存密钥" : "请输入 App Token"
})}
{renderSecretInput({
secretId: "xhsFeishuTableId",
configured: Boolean(config?.xhsFeishuConfig.tableIdConfigured),
label: "FEISHU_TABLE_ID",
value: drafts.xhsFeishuTableId,
setValue: setters.setXhsFeishuTableId,
placeholder: config?.xhsFeishuConfig.tableIdConfigured ? "留空则保持当前已保存密钥" : "请输入 Table ID"
})}
</div>
{renderActions({
hasPending: hasPendingXhsFeishuConfig,
......@@ -318,10 +466,17 @@ export function SettingsPanels({
</div>
</div>
<div className="model-config-card-body">
<div className="settings-field-grid single">
<label className="settings-input-label">
<input type="password" value={drafts.copywritingModelApiKey} placeholder={config?.expertModelConfig.copywriting.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入文案模型 API Key"} onChange={(event) => setters.setCopywritingModelApiKey(event.target.value)} />
</label>
<div className="settings-field-grid">
{renderReadonlyConfigValue("base_url", config?.expertModelConfig.copywriting.baseUrl)}
{renderReadonlyConfigValue("model_id", config?.expertModelConfig.copywriting.modelId)}
{renderSecretInput({
secretId: "copywritingModelApiKey",
configured: Boolean(config?.expertModelConfig.copywriting.apiKeyConfigured),
label: "api_key",
value: drafts.copywritingModelApiKey,
setValue: setters.setCopywritingModelApiKey,
placeholder: config?.expertModelConfig.copywriting.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入文案模型 API Key"
})}
</div>
</div>
{renderActions({
......@@ -346,10 +501,17 @@ export function SettingsPanels({
</div>
</div>
<div className="model-config-card-body">
<div className="settings-field-grid single">
<label className="settings-input-label">
<input type="password" value={drafts.imageModelApiKey} placeholder={config?.expertModelConfig.image.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入生图模型 API Key"} onChange={(event) => setters.setImageModelApiKey(event.target.value)} />
</label>
<div className="settings-field-grid">
{renderReadonlyConfigValue("base_url", config?.expertModelConfig.image.baseUrl)}
{renderReadonlyConfigValue("model_id", config?.expertModelConfig.image.modelId)}
{renderSecretInput({
secretId: "imageModelApiKey",
configured: Boolean(config?.expertModelConfig.image.apiKeyConfigured),
label: "api_key",
value: drafts.imageModelApiKey,
setValue: setters.setImageModelApiKey,
placeholder: config?.expertModelConfig.image.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入生图模型 API Key"
})}
</div>
</div>
{renderActions({
......@@ -374,10 +536,17 @@ export function SettingsPanels({
</div>
</div>
<div className="model-config-card-body">
<div className="settings-field-grid single">
<label className="settings-input-label">
<input type="password" value={drafts.videoModelApiKey} placeholder={config?.expertModelConfig.video.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入视频模型 API Key"} onChange={(event) => setters.setVideoModelApiKey(event.target.value)} />
</label>
<div className="settings-field-grid">
{renderReadonlyConfigValue("base_url", config?.expertModelConfig.video.baseUrl)}
{renderReadonlyConfigValue("model_id", config?.expertModelConfig.video.modelId)}
{renderSecretInput({
secretId: "videoModelApiKey",
configured: Boolean(config?.expertModelConfig.video.apiKeyConfigured),
label: "api_key",
value: drafts.videoModelApiKey,
setValue: setters.setVideoModelApiKey,
placeholder: config?.expertModelConfig.video.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入视频模型 API Key"
})}
</div>
</div>
{renderActions({
......@@ -403,22 +572,38 @@ export function SettingsPanels({
</div>
<div className="model-config-card-body model-config-card-body-digital-human">
<div className="settings-field-grid settings-field-grid-digital-human">
<label className="settings-input-label">
<span className="settings-input-label-text">VOLC_ACCESS_KEY</span>
<input type="password" value={drafts.digitalHumanVolcAccessKey} placeholder={config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 ACCESS_KEY"} onChange={(event) => setters.setDigitalHumanVolcAccessKey(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">VOLC_SECRET_KEY</span>
<input type="password" value={drafts.digitalHumanVolcSecretKey} placeholder={config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 SECRET_KEY"} onChange={(event) => setters.setDigitalHumanVolcSecretKey(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">QINIU_ACCESS_KEY</span>
<input type="password" value={drafts.digitalHumanQiniuAccessKey} placeholder={config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 ACCESS_KEY"} onChange={(event) => setters.setDigitalHumanQiniuAccessKey(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">QINIU_SECRET_KEY</span>
<input type="password" value={drafts.digitalHumanQiniuSecretKey} placeholder={config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 SECRET_KEY"} onChange={(event) => setters.setDigitalHumanQiniuSecretKey(event.target.value)} />
</label>
{renderSecretInput({
secretId: "digitalHumanVolcAccessKey",
configured: Boolean(config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured),
label: "VOLC_ACCESS_KEY",
value: drafts.digitalHumanVolcAccessKey,
setValue: setters.setDigitalHumanVolcAccessKey,
placeholder: config?.expertModelConfig.digitalHuman.volcAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 ACCESS_KEY"
})}
{renderSecretInput({
secretId: "digitalHumanVolcSecretKey",
configured: Boolean(config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured),
label: "VOLC_SECRET_KEY",
value: drafts.digitalHumanVolcSecretKey,
setValue: setters.setDigitalHumanVolcSecretKey,
placeholder: config?.expertModelConfig.digitalHuman.volcSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入火山 SECRET_KEY"
})}
{renderSecretInput({
secretId: "digitalHumanQiniuAccessKey",
configured: Boolean(config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured),
label: "QINIU_ACCESS_KEY",
value: drafts.digitalHumanQiniuAccessKey,
setValue: setters.setDigitalHumanQiniuAccessKey,
placeholder: config?.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 ACCESS_KEY"
})}
{renderSecretInput({
secretId: "digitalHumanQiniuSecretKey",
configured: Boolean(config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured),
label: "QINIU_SECRET_KEY",
value: drafts.digitalHumanQiniuSecretKey,
setValue: setters.setDigitalHumanQiniuSecretKey,
placeholder: config?.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured ? "留空则保持当前已保存密钥" : "请输入七牛 SECRET_KEY"
})}
</div>
</div>
{renderActions({
......@@ -444,18 +629,16 @@ export function SettingsPanels({
</div>
<div className="model-config-card-body">
<div className="settings-field-grid">
<label className="settings-input-label">
<span className="settings-input-label-text">base_url</span>
<input value={drafts.videoAnalyzerBaseUrl} placeholder="https://ark.cn-beijing.volces.com/api/v3" onChange={(event) => setters.setVideoAnalyzerBaseUrl(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">model_id</span>
<input value={drafts.videoAnalyzerModelId} placeholder="doubao-vision" onChange={(event) => setters.setVideoAnalyzerModelId(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">api_key</span>
<input type="password" value={drafts.videoAnalyzerApiKey} placeholder={config?.douyinRuntimeConfig.videoAnalyzer.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入 Video Analyzer API Key"} onChange={(event) => setters.setVideoAnalyzerApiKey(event.target.value)} />
</label>
{renderReadonlyConfigValue("base_url", config?.douyinRuntimeConfig.videoAnalyzer.baseUrl)}
{renderReadonlyConfigValue("model_id", config?.douyinRuntimeConfig.videoAnalyzer.modelId || "doubao-vision")}
{renderSecretInput({
secretId: "videoAnalyzerApiKey",
configured: Boolean(config?.douyinRuntimeConfig.videoAnalyzer.apiKeyConfigured),
label: "api_key",
value: drafts.videoAnalyzerApiKey,
setValue: setters.setVideoAnalyzerApiKey,
placeholder: config?.douyinRuntimeConfig.videoAnalyzer.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入 Video Analyzer API Key"
})}
</div>
</div>
</article>
......@@ -467,18 +650,16 @@ export function SettingsPanels({
</div>
<div className="model-config-card-body">
<div className="settings-field-grid">
<label className="settings-input-label">
<span className="settings-input-label-text">base_url</span>
<input value={drafts.replicationBriefBaseUrl} placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1" onChange={(event) => setters.setReplicationBriefBaseUrl(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">model_id</span>
<input value={drafts.replicationBriefModelId} placeholder="qwen-max" onChange={(event) => setters.setReplicationBriefModelId(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">api_key</span>
<input type="password" value={drafts.replicationBriefApiKey} placeholder={config?.douyinRuntimeConfig.replicationBrief.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入 Replication Brief API Key"} onChange={(event) => setters.setReplicationBriefApiKey(event.target.value)} />
</label>
{renderReadonlyConfigValue("base_url", config?.douyinRuntimeConfig.replicationBrief.baseUrl)}
{renderReadonlyConfigValue("model_id", config?.douyinRuntimeConfig.replicationBrief.modelId || "qwen-max")}
{renderSecretInput({
secretId: "replicationBriefApiKey",
configured: Boolean(config?.douyinRuntimeConfig.replicationBrief.apiKeyConfigured),
label: "api_key",
value: drafts.replicationBriefApiKey,
setValue: setters.setReplicationBriefApiKey,
placeholder: config?.douyinRuntimeConfig.replicationBrief.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入 Replication Brief API Key"
})}
</div>
</div>
</article>
......@@ -490,18 +671,16 @@ export function SettingsPanels({
</div>
<div className="model-config-card-body">
<div className="settings-field-grid">
<label className="settings-input-label">
<span className="settings-input-label-text">base_url</span>
<input value={drafts.vectcutBaseUrl} placeholder="https://open.vectcut.com/cut_jianying" onChange={(event) => setters.setVectcutBaseUrl(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">file_base_url</span>
<input value={drafts.vectcutFileBaseUrl} placeholder="https://open.vectcut.com" onChange={(event) => setters.setVectcutFileBaseUrl(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">api_key</span>
<input type="password" value={drafts.vectcutApiKey} placeholder={config?.douyinRuntimeConfig.vectcut.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入 VectCut API Key"} onChange={(event) => setters.setVectcutApiKey(event.target.value)} />
</label>
{renderReadonlyConfigValue("base_url", config?.douyinRuntimeConfig.vectcut.baseUrl)}
{renderReadonlyConfigValue("file_base_url", config?.douyinRuntimeConfig.vectcut.fileBaseUrl)}
{renderSecretInput({
secretId: "vectcutApiKey",
configured: Boolean(config?.douyinRuntimeConfig.vectcut.apiKeyConfigured),
label: "api_key",
value: drafts.vectcutApiKey,
setValue: setters.setVectcutApiKey,
placeholder: config?.douyinRuntimeConfig.vectcut.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入 VectCut API Key"
})}
</div>
</div>
</article>
......
......@@ -290,6 +290,7 @@ export const mockDesktopApi = {
}
}),
pickWorkspaceDirectory: async (currentPath?: string) => currentPath || "D:/workspace",
revealSecret: async (secretId: string) => `mock-${secretId}`,
save: async (input: SaveConfigInput) => ({
setupMode: input.setupMode,
provider: input.provider,
......
......@@ -367,6 +367,153 @@
box-shadow: var(--settings-focus-shadow);
}
.settings-secret-input-row {
position: relative;
min-width: 0;
}
.settings-secret-input-row input {
width: 100%;
min-width: 0;
}
.settings-secret-input-row.has-reveal input {
padding-right: calc(var(--settings-control-height) + 8px);
}
.settings-secret-value-input {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-secret-reveal-button {
position: absolute;
top: 50%;
right: 4px;
width: calc(var(--settings-control-height) - 8px);
height: calc(var(--settings-control-height) - 8px);
display: inline-flex;
align-items: center;
justify-content: center;
transform: translateY(-50%);
border: 0;
border-radius: 6px;
padding: 0;
z-index: 1;
background: transparent;
color: #60729b;
box-shadow: none;
cursor: pointer;
}
.shell.openclaw-theme .settings-secret-reveal-button {
transform: translateY(-50%);
box-shadow: none;
}
.settings-secret-reveal-button:hover:not(:disabled) {
color: #1f3f6d;
background: rgba(31, 63, 109, 0.08);
transform: translateY(-50%);
box-shadow: none;
}
.shell.openclaw-theme .settings-secret-reveal-button:hover:not(:disabled) {
transform: translateY(-50%);
box-shadow: none;
}
.settings-secret-reveal-button:focus-visible,
.settings-secret-reveal-button:active {
outline: none;
transform: translateY(-50%);
box-shadow: none;
}
.shell.openclaw-theme .settings-secret-reveal-button:focus-visible,
.shell.openclaw-theme .settings-secret-reveal-button:active {
transform: translateY(-50%);
box-shadow: none;
}
.settings-secret-reveal-button:disabled {
cursor: default;
opacity: 0.58;
}
.settings-secret-reveal-button svg {
width: 22px;
height: 22px;
flex: 0 0 auto;
}
.settings-readonly-config-field {
min-width: 0;
}
.settings-readonly-config-value {
min-height: var(--settings-control-height);
height: var(--settings-control-height);
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 var(--settings-control-padding-x);
border: 1px solid var(--settings-control-border);
border-radius: var(--settings-control-radius);
background: rgba(246, 249, 255, 0.82);
color: #405679;
font-size: 13px;
line-height: var(--settings-control-height);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.settings-readonly-config-text {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-readonly-config-copy-feedback {
flex: 0 0 auto;
min-width: 42px;
padding: 0 7px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.1);
color: #1d4ed8;
font-size: 11px;
font-weight: 700;
line-height: 22px;
text-align: center;
}
.settings-readonly-config-value[role="button"] {
cursor: copy;
}
.settings-readonly-config-value[aria-disabled="true"] {
cursor: default;
}
.settings-readonly-config-value[role="button"]:hover:not([aria-disabled="true"]) {
border-color: var(--settings-control-border-strong);
color: #1f3f6d;
background: rgba(255, 255, 255, 0.92);
}
.settings-readonly-config-value:focus-visible {
outline: 2px solid var(--settings-focus-outline);
outline-offset: 0;
border-color: var(--settings-control-border-strong);
box-shadow: var(--settings-focus-shadow);
}
.model-config-grid {
display: grid;
min-height: 0;
......
import test from "node:test"
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
const sharedTypesSource = readFileSync(new URL("../../../packages/shared-types/src/index.ts", import.meta.url), "utf8")
const preloadSource = readFileSync(new URL("../../desktop/src/preload/index.ts", import.meta.url), "utf8")
const ipcSource = readFileSync(new URL("../../desktop/src/main/ipc.ts", import.meta.url), "utf8")
test("config reveal secret IPC is whitelisted in shared types, preload, and main IPC", () => {
assert.match(sharedTypesSource, /configRevealSecret:\s*"config:reveal-secret"/)
assert.match(sharedTypesSource, /export type ConfigSecretId =/)
assert.match(sharedTypesSource, /revealSecret\(secretId: ConfigSecretId\): Promise<string \| null>/)
assert.match(preloadSource, /type ConfigSecretId/)
assert.match(preloadSource, /revealSecret: \(secretId: ConfigSecretId\) => ipcRenderer\.invoke\(IPC_CHANNELS\.configRevealSecret, secretId\)/)
assert.match(ipcSource, /const revealConfigSecret = async \(secretId: ConfigSecretId\): Promise<string \| null> =>/)
assert.match(ipcSource, /ipcMain\.handle\(IPC_CHANNELS\.configRevealSecret/)
assert.match(ipcSource, /revealSecret: \(secretId: ConfigSecretId\) => revealConfigSecret\(secretId\)/)
})
......@@ -65,3 +65,80 @@ test("settings panel actions are wired per section instead of global handlers",
assert.match(settingsPanelsSource, /\bonSaveDigitalHumanConfig\b/)
assert.match(settingsPanelsSource, /\bonSaveDouyinRuntimeConfig\b/)
})
test("settings secret inputs default hidden and gate reveal buttons behind saved secrets", () => {
assert.match(settingsPanelsSource, /type=\{visible \? "text" : "password"\}/)
assert.match(settingsPanelsSource, /configured:\s*boolean/)
assert.match(settingsPanelsSource, /const visible = configured && Boolean\(revealedSecrets\[secretId\]\)/)
assert.match(settingsPanelsSource, /const secretPlaceholder = configured && !value && !visible \? "••••••••••••" : placeholder/)
assert.match(settingsPanelsSource, /placeholder=\{secretPlaceholder\}/)
assert.match(settingsPanelsSource, /configured \? \(/)
assert.match(settingsPanelsSource, /secretId: "lobsterKey"/)
assert.match(settingsPanelsSource, /configured: workspaceApiKeyConfigured/)
assert.match(settingsPanelsSource, /secretId: "copywritingModelApiKey"/)
assert.match(settingsPanelsSource, /configured: Boolean\(config\?\.expertModelConfig\.copywriting\.apiKeyConfigured\)/)
assert.match(settingsPanelsSource, /secretId: "imageModelApiKey"/)
assert.match(settingsPanelsSource, /secretId: "videoModelApiKey"/)
assert.match(settingsPanelsSource, /secretId: "videoAnalyzerApiKey"/)
assert.match(settingsPanelsSource, /secretId: "replicationBriefApiKey"/)
assert.match(settingsPanelsSource, /secretId: "vectcutApiKey"/)
assert.match(settingsPanelsSource, /"settings-secret-input-row", configured \? "has-reveal" : ""/)
assert.match(settingsPanelsSource, /\["settings-secret-value-input", inputClassName\]\.filter\(Boolean\)\.join\(" "\)/)
assert.match(settingsPanelsSource, /className="settings-secret-reveal-button settings-secret-reveal-button-adornment"/)
assert.match(settingsPanelsSource, /onRevealSecret\(secretId\)/)
assert.match(settingsStylesSource, /\.settings-secret-input-row\s*\{[\s\S]*?position:\s*relative;/m)
assert.match(settingsStylesSource, /\.settings-secret-input-row\.has-reveal input\s*\{[\s\S]*?padding-right:\s*calc\(var\(--settings-control-height\) \+ 8px\);/m)
assert.match(settingsStylesSource, /\.settings-secret-value-input\s*\{[\s\S]*?overflow:\s*hidden;[\s\S]*?text-overflow:\s*ellipsis;[\s\S]*?white-space:\s*nowrap;/m)
assert.match(settingsStylesSource, /\.settings-secret-reveal-button\s*\{[\s\S]*?position:\s*absolute;[\s\S]*?right:\s*4px;/m)
assert.match(settingsStylesSource, /\.settings-secret-reveal-button\s*\{[\s\S]*?padding:\s*0;[\s\S]*?z-index:\s*1;/m)
assert.match(settingsStylesSource, /\.shell\.openclaw-theme \.settings-secret-reveal-button\s*\{[\s\S]*?transform:\s*translateY\(-50%\);/m)
assert.match(settingsStylesSource, /\.settings-secret-reveal-button:hover:not\(:disabled\)\s*\{[\s\S]*?transform:\s*translateY\(-50%\);[\s\S]*?box-shadow:\s*none;/m)
assert.match(settingsStylesSource, /\.shell\.openclaw-theme \.settings-secret-reveal-button:hover:not\(:disabled\)\s*\{[\s\S]*?transform:\s*translateY\(-50%\);[\s\S]*?box-shadow:\s*none;/m)
assert.match(settingsStylesSource, /\.settings-secret-reveal-button:focus-visible,\s*\n\.settings-secret-reveal-button:active\s*\{[\s\S]*?transform:\s*translateY\(-50%\);/m)
assert.match(settingsStylesSource, /\.settings-secret-reveal-button svg\s*\{[\s\S]*?width:\s*22px;[\s\S]*?height:\s*22px;/m)
})
test("fixed expert model cards show base_url and model_id as read-only values", () => {
assert.match(settingsPanelsSource, /config\?\.expertModelConfig\.copywriting\.baseUrl/)
assert.match(settingsPanelsSource, /config\?\.expertModelConfig\.copywriting\.modelId/)
assert.match(settingsPanelsSource, /config\?\.expertModelConfig\.image\.baseUrl/)
assert.match(settingsPanelsSource, /config\?\.expertModelConfig\.image\.modelId/)
assert.match(settingsPanelsSource, /config\?\.expertModelConfig\.video\.baseUrl/)
assert.match(settingsPanelsSource, /config\?\.expertModelConfig\.video\.modelId/)
assert.match(settingsPanelsSource, /settings-readonly-config-value/)
})
test("douyin runtime base urls, model ids, and file base url use copyable read-only values", () => {
assert.match(settingsPanelsSource, /renderReadonlyConfigValue\("base_url", config\?\.douyinRuntimeConfig\.videoAnalyzer\.baseUrl\)/)
assert.match(settingsPanelsSource, /renderReadonlyConfigValue\("model_id", config\?\.douyinRuntimeConfig\.videoAnalyzer\.modelId \|\| "doubao-vision"\)/)
assert.match(settingsPanelsSource, /renderReadonlyConfigValue\("base_url", config\?\.douyinRuntimeConfig\.replicationBrief\.baseUrl\)/)
assert.match(settingsPanelsSource, /renderReadonlyConfigValue\("model_id", config\?\.douyinRuntimeConfig\.replicationBrief\.modelId \|\| "qwen-max"\)/)
assert.match(settingsPanelsSource, /renderReadonlyConfigValue\("base_url", config\?\.douyinRuntimeConfig\.vectcut\.baseUrl\)/)
assert.match(settingsPanelsSource, /renderReadonlyConfigValue\("file_base_url", config\?\.douyinRuntimeConfig\.vectcut\.fileBaseUrl\)/)
assert.doesNotMatch(settingsPanelsSource, /setVideoAnalyzerBaseUrl\(event\.target\.value\)/)
assert.doesNotMatch(settingsPanelsSource, /setVideoAnalyzerModelId\(event\.target\.value\)/)
assert.doesNotMatch(settingsPanelsSource, /setReplicationBriefBaseUrl\(event\.target\.value\)/)
assert.doesNotMatch(settingsPanelsSource, /setReplicationBriefModelId\(event\.target\.value\)/)
assert.doesNotMatch(settingsPanelsSource, /setVectcutBaseUrl\(event\.target\.value\)/)
assert.doesNotMatch(settingsPanelsSource, /setVectcutFileBaseUrl\(event\.target\.value\)/)
})
test("read-only config values expose full values by title and copy on click or keyboard", () => {
assert.match(settingsPanelsSource, /const copyReadonlyConfigValue = \(value\?: string\) =>/)
assert.match(settingsPanelsSource, /const \[copiedConfigValue, setCopiedConfigValue\] = useState<string \| null>\(null\)/)
assert.match(settingsPanelsSource, /setCopiedConfigValue\(value\)/)
assert.match(settingsPanelsSource, /settings-readonly-config-copy-feedback/)
assert.match(settingsPanelsSource, />✅已复制</)
assert.match(settingsPanelsSource, /navigator\.clipboard\.writeText\(value\)/)
assert.match(settingsPanelsSource, /const handleReadonlyConfigKeyDown = \(event: KeyboardEvent<HTMLDivElement>, value\?: string\) =>/)
assert.match(settingsPanelsSource, /event\.key !== "Enter" && event\.key !== " "/)
assert.match(settingsPanelsSource, /role="button"/)
assert.match(settingsPanelsSource, /tabIndex=\{value \? 0 : -1\}/)
assert.match(settingsPanelsSource, /title=\{value \|\| undefined\}/)
assert.match(settingsPanelsSource, /onClick=\{\(\) => copyReadonlyConfigValue\(value\)\}/)
assert.match(settingsPanelsSource, /onKeyDown=\{\(event\) => handleReadonlyConfigKeyDown\(event, value\)\}/)
assert.match(settingsStylesSource, /\.settings-readonly-config-value\s*\{[\s\S]*?overflow:\s*hidden;[\s\S]*?text-overflow:\s*ellipsis;[\s\S]*?white-space:\s*nowrap;/m)
assert.match(settingsStylesSource, /\.settings-readonly-config-copy-feedback\s*\{/)
assert.match(settingsStylesSource, /\.settings-readonly-config-value\[role="button"\]\s*\{[\s\S]*?cursor:\s*copy;/m)
assert.match(settingsStylesSource, /\.settings-readonly-config-value:focus-visible\s*\{/)
})
......@@ -22,6 +22,7 @@
configLoad: "config:load",
configPickWorkspaceDirectory: "config:pick-workspace-directory",
configSave: "config:save",
configRevealSecret: "config:reveal-secret",
projectsList: "projects:list",
projectsSetActive: "projects:set-active",
projectsResolveIntent: "projects:resolve-intent",
......@@ -79,6 +80,22 @@ export type SkillDownloadState = "pending" | "downloading" | "ready" | "failed"
export type ExpertEntryMode = "standalone" | "home-chat-shortcut";
export type DailyReportDeliveryState = "draft" | "sent" | "failed";
export type TaskPanelStatus = "pending" | "running" | "completed" | "failed";
export type ConfigSecretId =
| "lobsterKey"
| "copywritingModelApiKey"
| "imageModelApiKey"
| "videoModelApiKey"
| "digitalHumanVolcAccessKey"
| "digitalHumanVolcSecretKey"
| "digitalHumanQiniuAccessKey"
| "digitalHumanQiniuSecretKey"
| "videoAnalyzerApiKey"
| "replicationBriefApiKey"
| "vectcutApiKey"
| "xhsFeishuAppId"
| "xhsFeishuAppSecret"
| "xhsFeishuAppToken"
| "xhsFeishuTableId";
export interface WorkspaceWarmupResult {
accepted: boolean;
......@@ -629,11 +646,11 @@ export const FIXED_DIGITAL_HUMAN_CONFIG = {
export const FIXED_DOUYIN_RUNTIME_CONFIG = {
videoAnalyzer: {
baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
modelId: "",
modelId: "doubao-vision",
},
replicationBrief: {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
modelId: "",
modelId: "qwen-max",
},
vectcut: {
baseUrl: "https://open.vectcut.com/cut_jianying",
......@@ -894,6 +911,7 @@ export interface DesktopApi {
load(): Promise<AppConfig>;
pickWorkspaceDirectory(currentPath?: string): Promise<string | null>;
save(input: SaveConfigInput): Promise<AppConfig>;
revealSecret(secretId: ConfigSecretId): Promise<string | null>;
};
projects: {
list(): Promise<ProjectSummary[]>;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment