Commit f492464b authored by AI-甘富林's avatar AI-甘富林

feat(client): add douyin runtime settings and attachment support

parent c5b18b81
...@@ -148,6 +148,94 @@ function didWorkspacePathChange(previousConfig: AppConfig, nextConfig: AppConfig ...@@ -148,6 +148,94 @@ function didWorkspacePathChange(previousConfig: AppConfig, nextConfig: AppConfig
return normalizeComparablePath(previousConfig.workspacePath) !== normalizeComparablePath(nextConfig.workspacePath); return normalizeComparablePath(previousConfig.workspacePath) !== normalizeComparablePath(nextConfig.workspacePath);
} }
const IMAGE_ATTACHMENT_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
const DOCUMENT_ATTACHMENT_EXTENSIONS = new Set([".pdf", ".ppt", ".pptx", ".xls", ".xlsx", ".csv", ".tsv", ".doc", ".docx", ".txt", ".md", ".json", ".mp3"]);
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
...IMAGE_ATTACHMENT_EXTENSIONS,
...DOCUMENT_ATTACHMENT_EXTENSIONS
]);
function inferAttachmentMimeType(localPath: string, name?: string): string {
const extension = path.extname(name || localPath).toLowerCase();
switch (extension) {
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".webp":
return "image/webp";
case ".gif":
return "image/gif";
case ".bmp":
return "image/bmp";
case ".pdf":
return "application/pdf";
case ".ppt":
return "application/vnd.ms-powerpoint";
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
case ".xls":
return "application/vnd.ms-excel";
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
case ".csv":
return "text/csv";
case ".tsv":
return "text/tab-separated-values";
case ".doc":
return "application/msword";
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
case ".txt":
return "text/plain";
case ".md":
return "text/markdown";
case ".json":
return "application/json";
case ".mp3":
return "audio/mpeg";
default:
return "application/octet-stream";
}
}
function inferAttachmentKind(localPath: string, name?: string, mimeType?: string): ChatAttachment["kind"] | null {
const normalizedMimeType = mimeType?.trim().toLowerCase() || "";
const extension = path.extname(name || localPath).toLowerCase();
if (normalizedMimeType.startsWith("image/") || IMAGE_ATTACHMENT_EXTENSIONS.has(extension)) {
return "image";
}
if (SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension)) {
return "file";
}
return null;
}
function normalizeChatAttachmentCandidate(attachment: Partial<ChatAttachment> | null | undefined): ChatAttachment | null {
if (!attachment) {
return null;
}
const localPath = attachment.localPath?.trim();
if (!localPath) {
return null;
}
const name = attachment.name?.trim() || path.basename(localPath);
const mimeType = attachment.mimeType?.trim() || inferAttachmentMimeType(localPath, name);
const kind = attachment.kind && attachment.kind !== "image" && attachment.kind !== "file"
? null
: inferAttachmentKind(localPath, name, mimeType);
if (!kind) {
return null;
}
return {
kind,
name,
mimeType,
localPath
};
}
async function pickImageAttachment(window: BrowserWindow | null): Promise<ChatAttachment | null> { async function pickImageAttachment(window: BrowserWindow | null): Promise<ChatAttachment | null> {
const dialogOptions: OpenDialogOptions = { const dialogOptions: OpenDialogOptions = {
title: "Select image", title: "Select image",
...@@ -168,26 +256,46 @@ async function pickImageAttachment(window: BrowserWindow | null): Promise<ChatAt ...@@ -168,26 +256,46 @@ async function pickImageAttachment(window: BrowserWindow | null): Promise<ChatAt
return null; return null;
} }
const name = path.basename(localPath) || "image"; return normalizeChatAttachmentCandidate({
const extension = path.extname(name).toLowerCase();
const mimeType = extension === ".png"
? "image/png"
: extension === ".jpg" || extension === ".jpeg"
? "image/jpeg"
: extension === ".webp"
? "image/webp"
: extension === ".gif"
? "image/gif"
: extension === ".bmp"
? "image/bmp"
: "application/octet-stream";
return {
kind: "image", kind: "image",
name, name: path.basename(localPath) || "image",
mimeType, mimeType: inferAttachmentMimeType(localPath),
localPath localPath
});
}
async function pickAttachments(window: BrowserWindow | null): Promise<ChatAttachment[]> {
const dialogOptions: OpenDialogOptions = {
title: "Select attachments",
properties: ["openFile", "multiSelections"],
filters: [
{ name: "Supported files", extensions: [...SUPPORTED_ATTACHMENT_EXTENSIONS].map((value) => value.slice(1)) },
{ name: "Images", extensions: [...IMAGE_ATTACHMENT_EXTENSIONS].map((value) => value.slice(1)) },
{ name: "Audio", extensions: ["mp3"] },
{ name: "Documents", extensions: ["pdf", "mp3", "doc", "docx", "txt", "md", "json"] },
{ name: "Presentations", extensions: ["ppt", "pptx"] },
{ name: "Spreadsheets", extensions: ["xls", "xlsx", "csv", "tsv"] }
]
}; };
const result = window
? await dialog.showOpenDialog(window, dialogOptions)
: await dialog.showOpenDialog(dialogOptions);
if (result.canceled || !result.filePaths.length) {
return [];
}
return result.filePaths.flatMap((filePath) => {
const localPath = filePath?.trim();
if (!localPath) {
return [];
}
const attachment = normalizeChatAttachmentCandidate({
name: path.basename(localPath),
mimeType: inferAttachmentMimeType(localPath),
localPath
});
return attachment ? [attachment] : [];
});
} }
async function pickWorkspaceDirectory(window: BrowserWindow | null, currentPath?: string): Promise<string | null> { async function pickWorkspaceDirectory(window: BrowserWindow | null, currentPath?: string): Promise<string | null> {
...@@ -213,21 +321,18 @@ function normalizeChatAttachments(attachments?: ChatAttachment[]): ChatAttachmen ...@@ -213,21 +321,18 @@ function normalizeChatAttachments(attachments?: ChatAttachment[]): ChatAttachmen
return []; return [];
} }
const seen = new Set<string>();
return attachments.flatMap((attachment) => { return attachments.flatMap((attachment) => {
if (!attachment || attachment.kind !== "image") { const normalized = normalizeChatAttachmentCandidate(attachment);
if (!normalized) {
return []; return [];
} }
const localPath = attachment.localPath?.trim(); const comparablePath = normalizeComparablePath(normalized.localPath);
if (!localPath) { if (seen.has(comparablePath)) {
return []; return [];
} }
const name = attachment.name?.trim() || path.basename(localPath); seen.add(comparablePath);
return [{ return [normalized];
kind: "image" as const,
name,
mimeType: attachment.mimeType?.trim() || "application/octet-stream",
localPath
}];
}); });
} }
...@@ -242,13 +347,15 @@ async function materializeProjectAttachments( ...@@ -242,13 +347,15 @@ async function materializeProjectAttachments(
} }
const sessionSlug = sanitizeAttachmentFileComponent(sessionId.replace(/[:]/g, "-")); const sessionSlug = sanitizeAttachmentFileComponent(sessionId.replace(/[:]/g, "-"));
const imagesRoot = path.join(projectRoot, "inputs", "images", "main");
await mkdir(imagesRoot, { recursive: true });
return await Promise.all(normalized.map(async (attachment, index) => { return await Promise.all(normalized.map(async (attachment, index) => {
const sourceExt = path.extname(attachment.name || attachment.localPath) || path.extname(attachment.localPath) || ".bin"; const sourceExt = path.extname(attachment.name || attachment.localPath) || path.extname(attachment.localPath) || ".bin";
const targetRoot = attachment.kind === "image"
? path.join(projectRoot, "inputs", "images", "main")
: path.join(projectRoot, "inputs", "assets", "manual");
await mkdir(targetRoot, { recursive: true });
const fileName = `${sessionSlug}-${String(index + 1).padStart(2, "0")}${sourceExt.toLowerCase()}`; const fileName = `${sessionSlug}-${String(index + 1).padStart(2, "0")}${sourceExt.toLowerCase()}`;
const targetPath = path.join(imagesRoot, fileName); const targetPath = path.join(targetRoot, fileName);
await copyFile(attachment.localPath, targetPath); await copyFile(attachment.localPath, targetPath);
return { return {
...attachment, ...attachment,
...@@ -415,39 +522,106 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -415,39 +522,106 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
); );
}; };
const getEffectiveConfig = async () => { const applySecretStateToConfig = async (config: AppConfig): Promise<AppConfig> => {
const config = await configService.load(); const [
return { apiKey,
gatewayToken,
authToken,
imageModelApiKey,
videoModelApiKey,
copywritingModelApiKey,
digitalHumanVolcAccessKey,
digitalHumanVolcSecretKey,
digitalHumanQiniuAccessKey,
digitalHumanQiniuSecretKey,
videoAnalyzerApiKey,
replicationBriefApiKey,
vectCutApiKey
] = await Promise.all([
secretManager.getApiKey(),
getEffectiveGatewayToken(config),
secretManager.getAuthToken(),
secretManager.getImageModelApiKey(),
secretManager.getVideoModelApiKey(),
secretManager.getCopywritingModelApiKey(),
secretManager.getDigitalHumanVolcAccessKey(),
secretManager.getDigitalHumanVolcSecretKey(),
secretManager.getDigitalHumanQiniuAccessKey(),
secretManager.getDigitalHumanQiniuSecretKey(),
secretManager.getVideoAnalyzerApiKey(),
secretManager.getReplicationBriefApiKey(),
secretManager.getVectCutApiKey()
]);
const nextConfig: AppConfig = {
...config, ...config,
gatewayUrl: resolveEffectiveGatewayUrl(config.gatewayUrl, getDiscoveredGatewayUrl(config.runtimeMode)), gatewayUrl: resolveEffectiveGatewayUrl(config.gatewayUrl, getDiscoveredGatewayUrl(config.runtimeMode)),
apiKeyConfigured: Boolean((await secretManager.getApiKey()) || config.apiKeyConfigured), apiKeyConfigured: Boolean(apiKey),
gatewayTokenConfigured: Boolean((await getEffectiveGatewayToken(config)) || config.gatewayTokenConfigured), gatewayTokenConfigured: Boolean(gatewayToken),
authTokenConfigured: Boolean((await secretManager.getAuthToken()) || config.authTokenConfigured), authTokenConfigured: Boolean(authToken),
expertModelConfig: { expertModelConfig: {
image: { image: {
baseUrl: config.expertModelConfig.image.baseUrl, ...config.expertModelConfig.image,
apiKeyConfigured: Boolean((await secretManager.getImageModelApiKey()) || config.expertModelConfig.image.apiKeyConfigured), apiKeyConfigured: Boolean(imageModelApiKey)
modelId: config.expertModelConfig.image.modelId
}, },
video: { video: {
baseUrl: config.expertModelConfig.video.baseUrl, ...config.expertModelConfig.video,
apiKeyConfigured: Boolean((await secretManager.getVideoModelApiKey()) || config.expertModelConfig.video.apiKeyConfigured), apiKeyConfigured: Boolean(videoModelApiKey)
modelId: config.expertModelConfig.video.modelId
}, },
copywriting: { copywriting: {
baseUrl: config.expertModelConfig.copywriting.baseUrl, ...config.expertModelConfig.copywriting,
apiKeyConfigured: Boolean((await secretManager.getCopywritingModelApiKey()) || config.expertModelConfig.copywriting.apiKeyConfigured), apiKeyConfigured: Boolean(copywritingModelApiKey)
modelId: config.expertModelConfig.copywriting.modelId
}, },
digitalHuman: { digitalHuman: {
...config.expertModelConfig.digitalHuman, ...config.expertModelConfig.digitalHuman,
volcAccessKeyConfigured: Boolean((await secretManager.getDigitalHumanVolcAccessKey()) || config.expertModelConfig.digitalHuman.volcAccessKeyConfigured), volcAccessKeyConfigured: Boolean(digitalHumanVolcAccessKey),
volcSecretKeyConfigured: Boolean((await secretManager.getDigitalHumanVolcSecretKey()) || config.expertModelConfig.digitalHuman.volcSecretKeyConfigured), volcSecretKeyConfigured: Boolean(digitalHumanVolcSecretKey),
qiniuAccessKeyConfigured: Boolean((await secretManager.getDigitalHumanQiniuAccessKey()) || config.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured), qiniuAccessKeyConfigured: Boolean(digitalHumanQiniuAccessKey),
qiniuSecretKeyConfigured: Boolean((await secretManager.getDigitalHumanQiniuSecretKey()) || config.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured) qiniuSecretKeyConfigured: Boolean(digitalHumanQiniuSecretKey)
}
},
douyinRuntimeConfig: {
videoAnalyzer: {
...config.douyinRuntimeConfig.videoAnalyzer,
apiKeyConfigured: Boolean(videoAnalyzerApiKey)
},
replicationBrief: {
...config.douyinRuntimeConfig.replicationBrief,
apiKeyConfigured: Boolean(replicationBriefApiKey)
},
vectcut: {
...config.douyinRuntimeConfig.vectcut,
apiKeyConfigured: Boolean(vectCutApiKey)
} }
} }
}; };
const secretStateChanged = nextConfig.apiKeyConfigured !== config.apiKeyConfigured
|| nextConfig.gatewayTokenConfigured !== config.gatewayTokenConfigured
|| nextConfig.authTokenConfigured !== config.authTokenConfigured
|| nextConfig.expertModelConfig.image.apiKeyConfigured !== config.expertModelConfig.image.apiKeyConfigured
|| nextConfig.expertModelConfig.video.apiKeyConfigured !== config.expertModelConfig.video.apiKeyConfigured
|| nextConfig.expertModelConfig.copywriting.apiKeyConfigured !== config.expertModelConfig.copywriting.apiKeyConfigured
|| nextConfig.expertModelConfig.digitalHuman.volcAccessKeyConfigured !== config.expertModelConfig.digitalHuman.volcAccessKeyConfigured
|| nextConfig.expertModelConfig.digitalHuman.volcSecretKeyConfigured !== config.expertModelConfig.digitalHuman.volcSecretKeyConfigured
|| nextConfig.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured !== config.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured
|| nextConfig.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured !== config.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured
|| nextConfig.douyinRuntimeConfig.videoAnalyzer.apiKeyConfigured !== config.douyinRuntimeConfig.videoAnalyzer.apiKeyConfigured
|| nextConfig.douyinRuntimeConfig.replicationBrief.apiKeyConfigured !== config.douyinRuntimeConfig.replicationBrief.apiKeyConfigured
|| nextConfig.douyinRuntimeConfig.vectcut.apiKeyConfigured !== config.douyinRuntimeConfig.vectcut.apiKeyConfigured;
if (secretStateChanged) {
await configService.persist({
...nextConfig,
gatewayUrl: config.gatewayUrl
});
}
return nextConfig;
};
const getEffectiveConfig = async () => {
return applySecretStateToConfig(await configService.load());
}; };
const prepareProjectModelRuntime = async (projectId: string, projectRoot: string): Promise<Record<string, string>> => { const prepareProjectModelRuntime = async (projectId: string, projectRoot: string): Promise<Record<string, string>> => {
...@@ -459,7 +633,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -459,7 +633,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
digitalHumanVolcAccessKey, digitalHumanVolcAccessKey,
digitalHumanVolcSecretKey, digitalHumanVolcSecretKey,
digitalHumanQiniuAccessKey, digitalHumanQiniuAccessKey,
digitalHumanQiniuSecretKey digitalHumanQiniuSecretKey,
videoAnalyzerApiKey,
replicationBriefApiKey,
vectCutApiKey
] = await Promise.all([ ] = await Promise.all([
secretManager.getCopywritingModelApiKey(), secretManager.getCopywritingModelApiKey(),
secretManager.getImageModelApiKey(), secretManager.getImageModelApiKey(),
...@@ -467,7 +644,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -467,7 +644,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
secretManager.getDigitalHumanVolcAccessKey(), secretManager.getDigitalHumanVolcAccessKey(),
secretManager.getDigitalHumanVolcSecretKey(), secretManager.getDigitalHumanVolcSecretKey(),
secretManager.getDigitalHumanQiniuAccessKey(), secretManager.getDigitalHumanQiniuAccessKey(),
secretManager.getDigitalHumanQiniuSecretKey() secretManager.getDigitalHumanQiniuSecretKey(),
secretManager.getVideoAnalyzerApiKey(),
secretManager.getReplicationBriefApiKey(),
secretManager.getVectCutApiKey()
]); ]);
const runtime = buildProjectModelRuntime(projectId, config, { const runtime = buildProjectModelRuntime(projectId, config, {
copywritingApiKey, copywritingApiKey,
...@@ -476,7 +656,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -476,7 +656,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
digitalHumanVolcAccessKey, digitalHumanVolcAccessKey,
digitalHumanVolcSecretKey, digitalHumanVolcSecretKey,
digitalHumanQiniuAccessKey, digitalHumanQiniuAccessKey,
digitalHumanQiniuSecretKey digitalHumanQiniuSecretKey,
videoAnalyzerApiKey,
replicationBriefApiKey,
vectCutApiKey
}); });
const envFilePath = await materializeProjectModelRuntime(projectRoot, runtime); const envFilePath = await materializeProjectModelRuntime(projectRoot, runtime);
...@@ -841,6 +1024,15 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -841,6 +1024,15 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
if (typeof input.expertModelConfig?.digitalHuman?.qiniuSecretKey === "string") { if (typeof input.expertModelConfig?.digitalHuman?.qiniuSecretKey === "string") {
await secretManager.setDigitalHumanQiniuSecretKey(input.expertModelConfig.digitalHuman.qiniuSecretKey || undefined); await secretManager.setDigitalHumanQiniuSecretKey(input.expertModelConfig.digitalHuman.qiniuSecretKey || undefined);
} }
if (typeof input.douyinRuntimeConfig?.videoAnalyzer?.apiKey === "string") {
await secretManager.setVideoAnalyzerApiKey(input.douyinRuntimeConfig.videoAnalyzer.apiKey || undefined);
}
if (typeof input.douyinRuntimeConfig?.replicationBrief?.apiKey === "string") {
await secretManager.setReplicationBriefApiKey(input.douyinRuntimeConfig.replicationBrief.apiKey || undefined);
}
if (typeof input.douyinRuntimeConfig?.vectcut?.apiKey === "string") {
await secretManager.setVectCutApiKey(input.douyinRuntimeConfig.vectcut.apiKey || undefined);
}
if ( if (
config.setupMode === "direct-provider" config.setupMode === "direct-provider"
|| previousConfig.setupMode !== config.setupMode || previousConfig.setupMode !== config.setupMode
...@@ -1875,6 +2067,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1875,6 +2067,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return sessions; return sessions;
}); });
ipcMain.handle(IPC_CHANNELS.chatListMessages, async (_event, sessionId: string) => listChatMessages(sessionId)); ipcMain.handle(IPC_CHANNELS.chatListMessages, async (_event, sessionId: string) => listChatMessages(sessionId));
ipcMain.handle(IPC_CHANNELS.chatPickAttachments, async (event) => pickAttachments(BrowserWindow.fromWebContents(event.sender)));
ipcMain.handle(IPC_CHANNELS.chatPickImageAttachment, async (event) => pickImageAttachment(BrowserWindow.fromWebContents(event.sender))); ipcMain.handle(IPC_CHANNELS.chatPickImageAttachment, async (event) => pickImageAttachment(BrowserWindow.fromWebContents(event.sender)));
ipcMain.handle(IPC_CHANNELS.chatSendPrompt, async (_event, sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => { ipcMain.handle(IPC_CHANNELS.chatSendPrompt, async (_event, sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => {
return sendPrompt(sessionId, prompt, skillId, attachments); return sendPrompt(sessionId, prompt, skillId, attachments);
...@@ -1992,6 +2185,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1992,6 +2185,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return sessions; return sessions;
}, },
listMessages: (sessionId: string) => listChatMessages(sessionId), listMessages: (sessionId: string) => listChatMessages(sessionId),
pickAttachments: async () => pickAttachments(BrowserWindow.getFocusedWindow() ?? null),
pickImageAttachment: async () => pickImageAttachment(BrowserWindow.getFocusedWindow() ?? null), pickImageAttachment: async () => pickImageAttachment(BrowserWindow.getFocusedWindow() ?? null),
sendPrompt: async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => sendPrompt(sessionId, prompt, skillId, attachments), sendPrompt: async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => sendPrompt(sessionId, prompt, skillId, attachments),
streamPrompt: async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => streamPrompt(sessionId, prompt, skillId, attachments), streamPrompt: async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => streamPrompt(sessionId, prompt, skillId, attachments),
......
...@@ -2,8 +2,11 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; ...@@ -2,8 +2,11 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { import {
FIXED_DIGITAL_HUMAN_CONFIG, FIXED_DIGITAL_HUMAN_CONFIG,
FIXED_DOUYIN_RUNTIME_CONFIG,
FIXED_EXPERT_MODEL_ENDPOINTS, FIXED_EXPERT_MODEL_ENDPOINTS,
type AppConfig, type AppConfig,
type DouyinRuntimeConfig,
type DouyinTextModelConfig,
type DigitalHumanModelConfig, type DigitalHumanModelConfig,
type ExpertModelConfig, type ExpertModelConfig,
type ModelEndpointConfig, type ModelEndpointConfig,
...@@ -62,6 +65,15 @@ interface LegacyConfig { ...@@ -62,6 +65,15 @@ interface LegacyConfig {
copywriting?: Partial<ModelEndpointConfig>; copywriting?: Partial<ModelEndpointConfig>;
digitalHuman?: Partial<DigitalHumanModelConfig>; digitalHuman?: Partial<DigitalHumanModelConfig>;
}; };
douyinRuntimeConfig?: {
videoAnalyzer?: Partial<DouyinTextModelConfig>;
replicationBrief?: Partial<DouyinTextModelConfig>;
vectcut?: {
baseUrl?: string;
fileBaseUrl?: string;
apiKeyConfigured?: boolean;
};
};
} }
function normalizeGatewayUrl(raw: string): string { function normalizeGatewayUrl(raw: string): string {
...@@ -173,6 +185,27 @@ function createDefaultExpertModelConfig(): ExpertModelConfig { ...@@ -173,6 +185,27 @@ function createDefaultExpertModelConfig(): ExpertModelConfig {
}; };
} }
function createDefaultDouyinTextModelConfig(kind: "videoAnalyzer" | "replicationBrief"): DouyinTextModelConfig {
const config = FIXED_DOUYIN_RUNTIME_CONFIG[kind];
return {
baseUrl: config.baseUrl,
apiKeyConfigured: false,
modelId: config.modelId
};
}
function createDefaultDouyinRuntimeConfig(): DouyinRuntimeConfig {
return {
videoAnalyzer: createDefaultDouyinTextModelConfig("videoAnalyzer"),
replicationBrief: createDefaultDouyinTextModelConfig("replicationBrief"),
vectcut: {
baseUrl: FIXED_DOUYIN_RUNTIME_CONFIG.vectcut.baseUrl,
fileBaseUrl: FIXED_DOUYIN_RUNTIME_CONFIG.vectcut.fileBaseUrl,
apiKeyConfigured: false
}
};
}
function mergeExpertModelConfig( function mergeExpertModelConfig(
current: ExpertModelConfig, current: ExpertModelConfig,
input?: SaveConfigInput["expertModelConfig"] input?: SaveConfigInput["expertModelConfig"]
...@@ -203,6 +236,47 @@ function mergeExpertModelConfig( ...@@ -203,6 +236,47 @@ function mergeExpertModelConfig(
}; };
} }
function mergeDouyinRuntimeConfig(
current: DouyinRuntimeConfig,
input?: SaveConfigInput["douyinRuntimeConfig"]
): DouyinRuntimeConfig {
return {
videoAnalyzer: {
baseUrl: typeof input?.videoAnalyzer?.baseUrl === "string"
? input.videoAnalyzer.baseUrl.trim()
: current.videoAnalyzer.baseUrl,
apiKeyConfigured: typeof input?.videoAnalyzer?.apiKey === "string"
? Boolean(input.videoAnalyzer.apiKey.trim())
: current.videoAnalyzer.apiKeyConfigured,
modelId: typeof input?.videoAnalyzer?.modelId === "string"
? input.videoAnalyzer.modelId.trim()
: current.videoAnalyzer.modelId
},
replicationBrief: {
baseUrl: typeof input?.replicationBrief?.baseUrl === "string"
? input.replicationBrief.baseUrl.trim()
: current.replicationBrief.baseUrl,
apiKeyConfigured: typeof input?.replicationBrief?.apiKey === "string"
? Boolean(input.replicationBrief.apiKey.trim())
: current.replicationBrief.apiKeyConfigured,
modelId: typeof input?.replicationBrief?.modelId === "string"
? input.replicationBrief.modelId.trim()
: current.replicationBrief.modelId
},
vectcut: {
baseUrl: typeof input?.vectcut?.baseUrl === "string"
? input.vectcut.baseUrl.trim()
: current.vectcut.baseUrl,
fileBaseUrl: typeof input?.vectcut?.fileBaseUrl === "string"
? input.vectcut.fileBaseUrl.trim()
: current.vectcut.fileBaseUrl,
apiKeyConfigured: typeof input?.vectcut?.apiKey === "string"
? Boolean(input.vectcut.apiKey.trim())
: current.vectcut.apiKeyConfigured
}
};
}
export function getRuntimeCloudApiTarget(config: Pick<AppConfig, "runtimeCloudApiBaseUrl">): RuntimeCloudApiTarget { export function getRuntimeCloudApiTarget(config: Pick<AppConfig, "runtimeCloudApiBaseUrl">): RuntimeCloudApiTarget {
return resolveRuntimeCloudApiTarget(config.runtimeCloudApiBaseUrl); return resolveRuntimeCloudApiTarget(config.runtimeCloudApiBaseUrl);
} }
...@@ -235,7 +309,8 @@ export class AppConfigService { ...@@ -235,7 +309,8 @@ export class AppConfigService {
cloudApiBaseUrl: normalizeCloudApiBaseUrl(input.cloudApiBaseUrl), cloudApiBaseUrl: normalizeCloudApiBaseUrl(input.cloudApiBaseUrl),
runtimeCloudApiBaseUrl: migrateDeprecatedRuntimeCloudApiBaseUrl(input.runtimeCloudApiBaseUrl), runtimeCloudApiBaseUrl: migrateDeprecatedRuntimeCloudApiBaseUrl(input.runtimeCloudApiBaseUrl),
runtimeMode: normalizeRuntimeMode(input.runtimeMode), runtimeMode: normalizeRuntimeMode(input.runtimeMode),
expertModelConfig: mergeExpertModelConfig(current.expertModelConfig, input.expertModelConfig) expertModelConfig: mergeExpertModelConfig(current.expertModelConfig, input.expertModelConfig),
douyinRuntimeConfig: mergeDouyinRuntimeConfig(current.douyinRuntimeConfig, input.douyinRuntimeConfig)
}; };
await this.writeConfig(config); await this.writeConfig(config);
...@@ -243,6 +318,12 @@ export class AppConfigService { ...@@ -243,6 +318,12 @@ export class AppConfigService {
}); });
} }
async persist(config: AppConfig): Promise<void> {
return this.runExclusive(async () => {
await this.writeConfig(config);
});
}
getDataPath(...segments: string[]): string { getDataPath(...segments: string[]): string {
return path.join(this.userDataPath, ...segments); return path.join(this.userDataPath, ...segments);
} }
...@@ -265,12 +346,14 @@ export class AppConfigService { ...@@ -265,12 +346,14 @@ export class AppConfigService {
cloudApiBaseUrl: normalizeCloudApiBaseUrl(process.env.QJCLAW_CLOUD_API_BASE_URL ?? ""), cloudApiBaseUrl: normalizeCloudApiBaseUrl(process.env.QJCLAW_CLOUD_API_BASE_URL ?? ""),
runtimeCloudApiBaseUrl: "", runtimeCloudApiBaseUrl: "",
runtimeMode: normalizeRuntimeMode(process.env.QJCLAW_RUNTIME_MODE), runtimeMode: normalizeRuntimeMode(process.env.QJCLAW_RUNTIME_MODE),
expertModelConfig: createDefaultExpertModelConfig() expertModelConfig: createDefaultExpertModelConfig(),
douyinRuntimeConfig: createDefaultDouyinRuntimeConfig()
}; };
} }
private normalizeConfig(config: LegacyConfig): AppConfig { private normalizeConfig(config: LegacyConfig): AppConfig {
const defaultExpertModelConfig = createDefaultExpertModelConfig(); const defaultExpertModelConfig = createDefaultExpertModelConfig();
const defaultDouyinRuntimeConfig = createDefaultDouyinRuntimeConfig();
return { return {
setupMode: normalizeSetupMode(config.setupMode), setupMode: normalizeSetupMode(config.setupMode),
provider: config.provider ?? "openai", provider: config.provider ?? "openai",
...@@ -307,6 +390,31 @@ export class AppConfigService { ...@@ -307,6 +390,31 @@ export class AppConfigService {
qiniuAccessKeyConfigured: Boolean(config.expertModelConfig?.digitalHuman?.qiniuAccessKeyConfigured), qiniuAccessKeyConfigured: Boolean(config.expertModelConfig?.digitalHuman?.qiniuAccessKeyConfigured),
qiniuSecretKeyConfigured: Boolean(config.expertModelConfig?.digitalHuman?.qiniuSecretKeyConfigured) qiniuSecretKeyConfigured: Boolean(config.expertModelConfig?.digitalHuman?.qiniuSecretKeyConfigured)
} }
},
douyinRuntimeConfig: {
videoAnalyzer: {
baseUrl: typeof config.douyinRuntimeConfig?.videoAnalyzer?.baseUrl === "string"
? config.douyinRuntimeConfig.videoAnalyzer.baseUrl
: defaultDouyinRuntimeConfig.videoAnalyzer.baseUrl,
apiKeyConfigured: Boolean(config.douyinRuntimeConfig?.videoAnalyzer?.apiKeyConfigured),
modelId: config.douyinRuntimeConfig?.videoAnalyzer?.modelId ?? defaultDouyinRuntimeConfig.videoAnalyzer.modelId
},
replicationBrief: {
baseUrl: typeof config.douyinRuntimeConfig?.replicationBrief?.baseUrl === "string"
? config.douyinRuntimeConfig.replicationBrief.baseUrl
: defaultDouyinRuntimeConfig.replicationBrief.baseUrl,
apiKeyConfigured: Boolean(config.douyinRuntimeConfig?.replicationBrief?.apiKeyConfigured),
modelId: config.douyinRuntimeConfig?.replicationBrief?.modelId ?? defaultDouyinRuntimeConfig.replicationBrief.modelId
},
vectcut: {
baseUrl: typeof config.douyinRuntimeConfig?.vectcut?.baseUrl === "string"
? config.douyinRuntimeConfig.vectcut.baseUrl
: defaultDouyinRuntimeConfig.vectcut.baseUrl,
fileBaseUrl: typeof config.douyinRuntimeConfig?.vectcut?.fileBaseUrl === "string"
? config.douyinRuntimeConfig.vectcut.fileBaseUrl
: defaultDouyinRuntimeConfig.vectcut.fileBaseUrl,
apiKeyConfigured: Boolean(config.douyinRuntimeConfig?.vectcut?.apiKeyConfigured)
}
} }
}; };
} }
......
...@@ -10,6 +10,9 @@ export interface ProjectModelRuntimeSecrets { ...@@ -10,6 +10,9 @@ export interface ProjectModelRuntimeSecrets {
digitalHumanVolcSecretKey?: string; digitalHumanVolcSecretKey?: string;
digitalHumanQiniuAccessKey?: string; digitalHumanQiniuAccessKey?: string;
digitalHumanQiniuSecretKey?: string; digitalHumanQiniuSecretKey?: string;
videoAnalyzerApiKey?: string;
replicationBriefApiKey?: string;
vectCutApiKey?: string;
} }
export interface ProjectModelRuntimePreparation { export interface ProjectModelRuntimePreparation {
...@@ -40,7 +43,7 @@ const DOUYIN_PROJECT_IDS = new Set([ ...@@ -40,7 +43,7 @@ const DOUYIN_PROJECT_IDS = new Set([
"douyin" "douyin"
]); ]);
const CLIENT_SETTINGS_HINT = "请在客户端设置中完成模型配置后重试。"; const CLIENT_SETTINGS_HINT = "Please complete the required settings in the client and try again.";
function normalizeValue(raw?: string): string { function normalizeValue(raw?: string): string {
return raw?.trim() ?? ""; return raw?.trim() ?? "";
...@@ -92,12 +95,12 @@ function formatMissingSection(label: string, missing: string[]): string | null { ...@@ -92,12 +95,12 @@ function formatMissingSection(label: string, missing: string[]): string | null {
return null; return null;
} }
return `${label}缺少 ${missing.join("、")}`; return `${label} missing ${missing.join(", ")}`;
} }
export function validateProjectModelRuntime( export function validateProjectModelRuntime(
projectId: string, projectId: string,
config: Pick<AppConfig, "expertModelConfig">, config: Pick<AppConfig, "expertModelConfig" | "douyinRuntimeConfig">,
secrets: ProjectModelRuntimeSecrets secrets: ProjectModelRuntimeSecrets
): ProjectModelRuntimeValidationResult { ): ProjectModelRuntimeValidationResult {
const normalizedProjectId = normalizeValue(projectId).toLowerCase(); const normalizedProjectId = normalizeValue(projectId).toLowerCase();
...@@ -137,11 +140,57 @@ export function validateProjectModelRuntime( ...@@ -137,11 +140,57 @@ export function validateProjectModelRuntime(
imageMissing.push("modelId"); imageMissing.push("modelId");
} }
const sections = [ const missingFields: string[] = [
formatMissingSection("文案模型", copywritingMissing), ...copywritingMissing.map((field) => `copywriting.${field}`),
formatMissingSection("生图模型", imageMissing) ...imageMissing.map((field) => `image.${field}`)
];
const sections: string[] = [
formatMissingSection("Copywriting model", copywritingMissing),
formatMissingSection("Image model", imageMissing)
].filter((value): value is string => Boolean(value)); ].filter((value): value is string => Boolean(value));
if (DOUYIN_PROJECT_IDS.has(normalizedProjectId)) {
const videoAnalyzerBaseUrl = normalizeOpenAiCompatibleBaseUrl(config.douyinRuntimeConfig.videoAnalyzer.baseUrl);
const videoAnalyzerModelId = normalizeValue(config.douyinRuntimeConfig.videoAnalyzer.modelId);
const videoAnalyzerApiKey = normalizeValue(secrets.videoAnalyzerApiKey);
const replicationBriefBaseUrl = normalizeOpenAiCompatibleBaseUrl(config.douyinRuntimeConfig.replicationBrief.baseUrl);
const replicationBriefModelId = normalizeValue(config.douyinRuntimeConfig.replicationBrief.modelId);
const replicationBriefApiKey = normalizeValue(secrets.replicationBriefApiKey);
const videoAnalyzerMissing: string[] = [];
if (!videoAnalyzerBaseUrl) {
videoAnalyzerMissing.push("baseUrl");
}
if (!videoAnalyzerApiKey) {
videoAnalyzerMissing.push("apiKey");
}
if (!videoAnalyzerModelId) {
videoAnalyzerMissing.push("modelId");
}
const replicationBriefMissing: string[] = [];
if (!replicationBriefBaseUrl) {
replicationBriefMissing.push("baseUrl");
}
if (!replicationBriefApiKey) {
replicationBriefMissing.push("apiKey");
}
if (!replicationBriefModelId) {
replicationBriefMissing.push("modelId");
}
missingFields.push(
...videoAnalyzerMissing.map((field) => `douyinRuntimeConfig.videoAnalyzer.${field}`),
...replicationBriefMissing.map((field) => `douyinRuntimeConfig.replicationBrief.${field}`)
);
sections.push(
...[
formatMissingSection("Video Analyzer", videoAnalyzerMissing),
formatMissingSection("Replication Brief", replicationBriefMissing)
].filter((value): value is string => Boolean(value))
);
}
if (sections.length === 0) { if (sections.length === 0) {
return { return {
ok: true, ok: true,
...@@ -149,20 +198,16 @@ export function validateProjectModelRuntime( ...@@ -149,20 +198,16 @@ export function validateProjectModelRuntime(
}; };
} }
const projectLabel = XHS_PROJECT_IDS.has(normalizedProjectId) ? "小红书专家" : "抖音专家";
return { return {
ok: false, ok: false,
message: `${projectLabel}缺少客户端模型配置:${sections.join(";")}${CLIENT_SETTINGS_HINT}`, message: `${XHS_PROJECT_IDS.has(normalizedProjectId) ? "XHS" : "Douyin"} project is missing client settings: ${sections.join("; ")}. ${CLIENT_SETTINGS_HINT}`,
missingFields: [ missingFields
...copywritingMissing.map((field) => `copywriting.${field}`),
...imageMissing.map((field) => `image.${field}`)
]
}; };
} }
export function buildProjectModelRuntime( export function buildProjectModelRuntime(
projectId: string, projectId: string,
config: Pick<AppConfig, "expertModelConfig">, config: Pick<AppConfig, "expertModelConfig" | "douyinRuntimeConfig">,
secrets: ProjectModelRuntimeSecrets secrets: ProjectModelRuntimeSecrets
): ProjectModelRuntimePreparation { ): ProjectModelRuntimePreparation {
const normalizedProjectId = normalizeValue(projectId).toLowerCase(); const normalizedProjectId = normalizeValue(projectId).toLowerCase();
...@@ -170,6 +215,7 @@ export function buildProjectModelRuntime( ...@@ -170,6 +215,7 @@ export function buildProjectModelRuntime(
if (!validation.ok) { if (!validation.ok) {
throw new Error(validation.message); throw new Error(validation.message);
} }
const copywritingBaseUrl = normalizeOpenAiCompatibleBaseUrl(config.expertModelConfig.copywriting.baseUrl); const copywritingBaseUrl = normalizeOpenAiCompatibleBaseUrl(config.expertModelConfig.copywriting.baseUrl);
const copywritingModelId = normalizeValue(config.expertModelConfig.copywriting.modelId); const copywritingModelId = normalizeValue(config.expertModelConfig.copywriting.modelId);
const copywritingApiKey = normalizeValue(secrets.copywritingApiKey); const copywritingApiKey = normalizeValue(secrets.copywritingApiKey);
...@@ -183,6 +229,15 @@ export function buildProjectModelRuntime( ...@@ -183,6 +229,15 @@ export function buildProjectModelRuntime(
const digitalHumanVolcSecretKey = normalizeValue(secrets.digitalHumanVolcSecretKey); const digitalHumanVolcSecretKey = normalizeValue(secrets.digitalHumanVolcSecretKey);
const digitalHumanQiniuAccessKey = normalizeValue(secrets.digitalHumanQiniuAccessKey); const digitalHumanQiniuAccessKey = normalizeValue(secrets.digitalHumanQiniuAccessKey);
const digitalHumanQiniuSecretKey = normalizeValue(secrets.digitalHumanQiniuSecretKey); const digitalHumanQiniuSecretKey = normalizeValue(secrets.digitalHumanQiniuSecretKey);
const videoAnalyzerBaseUrl = normalizeOpenAiCompatibleBaseUrl(config.douyinRuntimeConfig.videoAnalyzer.baseUrl);
const videoAnalyzerModelId = normalizeValue(config.douyinRuntimeConfig.videoAnalyzer.modelId);
const videoAnalyzerApiKey = normalizeValue(secrets.videoAnalyzerApiKey);
const replicationBriefBaseUrl = normalizeOpenAiCompatibleBaseUrl(config.douyinRuntimeConfig.replicationBrief.baseUrl);
const replicationBriefModelId = normalizeValue(config.douyinRuntimeConfig.replicationBrief.modelId);
const replicationBriefApiKey = normalizeValue(secrets.replicationBriefApiKey);
const vectCutBaseUrl = withoutTrailingSlash(config.douyinRuntimeConfig.vectcut.baseUrl);
const vectCutFileBaseUrl = withoutTrailingSlash(config.douyinRuntimeConfig.vectcut.fileBaseUrl);
const vectCutApiKey = normalizeValue(secrets.vectCutApiKey);
const env: Record<string, string> = {}; const env: Record<string, string> = {};
if (XHS_PROJECT_IDS.has(normalizedProjectId)) { if (XHS_PROJECT_IDS.has(normalizedProjectId)) {
...@@ -213,54 +268,120 @@ export function buildProjectModelRuntime( ...@@ -213,54 +268,120 @@ export function buildProjectModelRuntime(
if (DOUYIN_PROJECT_IDS.has(normalizedProjectId)) { if (DOUYIN_PROJECT_IDS.has(normalizedProjectId)) {
const writerBaseUrl = normalizeChatCompletionsBaseUrl(copywritingBaseUrl); const writerBaseUrl = normalizeChatCompletionsBaseUrl(copywritingBaseUrl);
const seedreamBaseUrl = normalizeArkBaseUrl(imageBaseUrl); const seedreamBaseUrl = normalizeArkBaseUrl(imageBaseUrl);
env.QJC_CLIENT_CONFIG_ACTIVE = "1";
if (writerBaseUrl) { if (writerBaseUrl) {
env.DOUYIN_WRITER_BASE_URL = writerBaseUrl;
env.DOUYIN_WRITER_LLM_BASE_URL = writerBaseUrl; env.DOUYIN_WRITER_LLM_BASE_URL = writerBaseUrl;
} }
if (copywritingApiKey) { if (copywritingApiKey) {
env.DOUYIN_WRITER_API_KEY = copywritingApiKey;
env.DASHSCOPE_API_KEY = copywritingApiKey; env.DASHSCOPE_API_KEY = copywritingApiKey;
env.QWEN_API_KEY = copywritingApiKey; env.QWEN_API_KEY = copywritingApiKey;
} }
if (copywritingModelId) { if (copywritingModelId) {
env.DOUYIN_WRITER_MODEL = copywritingModelId;
env.DOUYIN_WRITER_LLM_MODEL = copywritingModelId; env.DOUYIN_WRITER_LLM_MODEL = copywritingModelId;
} }
if (seedreamBaseUrl) { if (seedreamBaseUrl) {
env.SEEDREAM_BASE_URL = seedreamBaseUrl;
env.SEEDREAM_ARK_BASE_URL = seedreamBaseUrl; env.SEEDREAM_ARK_BASE_URL = seedreamBaseUrl;
} }
if (imageApiKey) { if (imageApiKey) {
env.SEEDREAM_API_KEY = imageApiKey;
env.SEEDREAM_ARK_API_KEY = imageApiKey; env.SEEDREAM_ARK_API_KEY = imageApiKey;
} }
if (imageModelId) { if (imageModelId) {
env.SEEDREAM_MODEL = imageModelId; env.SEEDREAM_MODEL = imageModelId;
} }
if (videoBaseUrl) { if (videoBaseUrl) {
env.SEEDANCE_BASE_URL = videoBaseUrl;
env.SEEDANCE_ARK_BASE_URL = videoBaseUrl; env.SEEDANCE_ARK_BASE_URL = videoBaseUrl;
} }
if (videoApiKey) { if (videoApiKey) {
env.SEEDANCE_API_KEY = videoApiKey;
env.SEEDANCE_ARK_API_KEY = videoApiKey; env.SEEDANCE_ARK_API_KEY = videoApiKey;
} }
if (videoModelId) { if (videoModelId) {
env.SEEDANCE_MODEL = videoModelId; env.SEEDANCE_MODEL = videoModelId;
} }
if (videoAnalyzerBaseUrl) {
env.VIDEO_LLM_ANALYZER_BASE_URL = videoAnalyzerBaseUrl;
env.ARK_BASE_URL = videoAnalyzerBaseUrl;
} else if (videoBaseUrl) {
env.ARK_BASE_URL = videoBaseUrl;
}
if (videoAnalyzerApiKey) {
env.VIDEO_LLM_ANALYZER_API_KEY = videoAnalyzerApiKey;
env.ARK_API_KEY = videoAnalyzerApiKey;
} else if (videoApiKey) {
env.ARK_API_KEY = videoApiKey;
}
if (videoAnalyzerModelId) {
env.VIDEO_LLM_ANALYZER_MODEL = videoAnalyzerModelId;
}
if (replicationBriefBaseUrl) {
env.REPLICATION_BRIEF_BASE_URL = replicationBriefBaseUrl;
}
if (replicationBriefApiKey) {
env.REPLICATION_BRIEF_API_KEY = replicationBriefApiKey;
}
if (replicationBriefModelId) {
env.REPLICATION_BRIEF_MODEL = replicationBriefModelId;
}
if (vectCutBaseUrl) {
env.VECTCUT_BASE_URL = vectCutBaseUrl;
}
if (vectCutFileBaseUrl) {
env.VECTCUT_FILE_BASE_URL = vectCutFileBaseUrl;
}
if (vectCutApiKey) {
env.VECTCUT_API_KEY = vectCutApiKey;
}
if (digitalHumanVolcAccessKey) { if (digitalHumanVolcAccessKey) {
env.OMNIHUMAN_VOLC_ACCESS_KEY = digitalHumanVolcAccessKey; env.OMNIHUMAN_VOLC_ACCESS_KEY = digitalHumanVolcAccessKey;
env.VOLC_ACCESS_KEY = digitalHumanVolcAccessKey;
} }
if (digitalHumanVolcSecretKey) { if (digitalHumanVolcSecretKey) {
env.OMNIHUMAN_VOLC_SECRET_KEY = digitalHumanVolcSecretKey; env.OMNIHUMAN_VOLC_SECRET_KEY = digitalHumanVolcSecretKey;
env.VOLC_SECRET_KEY = digitalHumanVolcSecretKey;
} }
env.OMNIHUMAN_VOLC_REGION = FIXED_DIGITAL_HUMAN_CONFIG.volcRegion; env.OMNIHUMAN_VOLC_REGION = FIXED_DIGITAL_HUMAN_CONFIG.volcRegion;
env.OMNIHUMAN_VOLC_SERVICE = FIXED_DIGITAL_HUMAN_CONFIG.volcService; env.OMNIHUMAN_VOLC_SERVICE = FIXED_DIGITAL_HUMAN_CONFIG.volcService;
env.OMNIHUMAN_VOLC_HOST = FIXED_DIGITAL_HUMAN_CONFIG.volcHost; env.OMNIHUMAN_VOLC_HOST = FIXED_DIGITAL_HUMAN_CONFIG.volcHost;
env.OMNIHUMAN_VOLC_SCHEME = FIXED_DIGITAL_HUMAN_CONFIG.volcScheme; env.OMNIHUMAN_VOLC_SCHEME = FIXED_DIGITAL_HUMAN_CONFIG.volcScheme;
env.OMNIHUMAN_TTS_VOICE = FIXED_DIGITAL_HUMAN_CONFIG.ttsVoice; env.OMNIHUMAN_TTS_VOICE = FIXED_DIGITAL_HUMAN_CONFIG.ttsVoice;
env.VOLC_REGION = FIXED_DIGITAL_HUMAN_CONFIG.volcRegion;
env.VOLC_SERVICE = FIXED_DIGITAL_HUMAN_CONFIG.volcService;
env.VOLC_HOST = FIXED_DIGITAL_HUMAN_CONFIG.volcHost;
env.VOLC_SCHEME = FIXED_DIGITAL_HUMAN_CONFIG.volcScheme;
if (digitalHumanQiniuAccessKey) { if (digitalHumanQiniuAccessKey) {
env.OMNIHUMAN_QINIU_ACCESS_KEY = digitalHumanQiniuAccessKey; env.OMNIHUMAN_QINIU_ACCESS_KEY = digitalHumanQiniuAccessKey;
env.QINIU_ACCESS_KEY = digitalHumanQiniuAccessKey;
env.SEEDANCE_QINIU_ACCESS_KEY = digitalHumanQiniuAccessKey;
} }
if (digitalHumanQiniuSecretKey) { if (digitalHumanQiniuSecretKey) {
env.OMNIHUMAN_QINIU_SECRET_KEY = digitalHumanQiniuSecretKey; env.OMNIHUMAN_QINIU_SECRET_KEY = digitalHumanQiniuSecretKey;
env.QINIU_SECRET_KEY = digitalHumanQiniuSecretKey;
env.SEEDANCE_QINIU_SECRET_KEY = digitalHumanQiniuSecretKey;
} }
env.OMNIHUMAN_QINIU_BUCKET = FIXED_DIGITAL_HUMAN_CONFIG.qiniuBucket; env.OMNIHUMAN_QINIU_BUCKET = FIXED_DIGITAL_HUMAN_CONFIG.qiniuBucket;
env.OMNIHUMAN_QINIU_DOMAIN = FIXED_DIGITAL_HUMAN_CONFIG.qiniuDomain; env.OMNIHUMAN_QINIU_DOMAIN = FIXED_DIGITAL_HUMAN_CONFIG.qiniuDomain;
env.OMNIHUMAN_QINIU_KEY_PREFIX = FIXED_DIGITAL_HUMAN_CONFIG.qiniuKeyPrefix; env.OMNIHUMAN_QINIU_KEY_PREFIX = FIXED_DIGITAL_HUMAN_CONFIG.qiniuKeyPrefix;
env.QINIU_BUCKET = FIXED_DIGITAL_HUMAN_CONFIG.qiniuBucket;
env.QINIU_DOMAIN = FIXED_DIGITAL_HUMAN_CONFIG.qiniuDomain;
env.QINIU_KEY_PREFIX = FIXED_DIGITAL_HUMAN_CONFIG.qiniuKeyPrefix;
env.SEEDANCE_QINIU_BUCKET = FIXED_DIGITAL_HUMAN_CONFIG.qiniuBucket;
env.SEEDANCE_QINIU_DOMAIN = FIXED_DIGITAL_HUMAN_CONFIG.qiniuDomain;
env.SEEDANCE_QINIU_KEY_PREFIX = FIXED_DIGITAL_HUMAN_CONFIG.qiniuKeyPrefix;
} }
const envKeys = Object.keys(env).sort(); const envKeys = Object.keys(env).sort();
......
...@@ -14,6 +14,9 @@ interface SecretRecord { ...@@ -14,6 +14,9 @@ interface SecretRecord {
digitalHumanVolcSecretKey?: string; digitalHumanVolcSecretKey?: string;
digitalHumanQiniuAccessKey?: string; digitalHumanQiniuAccessKey?: string;
digitalHumanQiniuSecretKey?: string; digitalHumanQiniuSecretKey?: string;
videoAnalyzerApiKey?: string;
replicationBriefApiKey?: string;
vectCutApiKey?: string;
} }
interface SecretAccessor { interface SecretAccessor {
...@@ -32,7 +35,10 @@ type SecretName = ...@@ -32,7 +35,10 @@ type SecretName =
| "digitalHumanVolcAccessKey" | "digitalHumanVolcAccessKey"
| "digitalHumanVolcSecretKey" | "digitalHumanVolcSecretKey"
| "digitalHumanQiniuAccessKey" | "digitalHumanQiniuAccessKey"
| "digitalHumanQiniuSecretKey"; | "digitalHumanQiniuSecretKey"
| "videoAnalyzerApiKey"
| "replicationBriefApiKey"
| "vectCutApiKey";
type KeytarModule = typeof import("keytar"); type KeytarModule = typeof import("keytar");
const KEYTAR_SERVICE = "QianjiangClaw"; const KEYTAR_SERVICE = "QianjiangClaw";
...@@ -48,7 +54,10 @@ const KEYTAR_ACCOUNT_MAP: Record<SecretName, string> = { ...@@ -48,7 +54,10 @@ const KEYTAR_ACCOUNT_MAP: Record<SecretName, string> = {
digitalHumanVolcAccessKey: "digital-human-volc-access-key", digitalHumanVolcAccessKey: "digital-human-volc-access-key",
digitalHumanVolcSecretKey: "digital-human-volc-secret-key", digitalHumanVolcSecretKey: "digital-human-volc-secret-key",
digitalHumanQiniuAccessKey: "digital-human-qiniu-access-key", digitalHumanQiniuAccessKey: "digital-human-qiniu-access-key",
digitalHumanQiniuSecretKey: "digital-human-qiniu-secret-key" digitalHumanQiniuSecretKey: "digital-human-qiniu-secret-key",
videoAnalyzerApiKey: "douyin-video-analyzer-api-key",
replicationBriefApiKey: "douyin-replication-brief-api-key",
vectCutApiKey: "douyin-vectcut-api-key"
}; };
class FileSecretStore implements SecretAccessor { class FileSecretStore implements SecretAccessor {
...@@ -244,6 +253,30 @@ export class SecretManager { ...@@ -244,6 +253,30 @@ export class SecretManager {
return this.store.get("digitalHumanQiniuSecretKey"); return this.store.get("digitalHumanQiniuSecretKey");
} }
async setVideoAnalyzerApiKey(value?: string): Promise<void> {
await this.store.set("videoAnalyzerApiKey", value);
}
async getVideoAnalyzerApiKey(): Promise<string | undefined> {
return this.store.get("videoAnalyzerApiKey");
}
async setReplicationBriefApiKey(value?: string): Promise<void> {
await this.store.set("replicationBriefApiKey", value);
}
async getReplicationBriefApiKey(): Promise<string | undefined> {
return this.store.get("replicationBriefApiKey");
}
async setVectCutApiKey(value?: string): Promise<void> {
await this.store.set("vectCutApiKey", value);
}
async getVectCutApiKey(): Promise<string | undefined> {
return this.store.get("vectCutApiKey");
}
private async tryLoadKeytar(): Promise<KeytarModule | null> { private async tryLoadKeytar(): Promise<KeytarModule | null> {
try { try {
const imported = await import("keytar"); const imported = await import("keytar");
...@@ -265,7 +298,10 @@ export class SecretManager { ...@@ -265,7 +298,10 @@ export class SecretManager {
"digitalHumanVolcAccessKey", "digitalHumanVolcAccessKey",
"digitalHumanVolcSecretKey", "digitalHumanVolcSecretKey",
"digitalHumanQiniuAccessKey", "digitalHumanQiniuAccessKey",
"digitalHumanQiniuSecretKey" "digitalHumanQiniuSecretKey",
"videoAnalyzerApiKey",
"replicationBriefApiKey",
"vectCutApiKey"
] as const) { ] as const) {
const existing = await this.store.get(secretName); const existing = await this.store.get(secretName);
if (existing) { if (existing) {
......
...@@ -85,6 +85,7 @@ const desktopApi: DesktopApi = { ...@@ -85,6 +85,7 @@ const desktopApi: DesktopApi = {
createSessionForProject: (projectId: string, title?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatCreateSessionForProject, projectId, title), createSessionForProject: (projectId: string, title?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatCreateSessionForProject, projectId, title),
closeSession: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatCloseSession, sessionId), closeSession: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatCloseSession, sessionId),
listMessages: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatListMessages, sessionId), listMessages: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatListMessages, sessionId),
pickAttachments: () => ipcRenderer.invoke(IPC_CHANNELS.chatPickAttachments),
pickImageAttachment: () => ipcRenderer.invoke(IPC_CHANNELS.chatPickImageAttachment), pickImageAttachment: () => ipcRenderer.invoke(IPC_CHANNELS.chatPickImageAttachment),
sendPrompt: (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => ipcRenderer.invoke(IPC_CHANNELS.chatSendPrompt, sessionId, prompt, skillId, attachments), sendPrompt: (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => ipcRenderer.invoke(IPC_CHANNELS.chatSendPrompt, sessionId, prompt, skillId, attachments),
streamPrompt: (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => ipcRenderer.invoke(IPC_CHANNELS.chatStreamPrompt, sessionId, prompt, skillId, attachments), streamPrompt: (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => ipcRenderer.invoke(IPC_CHANNELS.chatStreamPrompt, sessionId, prompt, skillId, attachments),
......
...@@ -26,6 +26,7 @@ import type { ...@@ -26,6 +26,7 @@ import type {
} from "@qjclaw/shared-types"; } from "@qjclaw/shared-types";
import { import {
FIXED_DIGITAL_HUMAN_CONFIG, FIXED_DIGITAL_HUMAN_CONFIG,
FIXED_DOUYIN_RUNTIME_CONFIG,
FIXED_EXPERT_MODEL_ENDPOINTS FIXED_EXPERT_MODEL_ENDPOINTS
} from "@qjclaw/shared-types"; } from "@qjclaw/shared-types";
...@@ -133,6 +134,10 @@ const SUCCESS_NOTICE_TIMEOUT_MS = 2400; ...@@ -133,6 +134,10 @@ const SUCCESS_NOTICE_TIMEOUT_MS = 2400;
const TYPEWRITER_CHARS_PER_FRAME = 3; const TYPEWRITER_CHARS_PER_FRAME = 3;
const MAX_TRACE_ITEMS = 60; const MAX_TRACE_ITEMS = 60;
const HOME_EXPERT_SUGGESTION_PROJECT_IDS = new Set(["xhs", "douyin"]); const HOME_EXPERT_SUGGESTION_PROJECT_IDS = new Set(["xhs", "douyin"]);
const IMAGE_ATTACHMENT_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
const DOCUMENT_ATTACHMENT_EXTENSIONS = new Set([".pdf", ".ppt", ".pptx", ".xls", ".xlsx", ".csv", ".tsv", ".doc", ".docx", ".txt", ".md", ".json", ".mp3"]);
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([...IMAGE_ATTACHMENT_EXTENSIONS, ...DOCUMENT_ATTACHMENT_EXTENSIONS]);
const COMPOSER_ATTACHMENT_ACCEPT = [...SUPPORTED_ATTACHMENT_EXTENSIONS].join(",");
function shouldOfferHomeExpertSwitch(prompt: string): boolean { function shouldOfferHomeExpertSwitch(prompt: string): boolean {
const normalized = prompt.normalize("NFKC").toLowerCase(); const normalized = prompt.normalize("NFKC").toLowerCase();
...@@ -988,6 +993,20 @@ const mockDesktopApi = { ...@@ -988,6 +993,20 @@ const mockDesktopApi = {
qiniuAccessKeyConfigured: false, qiniuAccessKeyConfigured: false,
qiniuSecretKeyConfigured: false qiniuSecretKeyConfigured: false
} }
},
douyinRuntimeConfig: {
videoAnalyzer: {
...FIXED_DOUYIN_RUNTIME_CONFIG.videoAnalyzer,
apiKeyConfigured: false,
},
replicationBrief: {
...FIXED_DOUYIN_RUNTIME_CONFIG.replicationBrief,
apiKeyConfigured: false,
},
vectcut: {
...FIXED_DOUYIN_RUNTIME_CONFIG.vectcut,
apiKeyConfigured: false
}
} }
}), }),
pickWorkspaceDirectory: async (currentPath?: string) => currentPath || "D:/workspace", pickWorkspaceDirectory: async (currentPath?: string) => currentPath || "D:/workspace",
...@@ -1024,6 +1043,23 @@ const mockDesktopApi = { ...@@ -1024,6 +1043,23 @@ const mockDesktopApi = {
qiniuAccessKeyConfigured: Boolean(input.expertModelConfig?.digitalHuman?.qiniuAccessKey?.trim()), qiniuAccessKeyConfigured: Boolean(input.expertModelConfig?.digitalHuman?.qiniuAccessKey?.trim()),
qiniuSecretKeyConfigured: Boolean(input.expertModelConfig?.digitalHuman?.qiniuSecretKey?.trim()) qiniuSecretKeyConfigured: Boolean(input.expertModelConfig?.digitalHuman?.qiniuSecretKey?.trim())
} }
},
douyinRuntimeConfig: {
videoAnalyzer: {
baseUrl: input.douyinRuntimeConfig?.videoAnalyzer?.baseUrl?.trim() || FIXED_DOUYIN_RUNTIME_CONFIG.videoAnalyzer.baseUrl,
apiKeyConfigured: Boolean(input.douyinRuntimeConfig?.videoAnalyzer?.apiKey?.trim()),
modelId: input.douyinRuntimeConfig?.videoAnalyzer?.modelId?.trim() || ""
},
replicationBrief: {
baseUrl: input.douyinRuntimeConfig?.replicationBrief?.baseUrl?.trim() || FIXED_DOUYIN_RUNTIME_CONFIG.replicationBrief.baseUrl,
apiKeyConfigured: Boolean(input.douyinRuntimeConfig?.replicationBrief?.apiKey?.trim()),
modelId: input.douyinRuntimeConfig?.replicationBrief?.modelId?.trim() || ""
},
vectcut: {
baseUrl: input.douyinRuntimeConfig?.vectcut?.baseUrl?.trim() || FIXED_DOUYIN_RUNTIME_CONFIG.vectcut.baseUrl,
fileBaseUrl: input.douyinRuntimeConfig?.vectcut?.fileBaseUrl?.trim() || FIXED_DOUYIN_RUNTIME_CONFIG.vectcut.fileBaseUrl,
apiKeyConfigured: Boolean(input.douyinRuntimeConfig?.vectcut?.apiKey?.trim())
}
} }
}) })
}, },
...@@ -1061,6 +1097,7 @@ const mockDesktopApi = { ...@@ -1061,6 +1097,7 @@ const mockDesktopApi = {
createSessionForProject: async (projectId: string, title?: string) => ({ id: `project:${projectId}:${createClientMessageId("session")}`, projectId, title: title || "\u65b0\u5bf9\u8bdd", updatedAt: new Date().toISOString() }), createSessionForProject: async (projectId: string, title?: string) => ({ id: `project:${projectId}:${createClientMessageId("session")}`, projectId, title: title || "\u65b0\u5bf9\u8bdd", updatedAt: new Date().toISOString() }),
closeSession: async (sessionId: string) => getMockSessions(sessionId.split(":")[1]), closeSession: async (sessionId: string) => getMockSessions(sessionId.split(":")[1]),
listMessages: async () => [], listMessages: async () => [],
pickAttachments: async () => [],
pickImageAttachment: async () => null, pickImageAttachment: async () => null,
sendPrompt: async (sessionId: string, prompt: string, skillId?: string, _attachments?: ChatAttachment[]) => ({ sessionId: sessionId || "project:xiaohongshu:default", reply: { id: "reply-1", role: "assistant", content: "Mock: " + prompt, createdAt: new Date().toISOString() }, executionPolicy: { source: "client-config", modelId: "qwen3.5-plus", modelLabel: "qwen3.5-plus", routingMode: "platform-managed", skillId, skillName: skillId, message: "mock" } }), sendPrompt: async (sessionId: string, prompt: string, skillId?: string, _attachments?: ChatAttachment[]) => ({ sessionId: sessionId || "project:xiaohongshu:default", reply: { id: "reply-1", role: "assistant", content: "Mock: " + prompt, createdAt: new Date().toISOString() }, executionPolicy: { source: "client-config", modelId: "qwen3.5-plus", modelLabel: "qwen3.5-plus", routingMode: "platform-managed", skillId, skillName: skillId, message: "mock" } }),
streamPrompt: async (_sessionId: string, prompt: string, skillId?: string, _attachments?: ChatAttachment[]) => { streamPrompt: async (_sessionId: string, prompt: string, skillId?: string, _attachments?: ChatAttachment[]) => {
...@@ -1215,9 +1252,11 @@ declare global { ...@@ -1215,9 +1252,11 @@ declare global {
lobsterKey?: string; lobsterKey?: string;
workspacePath?: string; workspacePath?: string;
expertModelConfig?: SaveConfigInput["expertModelConfig"]; expertModelConfig?: SaveConfigInput["expertModelConfig"];
douyinRuntimeConfig?: SaveConfigInput["douyinRuntimeConfig"];
}): Promise<{ }): Promise<{
workspacePath: string; workspacePath: string;
expertModelConfig: AppConfig["expertModelConfig"]; expertModelConfig: AppConfig["expertModelConfig"];
douyinRuntimeConfig: AppConfig["douyinRuntimeConfig"];
apiKeyConfigured: boolean; apiKeyConfigured: boolean;
}>; }>;
createProjectSession(projectId?: string, title?: string): Promise<SessionSummary>; createProjectSession(projectId?: string, title?: string): Promise<SessionSummary>;
...@@ -2030,7 +2069,7 @@ export default function App() { ...@@ -2030,7 +2069,7 @@ export default function App() {
const [projectActionPending, setProjectActionPending] = useState(false); const [projectActionPending, setProjectActionPending] = useState(false);
const [selectedSkillId, setSelectedSkillId] = useState(DEFAULT_SKILL.id); const [selectedSkillId, setSelectedSkillId] = useState(DEFAULT_SKILL.id);
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [composerAttachment, setComposerAttachment] = useState<ChatAttachment | null>(null); const [composerAttachments, setComposerAttachments] = useState<ChatAttachment[]>([]);
const [lobsterKeyDraft, setLobsterKeyDraft] = useState(""); const [lobsterKeyDraft, setLobsterKeyDraft] = useState("");
const [workspacePathDraft, setWorkspacePathDraft] = useState(""); const [workspacePathDraft, setWorkspacePathDraft] = useState("");
const [imageModelBaseUrlDraft, setImageModelBaseUrlDraft] = useState<string>(FIXED_EXPERT_MODEL_ENDPOINTS.image.baseUrl); const [imageModelBaseUrlDraft, setImageModelBaseUrlDraft] = useState<string>(FIXED_EXPERT_MODEL_ENDPOINTS.image.baseUrl);
...@@ -2045,6 +2084,15 @@ export default function App() { ...@@ -2045,6 +2084,15 @@ export default function App() {
const [digitalHumanVolcSecretKeyDraft, setDigitalHumanVolcSecretKeyDraft] = useState(""); const [digitalHumanVolcSecretKeyDraft, setDigitalHumanVolcSecretKeyDraft] = useState("");
const [digitalHumanQiniuAccessKeyDraft, setDigitalHumanQiniuAccessKeyDraft] = useState(""); const [digitalHumanQiniuAccessKeyDraft, setDigitalHumanQiniuAccessKeyDraft] = useState("");
const [digitalHumanQiniuSecretKeyDraft, setDigitalHumanQiniuSecretKeyDraft] = useState(""); const [digitalHumanQiniuSecretKeyDraft, setDigitalHumanQiniuSecretKeyDraft] = useState("");
const [videoAnalyzerBaseUrlDraft, setVideoAnalyzerBaseUrlDraft] = useState("");
const [videoAnalyzerModelIdDraft, setVideoAnalyzerModelIdDraft] = useState("");
const [videoAnalyzerApiKeyDraft, setVideoAnalyzerApiKeyDraft] = useState("");
const [replicationBriefBaseUrlDraft, setReplicationBriefBaseUrlDraft] = useState("");
const [replicationBriefModelIdDraft, setReplicationBriefModelIdDraft] = useState("");
const [replicationBriefApiKeyDraft, setReplicationBriefApiKeyDraft] = useState("");
const [vectcutBaseUrlDraft, setVectcutBaseUrlDraft] = useState("");
const [vectcutFileBaseUrlDraft, setVectcutFileBaseUrlDraft] = useState("");
const [vectcutApiKeyDraft, setVectcutApiKeyDraft] = useState("");
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
...@@ -2172,7 +2220,7 @@ export default function App() { ...@@ -2172,7 +2220,7 @@ export default function App() {
const hasVisibleConversation = messages.length > 0 || sendPhase !== "idle"; const hasVisibleConversation = messages.length > 0 || sendPhase !== "idle";
const showStartupOverlay = startupStateActive && !hasVisibleConversation; const showStartupOverlay = startupStateActive && !hasVisibleConversation;
const sending = sendPhase !== "idle"; const sending = sendPhase !== "idle";
const canSend = isBound && hasConversationProject && (prompt.trim().length > 0 || Boolean(composerAttachment)) && !sending && !saving; const canSend = isBound && hasConversationProject && (prompt.trim().length > 0 || composerAttachments.length > 0) && !sending && !saving;
const hasPendingLobsterKey = lobsterKeyDraft.trim().length > 0; const hasPendingLobsterKey = lobsterKeyDraft.trim().length > 0;
const hasPendingModelKeys = Boolean( const hasPendingModelKeys = Boolean(
imageModelApiKeyDraft.trim() imageModelApiKeyDraft.trim()
...@@ -2182,6 +2230,15 @@ export default function App() { ...@@ -2182,6 +2230,15 @@ export default function App() {
|| digitalHumanVolcSecretKeyDraft.trim() || digitalHumanVolcSecretKeyDraft.trim()
|| digitalHumanQiniuAccessKeyDraft.trim() || digitalHumanQiniuAccessKeyDraft.trim()
|| digitalHumanQiniuSecretKeyDraft.trim() || digitalHumanQiniuSecretKeyDraft.trim()
|| videoAnalyzerBaseUrlDraft.trim() !== (config?.douyinRuntimeConfig.videoAnalyzer.baseUrl ?? "").trim()
|| videoAnalyzerModelIdDraft.trim() !== (config?.douyinRuntimeConfig.videoAnalyzer.modelId ?? "").trim()
|| videoAnalyzerApiKeyDraft.trim()
|| replicationBriefBaseUrlDraft.trim() !== (config?.douyinRuntimeConfig.replicationBrief.baseUrl ?? "").trim()
|| replicationBriefModelIdDraft.trim() !== (config?.douyinRuntimeConfig.replicationBrief.modelId ?? "").trim()
|| replicationBriefApiKeyDraft.trim()
|| vectcutBaseUrlDraft.trim() !== (config?.douyinRuntimeConfig.vectcut.baseUrl ?? "").trim()
|| vectcutFileBaseUrlDraft.trim() !== (config?.douyinRuntimeConfig.vectcut.fileBaseUrl ?? "").trim()
|| vectcutApiKeyDraft.trim()
); );
const sendButtonLabel = sendPhase === "preparing" const sendButtonLabel = sendPhase === "preparing"
? ui.preparing ? ui.preparing
...@@ -2560,6 +2617,12 @@ export default function App() { ...@@ -2560,6 +2617,12 @@ export default function App() {
setVideoModelBaseUrlDraft(config.expertModelConfig.video.baseUrl); setVideoModelBaseUrlDraft(config.expertModelConfig.video.baseUrl);
setCopywritingModelBaseUrlDraft(config.expertModelConfig.copywriting.baseUrl); setCopywritingModelBaseUrlDraft(config.expertModelConfig.copywriting.baseUrl);
setCopywritingModelIdDraft(config.expertModelConfig.copywriting.modelId ?? FIXED_EXPERT_MODEL_ENDPOINTS.copywriting.modelId); setCopywritingModelIdDraft(config.expertModelConfig.copywriting.modelId ?? FIXED_EXPERT_MODEL_ENDPOINTS.copywriting.modelId);
setVideoAnalyzerBaseUrlDraft(config.douyinRuntimeConfig.videoAnalyzer.baseUrl);
setVideoAnalyzerModelIdDraft(config.douyinRuntimeConfig.videoAnalyzer.modelId ?? "");
setReplicationBriefBaseUrlDraft(config.douyinRuntimeConfig.replicationBrief.baseUrl);
setReplicationBriefModelIdDraft(config.douyinRuntimeConfig.replicationBrief.modelId ?? "");
setVectcutBaseUrlDraft(config.douyinRuntimeConfig.vectcut.baseUrl);
setVectcutFileBaseUrlDraft(config.douyinRuntimeConfig.vectcut.fileBaseUrl);
}, [config]); }, [config]);
useEffect(() => { useEffect(() => {
...@@ -2787,7 +2850,7 @@ export default function App() { ...@@ -2787,7 +2850,7 @@ export default function App() {
? options.attachments.filter((attachment): attachment is ChatAttachment => ? options.attachments.filter((attachment): attachment is ChatAttachment =>
Boolean( Boolean(
attachment attachment
&& attachment.kind === "image" && (attachment.kind === "image" || attachment.kind === "file")
&& attachment.localPath?.trim() && attachment.localPath?.trim()
)) ))
: undefined; : undefined;
...@@ -2885,9 +2948,11 @@ export default function App() { ...@@ -2885,9 +2948,11 @@ export default function App() {
lobsterKey?: string; lobsterKey?: string;
workspacePath?: string; workspacePath?: string;
expertModelConfig?: SaveConfigInput["expertModelConfig"]; expertModelConfig?: SaveConfigInput["expertModelConfig"];
douyinRuntimeConfig?: SaveConfigInput["douyinRuntimeConfig"];
}) => { }) => {
const nextWorkspacePath = options?.workspacePath; const nextWorkspacePath = options?.workspacePath;
const nextExpertModelConfig = options?.expertModelConfig; const nextExpertModelConfig = options?.expertModelConfig;
const nextDouyinRuntimeConfig = options?.douyinRuntimeConfig;
if (typeof nextWorkspacePath === "string") { if (typeof nextWorkspacePath === "string") {
setWorkspacePathDraft(nextWorkspacePath); setWorkspacePathDraft(nextWorkspacePath);
} }
...@@ -2906,6 +2971,21 @@ export default function App() { ...@@ -2906,6 +2971,21 @@ export default function App() {
setDigitalHumanQiniuAccessKeyDraft(nextExpertModelConfig.digitalHuman.qiniuAccessKey ?? ""); setDigitalHumanQiniuAccessKeyDraft(nextExpertModelConfig.digitalHuman.qiniuAccessKey ?? "");
setDigitalHumanQiniuSecretKeyDraft(nextExpertModelConfig.digitalHuman.qiniuSecretKey ?? ""); setDigitalHumanQiniuSecretKeyDraft(nextExpertModelConfig.digitalHuman.qiniuSecretKey ?? "");
} }
if (nextDouyinRuntimeConfig?.videoAnalyzer) {
setVideoAnalyzerBaseUrlDraft(nextDouyinRuntimeConfig.videoAnalyzer.baseUrl ?? "");
setVideoAnalyzerModelIdDraft(nextDouyinRuntimeConfig.videoAnalyzer.modelId ?? "");
setVideoAnalyzerApiKeyDraft(nextDouyinRuntimeConfig.videoAnalyzer.apiKey ?? "");
}
if (nextDouyinRuntimeConfig?.replicationBrief) {
setReplicationBriefBaseUrlDraft(nextDouyinRuntimeConfig.replicationBrief.baseUrl ?? "");
setReplicationBriefModelIdDraft(nextDouyinRuntimeConfig.replicationBrief.modelId ?? "");
setReplicationBriefApiKeyDraft(nextDouyinRuntimeConfig.replicationBrief.apiKey ?? "");
}
if (nextDouyinRuntimeConfig?.vectcut) {
setVectcutBaseUrlDraft(nextDouyinRuntimeConfig.vectcut.baseUrl ?? "");
setVectcutFileBaseUrlDraft(nextDouyinRuntimeConfig.vectcut.fileBaseUrl ?? "");
setVectcutApiKeyDraft(nextDouyinRuntimeConfig.vectcut.apiKey ?? "");
}
if (typeof options?.lobsterKey === "string") { if (typeof options?.lobsterKey === "string") {
setLobsterKeyDraft(options.lobsterKey); setLobsterKeyDraft(options.lobsterKey);
} }
...@@ -2913,7 +2993,8 @@ export default function App() { ...@@ -2913,7 +2993,8 @@ export default function App() {
await saveConfig({ await saveConfig({
lobsterKey: options?.lobsterKey, lobsterKey: options?.lobsterKey,
workspacePath: nextWorkspacePath, workspacePath: nextWorkspacePath,
expertModelConfig: nextExpertModelConfig expertModelConfig: nextExpertModelConfig,
douyinRuntimeConfig: nextDouyinRuntimeConfig
}); });
const latestConfig = await desktopApi.config.load(); const latestConfig = await desktopApi.config.load();
...@@ -2926,11 +3007,15 @@ export default function App() { ...@@ -2926,11 +3007,15 @@ export default function App() {
setDigitalHumanVolcSecretKeyDraft(""); setDigitalHumanVolcSecretKeyDraft("");
setDigitalHumanQiniuAccessKeyDraft(""); setDigitalHumanQiniuAccessKeyDraft("");
setDigitalHumanQiniuSecretKeyDraft(""); setDigitalHumanQiniuSecretKeyDraft("");
setVideoAnalyzerApiKeyDraft("");
setReplicationBriefApiKeyDraft("");
setVectcutApiKeyDraft("");
await waitForSmokeConfigPublish(latestConfig.expertModelConfig); await waitForSmokeConfigPublish(latestConfig.expertModelConfig);
return { return {
workspacePath: latestConfig.workspacePath, workspacePath: latestConfig.workspacePath,
expertModelConfig: latestConfig.expertModelConfig, expertModelConfig: latestConfig.expertModelConfig,
douyinRuntimeConfig: latestConfig.douyinRuntimeConfig,
apiKeyConfigured: latestConfig.apiKeyConfigured apiKeyConfigured: latestConfig.apiKeyConfigured
}; };
}, },
...@@ -2960,7 +3045,7 @@ export default function App() { ...@@ -2960,7 +3045,7 @@ export default function App() {
suggestion, suggestion,
prompt: currentPrompt, prompt: currentPrompt,
skillId: selectedSkill.id === DEFAULT_SKILL.id ? undefined : selectedSkill.id, skillId: selectedSkill.id === DEFAULT_SKILL.id ? undefined : selectedSkill.id,
attachments: composerAttachment ? [composerAttachment] : undefined attachments: composerAttachments.length ? composerAttachments : undefined
} satisfies PendingHomeIntentSuggestion; } satisfies PendingHomeIntentSuggestion;
setPendingHomeIntentSuggestion(nextPendingSuggestion); setPendingHomeIntentSuggestion(nextPendingSuggestion);
setSkillMenuOpen(false); setSkillMenuOpen(false);
...@@ -3431,6 +3516,7 @@ export default function App() { ...@@ -3431,6 +3516,7 @@ export default function App() {
lobsterKey?: string; lobsterKey?: string;
workspacePath?: string; workspacePath?: string;
expertModelConfig?: SaveConfigInput["expertModelConfig"]; expertModelConfig?: SaveConfigInput["expertModelConfig"];
douyinRuntimeConfig?: SaveConfigInput["douyinRuntimeConfig"];
successMessage?: string; successMessage?: string;
}) { }) {
if (!config) { if (!config) {
...@@ -3456,6 +3542,23 @@ export default function App() { ...@@ -3456,6 +3542,23 @@ export default function App() {
qiniuSecretKey: digitalHumanQiniuSecretKeyDraft.trim() || undefined qiniuSecretKey: digitalHumanQiniuSecretKeyDraft.trim() || undefined
} }
}; };
const resolvedDouyinRuntimeConfig = options?.douyinRuntimeConfig ?? {
videoAnalyzer: {
baseUrl: videoAnalyzerBaseUrlDraft.trim() || undefined,
modelId: videoAnalyzerModelIdDraft.trim() || undefined,
apiKey: videoAnalyzerApiKeyDraft.trim() || undefined
},
replicationBrief: {
baseUrl: replicationBriefBaseUrlDraft.trim() || undefined,
modelId: replicationBriefModelIdDraft.trim() || undefined,
apiKey: replicationBriefApiKeyDraft.trim() || undefined
},
vectcut: {
baseUrl: vectcutBaseUrlDraft.trim() || undefined,
fileBaseUrl: vectcutFileBaseUrlDraft.trim() || undefined,
apiKey: vectcutApiKeyDraft.trim() || undefined
}
};
const input: SaveConfigInput = { const input: SaveConfigInput = {
setupMode: config.setupMode, setupMode: config.setupMode,
provider: config.provider, provider: config.provider,
...@@ -3467,6 +3570,7 @@ export default function App() { ...@@ -3467,6 +3570,7 @@ export default function App() {
runtimeCloudApiBaseUrl: config.runtimeCloudApiBaseUrl.trim(), runtimeCloudApiBaseUrl: config.runtimeCloudApiBaseUrl.trim(),
runtimeMode: "bundled-runtime", runtimeMode: "bundled-runtime",
expertModelConfig: resolvedExpertModelConfig, expertModelConfig: resolvedExpertModelConfig,
douyinRuntimeConfig: resolvedDouyinRuntimeConfig,
...(trimmedLobsterKey ? { apiKey: trimmedLobsterKey } : {}) ...(trimmedLobsterKey ? { apiKey: trimmedLobsterKey } : {})
}; };
...@@ -3486,6 +3590,9 @@ export default function App() { ...@@ -3486,6 +3590,9 @@ export default function App() {
setDigitalHumanVolcSecretKeyDraft(""); setDigitalHumanVolcSecretKeyDraft("");
setDigitalHumanQiniuAccessKeyDraft(""); setDigitalHumanQiniuAccessKeyDraft("");
setDigitalHumanQiniuSecretKeyDraft(""); setDigitalHumanQiniuSecretKeyDraft("");
setVideoAnalyzerApiKeyDraft("");
setReplicationBriefApiKeyDraft("");
setVectcutApiKeyDraft("");
setInfoText(options?.successMessage ?? (trimmedLobsterKey ? ui.saveSuccessPending : ui.saveSuccessApplied)); setInfoText(options?.successMessage ?? (trimmedLobsterKey ? ui.saveSuccessPending : ui.saveSuccessApplied));
void refresh(false); void refresh(false);
} catch (error) { } catch (error) {
...@@ -3585,15 +3692,15 @@ export default function App() { ...@@ -3585,15 +3692,15 @@ export default function App() {
const trimmedPrompt = promptText.trim(); const trimmedPrompt = promptText.trim();
const attachmentsToSend = forcedAttachments?.length const attachmentsToSend = forcedAttachments?.length
? forcedAttachments ? forcedAttachments
: composerAttachment : composerAttachments.length
? [composerAttachment] ? composerAttachments
: undefined; : undefined;
if ((!trimmedPrompt && !attachmentsToSend?.length) || sending || saving) { if ((!trimmedPrompt && !attachmentsToSend?.length) || sending || saving) {
return; return;
} }
const skillId = requestedSkillId === DEFAULT_SKILL.id ? undefined : requestedSkillId; const skillId = requestedSkillId === DEFAULT_SKILL.id ? undefined : requestedSkillId;
const renderedPrompt = trimmedPrompt || (attachmentsToSend?.length ? `[图片] ${attachmentsToSend[0].name}` : ""); const renderedPrompt = trimmedPrompt || (attachmentsToSend?.length ? buildAttachmentPromptSummary(attachmentsToSend) : "");
const userMessage = buildUserMessage(renderedPrompt); const userMessage = buildUserMessage(renderedPrompt);
const assistantMessage = buildAssistantPlaceholder(ui.preparingReply); const assistantMessage = buildAssistantPlaceholder(ui.preparingReply);
...@@ -3744,7 +3851,7 @@ export default function App() { ...@@ -3744,7 +3851,7 @@ export default function App() {
const skillId = selectedSkill.id === DEFAULT_SKILL.id ? undefined : selectedSkill.id; const skillId = selectedSkill.id === DEFAULT_SKILL.id ? undefined : selectedSkill.id;
const trimmedPrompt = prompt.trim(); const trimmedPrompt = prompt.trim();
const attachmentsToSend = composerAttachment ? [composerAttachment] : undefined; const attachmentsToSend = composerAttachments.length ? composerAttachments : undefined;
const shouldSuggestExpert = !options?.skipHomeIntentSuggestion const shouldSuggestExpert = !options?.skipHomeIntentSuggestion
&& viewMode === "chat" && viewMode === "chat"
&& sessionScopeProjectId === HOME_CHAT_PROJECT_ID && sessionScopeProjectId === HOME_CHAT_PROJECT_ID
...@@ -3873,32 +3980,91 @@ export default function App() { ...@@ -3873,32 +3980,91 @@ export default function App() {
setPendingHomeIntentSuggestion(null); setPendingHomeIntentSuggestion(null);
} }
function resolveComposerAttachmentKind(file: File, localPath: string): ChatAttachment["kind"] | null {
if (file.type.startsWith("image/")) {
return "image";
}
const extension = (file.name ? file.name.slice(file.name.lastIndexOf(".")) : localPath.slice(localPath.lastIndexOf("."))).toLowerCase();
if (IMAGE_ATTACHMENT_EXTENSIONS.has(extension)) {
return "image";
}
if (SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension)) {
return "file";
}
return null;
}
function appendComposerAttachments(nextAttachments: ChatAttachment[]) {
setComposerAttachments((current) => {
const merged = [...current];
const seen = new Set(current.map((attachment) => attachment.localPath.toLowerCase()));
for (const attachment of nextAttachments) {
const key = attachment.localPath.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
merged.push(attachment);
}
return merged;
});
}
function buildAttachmentPromptSummary(attachments: ChatAttachment[]): string {
if (attachments.length === 1) {
return `[Attachment] ${attachments[0].name}`;
}
return `[Attachment] ${attachments.length} files`;
}
function summarizeAttachmentPrompt(attachments: ChatAttachment[]): string {
if (attachments.length === 1) {
return `[附件] ${attachments[0].name}`;
}
return `[附件] ${attachments.length} 个文件`;
}
function clearComposerAttachment() { function clearComposerAttachment() {
setComposerAttachment(null); setErrorText("");
setComposerAttachments([]);
if (attachmentInputRef.current) { if (attachmentInputRef.current) {
attachmentInputRef.current.value = ""; attachmentInputRef.current.value = "";
} }
} }
function removeComposerAttachment(localPath: string) {
setComposerAttachments((current) => current.filter((attachment) => attachment.localPath !== localPath));
}
function acceptComposerAttachmentFile(file: File) { function acceptComposerAttachmentFile(file: File) {
const localPath = (file as File & { path?: string }).path?.trim(); const localPath = (file as File & { path?: string }).path?.trim();
if (!localPath) { if (!localPath) {
setErrorText("当前客户端未提供本地图片路径,无法把图片透传到项目工作区。"); setErrorText("The desktop client did not provide a local file path, so this attachment cannot be sent into the project workspace.");
return;
}
if (!localPath) {
setErrorText("当前客户端未提供本地文件路径,无法把附件透传到项目工作区。");
return; return;
} }
if (file.type && !file.type.startsWith("image/")) { const kind = resolveComposerAttachmentKind(file, localPath);
setErrorText("当前附件只支持图片。"); if (!kind) {
setErrorText("Supported attachments: images, MP3, PDF, PPT, Excel, Word, CSV, TXT, Markdown, and JSON.");
return;
}
if (!kind) {
setErrorText("当前附件仅支持图片、PDF、PPT、Excel、Word、CSV、TXT、Markdown、JSON。");
return; return;
} }
setErrorText(""); setErrorText("");
setComposerAttachment({ appendComposerAttachments([{
kind: "image", kind,
name: file.name || localPath.split(/[\\/]/).pop() || "image", name: file.name || localPath.split(/[\\/]/).pop() || "attachment",
mimeType: file.type || "application/octet-stream", mimeType: file.type || "application/octet-stream",
localPath localPath
}); }]);
} }
function handleComposerDragEnter(event: ReactDragEvent<HTMLFormElement>) { function handleComposerDragEnter(event: ReactDragEvent<HTMLFormElement>) {
...@@ -3940,20 +4106,20 @@ export default function App() { ...@@ -3940,20 +4106,20 @@ export default function App() {
event.preventDefault(); event.preventDefault();
composerDragDepthRef.current = 0; composerDragDepthRef.current = 0;
setIsComposerDragOver(false); setIsComposerDragOver(false);
const file = event.dataTransfer.files[0]; const files = Array.from(event.dataTransfer.files);
if (file) { for (const file of files) {
acceptComposerAttachmentFile(file); acceptComposerAttachmentFile(file);
} }
} }
async function openAttachmentPicker() { async function openAttachmentPicker() {
if (window.qjcDesktop) { if (window.qjcDesktop) {
const attachment = await desktopApi.chat.pickImageAttachment(); const attachments = await desktopApi.chat.pickAttachments();
if (!attachment) { if (!attachments.length) {
return; return;
} }
setErrorText(""); setErrorText("");
setComposerAttachment(attachment); appendComposerAttachments(attachments);
return; return;
} }
...@@ -3961,12 +4127,14 @@ export default function App() { ...@@ -3961,12 +4127,14 @@ export default function App() {
} }
function handleAttachmentSelection(event: ChangeEvent<HTMLInputElement>) { function handleAttachmentSelection(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]; const files = event.target.files ? Array.from(event.target.files) : [];
if (!file) { if (!files.length) {
return; return;
} }
acceptComposerAttachmentFile(file); for (const file of files) {
acceptComposerAttachmentFile(file);
}
event.target.value = ""; event.target.value = "";
return; return;
...@@ -4505,11 +4673,12 @@ export default function App() { ...@@ -4505,11 +4673,12 @@ export default function App() {
ref={attachmentInputRef} ref={attachmentInputRef}
className="composer-attachment-input" className="composer-attachment-input"
type="file" type="file"
accept="image/*" accept={COMPOSER_ATTACHMENT_ACCEPT}
multiple
tabIndex={-1} tabIndex={-1}
onChange={handleAttachmentSelection} onChange={handleAttachmentSelection}
/> />
{isComposerDragOver ? <div className="composer-drop-indicator">释放以上传图片</div> : null} {isComposerDragOver ? <div className="composer-drop-indicator">释放以上传附件</div> : null}
<div className="composer-surface"> <div className="composer-surface">
<label className="composer-field"> <label className="composer-field">
<textarea <textarea
...@@ -4521,19 +4690,21 @@ export default function App() { ...@@ -4521,19 +4690,21 @@ export default function App() {
className="composer-textarea" className="composer-textarea"
/> />
</label> </label>
{composerAttachment ? ( {composerAttachments.length ? (
<div className="composer-attachment-strip"> <div className="composer-attachment-strip">
<span className="composer-attachment-chip"> {composerAttachments.map((attachment) => (
<span className="composer-attachment-chip-label">{composerAttachment.name}</span> <span key={attachment.localPath} className="composer-attachment-chip">
<button type="button" className="composer-attachment-remove" onClick={() => clearComposerAttachment()} aria-label="移除图片附件"> <span className="composer-attachment-chip-label">{attachment.name}</span>
x <button type="button" className="composer-attachment-remove" onClick={() => removeComposerAttachment(attachment.localPath)} aria-label={`移除附件 ${attachment.name}`}>
</button> x
</span> </button>
</span>
))}
</div> </div>
) : null} ) : null}
<div className="composer-footer"> <div className="composer-footer">
<div className="composer-left-tools" ref={skillMenuRef}> <div className="composer-left-tools" ref={skillMenuRef}>
<button type="button" className="attachment-trigger icon-only" disabled={!isBound || sending} onClick={openAttachmentPicker} aria-label="上传图片" title="上传图片"> <button type="button" className="attachment-trigger icon-only" disabled={!isBound || sending} onClick={openAttachmentPicker} aria-label="上传附件" title="上传附件">
<AttachmentIcon /> <AttachmentIcon />
</button> </button>
<button type="button" className="skill-trigger" disabled={!isBound} aria-label={ui.skillMenuTitle} aria-expanded={skillMenuOpen} onClick={() => setSkillMenuOpen((current) => !current)}> <button type="button" className="skill-trigger" disabled={!isBound} aria-label={ui.skillMenuTitle} aria-expanded={skillMenuOpen} onClick={() => setSkillMenuOpen((current) => !current)}>
...@@ -4865,7 +5036,7 @@ export default function App() { ...@@ -4865,7 +5036,7 @@ export default function App() {
</div> </div>
<StatusChip tone={workspace?.apiKeyConfigured ? "positive" : "warning"}>{workspace?.apiKeyConfigured ? "已绑定" : "未绑定"}</StatusChip> <StatusChip tone={workspace?.apiKeyConfigured ? "positive" : "warning"}>{workspace?.apiKeyConfigured ? "已绑定" : "未绑定"}</StatusChip>
</div> </div>
<div className="settings-field-grid single"> <div className="settings-inline-key-row">
<label className="settings-input-label"> <label className="settings-input-label">
<span className="settings-input-label-text">龙虾密钥</span> <span className="settings-input-label-text">龙虾密钥</span>
<input <input
...@@ -4875,9 +5046,7 @@ export default function App() { ...@@ -4875,9 +5046,7 @@ export default function App() {
onChange={(event) => setLobsterKeyDraft(event.target.value)} onChange={(event) => setLobsterKeyDraft(event.target.value)}
/> />
</label> </label>
</div> <button className="settings-primary-button settings-inline-save-button" disabled={saving || !hasPendingLobsterKey} onClick={() => void saveConfig({ lobsterKey: lobsterKeyDraft })}>{saving ? ui.saving : "保存"}</button>
<div className="button-row settings-actions">
<button className="settings-primary-button" disabled={saving || !hasPendingLobsterKey} onClick={() => void saveConfig({ lobsterKey: lobsterKeyDraft })}>{saving ? ui.saving : "保存龙虾密钥"}</button>
</div> </div>
</div> </div>
</section> </section>
...@@ -4886,15 +5055,13 @@ export default function App() { ...@@ -4886,15 +5055,13 @@ export default function App() {
<div className="settings-section-headline"> <div className="settings-section-headline">
<div> <div>
<span className="settings-section-kicker">诊断与工作区</span> <span className="settings-section-kicker">诊断与工作区</span>
{/* <h4>当前生效目录</h4> */} <h4>工作目录</h4>
</div> </div>
<StatusChip tone={hasPendingWorkspacePathChange ? "warning" : "info"}>{hasPendingWorkspacePathChange ? "待保存" : "已同步"}</StatusChip> <StatusChip tone={hasPendingWorkspacePathChange ? "warning" : "info"}>{hasPendingWorkspacePathChange ? "待保存" : "已同步"}</StatusChip>
</div> </div>
<div className="workspace-directory-card"> <div className="workspace-directory-card">
<div className="workspace-directory-panel"> <div className="workspace-directory-panel">
<span className="workspace-directory-eyebrow">当前生效目录</span>
<strong className="workspace-directory-path">{displayedWorkspacePath}</strong> <strong className="workspace-directory-path">{displayedWorkspacePath}</strong>
{/* <p className="workspace-directory-hint">项目会从该目录加载;保存后会重新预热工作区。</p> */}
</div> </div>
{hasPendingWorkspacePathChange ? ( {hasPendingWorkspacePathChange ? (
<div className="workspace-directory-draft-row"> <div className="workspace-directory-draft-row">
...@@ -4929,7 +5096,6 @@ export default function App() { ...@@ -4929,7 +5096,6 @@ export default function App() {
<div className="model-config-card-body"> <div className="model-config-card-body">
<div className="settings-field-grid single"> <div className="settings-field-grid single">
<label className="settings-input-label"> <label className="settings-input-label">
{/* <span className="settings-input-label-text">API Key</span> */}
<input type="password" value={copywritingModelApiKeyDraft} placeholder={config?.expertModelConfig.copywriting.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入文案模型 API Key"} onChange={(event) => setCopywritingModelApiKeyDraft(event.target.value)} /> <input type="password" value={copywritingModelApiKeyDraft} placeholder={config?.expertModelConfig.copywriting.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入文案模型 API Key"} onChange={(event) => setCopywritingModelApiKeyDraft(event.target.value)} />
</label> </label>
</div> </div>
...@@ -4946,7 +5112,6 @@ export default function App() { ...@@ -4946,7 +5112,6 @@ export default function App() {
<div className="model-config-card-body"> <div className="model-config-card-body">
<div className="settings-field-grid single"> <div className="settings-field-grid single">
<label className="settings-input-label"> <label className="settings-input-label">
{/* <span className="settings-input-label-text">API Key</span> */}
<input type="password" value={imageModelApiKeyDraft} placeholder={config?.expertModelConfig.image.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入生图模型 API Key"} onChange={(event) => setImageModelApiKeyDraft(event.target.value)} /> <input type="password" value={imageModelApiKeyDraft} placeholder={config?.expertModelConfig.image.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入生图模型 API Key"} onChange={(event) => setImageModelApiKeyDraft(event.target.value)} />
</label> </label>
</div> </div>
...@@ -4963,7 +5128,6 @@ export default function App() { ...@@ -4963,7 +5128,6 @@ export default function App() {
<div className="model-config-card-body"> <div className="model-config-card-body">
<div className="settings-field-grid single"> <div className="settings-field-grid single">
<label className="settings-input-label"> <label className="settings-input-label">
{/* <span className="settings-input-label-text">API Key</span> */}
<input type="password" value={videoModelApiKeyDraft} placeholder={config?.expertModelConfig.video.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入视频模型 API Key"} onChange={(event) => setVideoModelApiKeyDraft(event.target.value)} /> <input type="password" value={videoModelApiKeyDraft} placeholder={config?.expertModelConfig.video.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入视频模型 API Key"} onChange={(event) => setVideoModelApiKeyDraft(event.target.value)} />
</label> </label>
</div> </div>
...@@ -5012,6 +5176,81 @@ export default function App() { ...@@ -5012,6 +5176,81 @@ export default function App() {
</div> </div>
</div> </div>
</article> </article>
<article className="model-config-card model-config-card-video-analyzer">
<div className="model-config-card-head">
<div>
<strong>Video Analyzer</strong>
<p>用于抖音样本分析阶段。</p>
</div>
<StatusChip tone={config?.douyinRuntimeConfig.videoAnalyzer.apiKeyConfigured ? "positive" : "warning"}>{config?.douyinRuntimeConfig.videoAnalyzer.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</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={videoAnalyzerBaseUrlDraft} placeholder="https://ark.cn-beijing.volces.com/api/v3" onChange={(event) => setVideoAnalyzerBaseUrlDraft(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">model_id</span>
<input value={videoAnalyzerModelIdDraft} placeholder="doubao-vision" onChange={(event) => setVideoAnalyzerModelIdDraft(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">api_key</span>
<input type="password" value={videoAnalyzerApiKeyDraft} placeholder={config?.douyinRuntimeConfig.videoAnalyzer.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入 Video Analyzer API Key"} onChange={(event) => setVideoAnalyzerApiKeyDraft(event.target.value)} />
</label>
</div>
</div>
</article>
<article className="model-config-card model-config-card-replication-brief">
<div className="model-config-card-head">
<div>
<strong>Replication Brief</strong>
<p>用于抖音 brief 生成阶段。</p>
</div>
<StatusChip tone={config?.douyinRuntimeConfig.replicationBrief.apiKeyConfigured ? "positive" : "warning"}>{config?.douyinRuntimeConfig.replicationBrief.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</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={replicationBriefBaseUrlDraft} placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1" onChange={(event) => setReplicationBriefBaseUrlDraft(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">model_id</span>
<input value={replicationBriefModelIdDraft} placeholder="qwen-max" onChange={(event) => setReplicationBriefModelIdDraft(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">api_key</span>
<input type="password" value={replicationBriefApiKeyDraft} placeholder={config?.douyinRuntimeConfig.replicationBrief.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入 Replication Brief API Key"} onChange={(event) => setReplicationBriefApiKeyDraft(event.target.value)} />
</label>
</div>
</div>
</article>
<article className="model-config-card model-config-card-vectcut">
<div className="model-config-card-head">
<div>
<strong>VectCut</strong>
<p>用于抖音后处理剪辑阶段。</p>
</div>
<StatusChip tone={config?.douyinRuntimeConfig.vectcut.apiKeyConfigured ? "positive" : "warning"}>{config?.douyinRuntimeConfig.vectcut.apiKeyConfigured ? "已配置" : "未配置"}</StatusChip>
</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={vectcutBaseUrlDraft} placeholder="https://open.vectcut.com/cut_jianying" onChange={(event) => setVectcutBaseUrlDraft(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">file_base_url</span>
<input value={vectcutFileBaseUrlDraft} placeholder="https://open.vectcut.com" onChange={(event) => setVectcutFileBaseUrlDraft(event.target.value)} />
</label>
<label className="settings-input-label">
<span className="settings-input-label-text">api_key</span>
<input type="password" value={vectcutApiKeyDraft} placeholder={config?.douyinRuntimeConfig.vectcut.apiKeyConfigured ? "留空则保持当前已保存密钥" : "请输入 VectCut API Key"} onChange={(event) => setVectcutApiKeyDraft(event.target.value)} />
</label>
</div>
</div>
</article>
</div> </div>
<div className="button-row settings-actions"> <div className="button-row settings-actions">
<button className="settings-primary-button" disabled={saving || !hasPendingModelKeys} onClick={() => void saveConfig()}>{saving ? ui.saving : "保存模型配置"}</button> <button className="settings-primary-button" disabled={saving || !hasPendingModelKeys} onClick={() => void saveConfig()}>{saving ? ui.saving : "保存模型配置"}</button>
......
...@@ -3945,40 +3945,12 @@ button.secondary { ...@@ -3945,40 +3945,12 @@ button.secondary {
position: relative; position: relative;
min-height: 0; min-height: 0;
height: 100%; height: 100%;
padding: 8px; padding: 0;
overflow: hidden; overflow: hidden;
border-radius: 30px; border: 0;
border: 1px solid rgba(190, 205, 255, 0.88); border-radius: 0;
background: background: transparent;
radial-gradient(circle at 12% 12%, rgba(94, 203, 255, 0.22), transparent 28%), box-shadow: none;
radial-gradient(circle at 88% 10%, rgba(139, 125, 255, 0.18), transparent 24%),
linear-gradient(145deg, #f7f9ff 0%, #eef4ff 52%, #f3efff 100%);
box-shadow: 0 26px 56px rgba(109, 124, 255, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.88);
}
.settings-page-shell::before,
.settings-page-shell::after {
content: "";
position: absolute;
border-radius: 999px;
pointer-events: none;
filter: blur(12px);
}
.settings-page-shell::before {
width: 240px;
height: 240px;
top: -88px;
right: 11%;
background: rgba(109, 124, 255, 0.16);
}
.settings-page-shell::after {
width: 220px;
height: 220px;
left: -76px;
bottom: -84px;
background: rgba(94, 203, 255, 0.16);
} }
.settings-page-shell > * { .settings-page-shell > * {
...@@ -4137,7 +4109,7 @@ button.secondary { ...@@ -4137,7 +4109,7 @@ button.secondary {
display: grid; display: grid;
gap: 10px; gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: minmax(164px, 0.72fr) minmax(0, 1.28fr); grid-template-rows: minmax(164px, 0.43fr) minmax(0, 1.57fr);
grid-template-areas: grid-template-areas:
"connection diagnostics" "connection diagnostics"
"models models"; "models models";
...@@ -4173,7 +4145,7 @@ button.secondary { ...@@ -4173,7 +4145,7 @@ button.secondary {
overflow: hidden; overflow: hidden;
border: 1px solid rgba(157, 180, 255, 0.45); border: 1px solid rgba(157, 180, 255, 0.45);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.5)); background: linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.5));
box-shadow: 0 14px 30px rgba(109, 124, 255, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.9); box-shadow: 0 10px 22px rgba(109, 124, 255, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.88);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
} }
...@@ -4200,11 +4172,11 @@ button.secondary { ...@@ -4200,11 +4172,11 @@ button.secondary {
min-height: 0; min-height: 0;
height: 100%; height: 100%;
gap: 10px; gap: 10px;
padding: 14px; padding: 10px;
border-radius: 20px; border: 0;
border: 1px solid rgba(175, 196, 255, 0.55); border-radius: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(247, 249, 255, 0.76)); background: transparent;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92), 0 8px 22px rgba(94, 125, 255, 0.06); box-shadow: none;
} }
.settings-section-card-compact { .settings-section-card-compact {
...@@ -4274,6 +4246,24 @@ button.secondary { ...@@ -4274,6 +4246,24 @@ button.secondary {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.settings-inline-key-row {
display: grid;
grid-template-columns: minmax(0, 520px) auto;
align-items: end;
gap: 10px;
}
.settings-inline-key-row .settings-input-label {
display: grid;
}
.settings-inline-save-button {
min-width: 88px;
padding-inline: 16px;
justify-self: start;
white-space: nowrap;
}
.settings-input-label { .settings-input-label {
min-width: 0; min-width: 0;
gap: 5px; gap: 5px;
...@@ -4315,12 +4305,34 @@ button.secondary { ...@@ -4315,12 +4305,34 @@ button.secondary {
} }
.model-config-grid-four { .model-config-grid-four {
min-height: 0;
height: 100%; height: 100%;
overflow: auto; overflow-y: auto;
padding-right: 2px; overflow-x: hidden;
grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr); padding-right: 8px;
grid-template-rows: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
align-content: stretch; grid-auto-rows: minmax(188px, auto);
align-content: start;
scrollbar-width: thin;
scrollbar-color: rgba(125, 143, 255, 0.34) transparent;
}
.model-config-grid-four::-webkit-scrollbar {
width: 8px;
}
.model-config-grid-four::-webkit-scrollbar-track {
background: transparent;
}
.model-config-grid-four::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(125, 143, 255, 0.28);
border: 1px solid rgba(255, 255, 255, 0.22);
}
.model-config-grid-four::-webkit-scrollbar-thumb:hover {
background: rgba(125, 143, 255, 0.44);
} }
.model-config-card { .model-config-card {
...@@ -4382,26 +4394,6 @@ button.secondary { ...@@ -4382,26 +4394,6 @@ button.secondary {
align-content: start; align-content: start;
} }
.model-config-card-copywriting {
grid-column: 1;
grid-row: 1;
}
.model-config-card-image {
grid-column: 1;
grid-row: 2;
}
.model-config-card-video {
grid-column: 1;
grid-row: 3;
}
.model-config-card-digital-human {
grid-column: 2;
grid-row: 1 / span 3;
}
.model-config-card-body-digital-human { .model-config-card-body-digital-human {
overflow: visible; overflow: visible;
padding-right: 0; padding-right: 0;
...@@ -4510,27 +4502,7 @@ button.secondary { ...@@ -4510,27 +4502,7 @@ button.secondary {
@media (max-width: 1180px) { @media (max-width: 1180px) {
.model-config-grid-four { .model-config-grid-four {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: auto auto auto; grid-auto-rows: minmax(188px, auto);
}
.model-config-card-copywriting {
grid-column: 1;
grid-row: 1;
}
.model-config-card-image {
grid-column: 2;
grid-row: 1;
}
.model-config-card-video {
grid-column: 1 / -1;
grid-row: 2;
}
.model-config-card-digital-human {
grid-column: 1 / -1;
grid-row: 3;
} }
} }
...@@ -4572,15 +4544,11 @@ button.secondary { ...@@ -4572,15 +4544,11 @@ button.secondary {
.model-config-grid, .model-config-grid,
.model-config-grid-four { .model-config-grid-four {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: none; grid-auto-rows: minmax(0, auto);
} }
.model-config-card-copywriting, .settings-inline-key-row {
.model-config-card-image, grid-template-columns: minmax(0, 1fr);
.model-config-card-video,
.model-config-card-digital-human {
grid-column: auto;
grid-row: auto;
} }
.settings-field-grid-digital-human { .settings-field-grid-digital-human {
......
...@@ -32,6 +32,7 @@ ...@@ -32,6 +32,7 @@
chatCreateSessionForProject: "chat:create-session-for-project", chatCreateSessionForProject: "chat:create-session-for-project",
chatCloseSession: "chat:close-session", chatCloseSession: "chat:close-session",
chatListMessages: "chat:list-messages", chatListMessages: "chat:list-messages",
chatPickAttachments: "chat:pick-attachments",
chatPickImageAttachment: "chat:pick-image-attachment", chatPickImageAttachment: "chat:pick-image-attachment",
chatSendPrompt: "chat:send-prompt", chatSendPrompt: "chat:send-prompt",
chatStreamPrompt: "chat:stream-prompt", chatStreamPrompt: "chat:stream-prompt",
...@@ -416,7 +417,7 @@ export interface ProjectSessionState { ...@@ -416,7 +417,7 @@ export interface ProjectSessionState {
} }
export interface ChatAttachment { export interface ChatAttachment {
kind: "image"; kind: "image" | "file";
name: string; name: string;
mimeType: string; mimeType: string;
localPath: string; localPath: string;
...@@ -563,6 +564,18 @@ export interface DigitalHumanModelConfig { ...@@ -563,6 +564,18 @@ export interface DigitalHumanModelConfig {
qiniuSecretKeyConfigured: boolean; qiniuSecretKeyConfigured: boolean;
} }
export interface DouyinTextModelConfig {
baseUrl: string;
apiKeyConfigured: boolean;
modelId?: string;
}
export interface VectCutModelConfig {
baseUrl: string;
fileBaseUrl: string;
apiKeyConfigured: boolean;
}
export interface ExpertModelConfig { export interface ExpertModelConfig {
image: ModelEndpointConfig; image: ModelEndpointConfig;
video: ModelEndpointConfig; video: ModelEndpointConfig;
...@@ -570,6 +583,12 @@ export interface ExpertModelConfig { ...@@ -570,6 +583,12 @@ export interface ExpertModelConfig {
digitalHuman: DigitalHumanModelConfig; digitalHuman: DigitalHumanModelConfig;
} }
export interface DouyinRuntimeConfig {
videoAnalyzer: DouyinTextModelConfig;
replicationBrief: DouyinTextModelConfig;
vectcut: VectCutModelConfig;
}
export const FIXED_EXPERT_MODEL_ENDPOINTS = { export const FIXED_EXPERT_MODEL_ENDPOINTS = {
copywriting: { copywriting: {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
...@@ -596,6 +615,21 @@ export const FIXED_DIGITAL_HUMAN_CONFIG = { ...@@ -596,6 +615,21 @@ export const FIXED_DIGITAL_HUMAN_CONFIG = {
qiniuKeyPrefix: "omnihuman" qiniuKeyPrefix: "omnihuman"
} as const; } as const;
export const FIXED_DOUYIN_RUNTIME_CONFIG = {
videoAnalyzer: {
baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
modelId: "",
},
replicationBrief: {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
modelId: "",
},
vectcut: {
baseUrl: "https://open.vectcut.com/cut_jianying",
fileBaseUrl: "https://open.vectcut.com"
}
} as const;
export interface AppConfig { export interface AppConfig {
setupMode: SetupMode; setupMode: SetupMode;
provider: string; provider: string;
...@@ -610,6 +644,7 @@ export interface AppConfig { ...@@ -610,6 +644,7 @@ export interface AppConfig {
runtimeCloudApiBaseUrl: string; runtimeCloudApiBaseUrl: string;
runtimeMode: RuntimeModePreference; runtimeMode: RuntimeModePreference;
expertModelConfig: ExpertModelConfig; expertModelConfig: ExpertModelConfig;
douyinRuntimeConfig: DouyinRuntimeConfig;
} }
export interface DiagnosticsExportResult { export interface DiagnosticsExportResult {
...@@ -631,6 +666,18 @@ export interface DigitalHumanModelInput { ...@@ -631,6 +666,18 @@ export interface DigitalHumanModelInput {
qiniuSecretKey?: string; qiniuSecretKey?: string;
} }
export interface DouyinTextModelInput {
baseUrl?: string;
apiKey?: string;
modelId?: string;
}
export interface VectCutModelInput {
baseUrl?: string;
fileBaseUrl?: string;
apiKey?: string;
}
export interface SaveConfigInput { export interface SaveConfigInput {
setupMode: SetupMode; setupMode: SetupMode;
provider: string; provider: string;
...@@ -650,6 +697,11 @@ export interface SaveConfigInput { ...@@ -650,6 +697,11 @@ export interface SaveConfigInput {
copywriting?: ModelEndpointInput; copywriting?: ModelEndpointInput;
digitalHuman?: DigitalHumanModelInput; digitalHuman?: DigitalHumanModelInput;
}; };
douyinRuntimeConfig?: {
videoAnalyzer?: DouyinTextModelInput;
replicationBrief?: DouyinTextModelInput;
vectcut?: VectCutModelInput;
};
} }
export interface AuthSessionSummary { export interface AuthSessionSummary {
...@@ -844,6 +896,7 @@ export interface DesktopApi { ...@@ -844,6 +896,7 @@ export interface DesktopApi {
createSessionForProject(projectId: string, title?: string): Promise<ProjectSessionSummary>; createSessionForProject(projectId: string, title?: string): Promise<ProjectSessionSummary>;
closeSession(sessionId: string): Promise<ProjectSessionSummary[]>; closeSession(sessionId: string): Promise<ProjectSessionSummary[]>;
listMessages(sessionId: string): Promise<ChatMessage[]>; listMessages(sessionId: string): Promise<ChatMessage[]>;
pickAttachments(): Promise<ChatAttachment[]>;
pickImageAttachment(): Promise<ChatAttachment | null>; pickImageAttachment(): Promise<ChatAttachment | null>;
sendPrompt(sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]): Promise<PromptResult>; sendPrompt(sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]): Promise<PromptResult>;
streamPrompt(sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]): Promise<ChatStreamPromptResult>; streamPrompt(sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]): Promise<ChatStreamPromptResult>;
......
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