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

fix(chat): preserve streaming conversation when switching sessions

parent 6dec17bf
import { randomUUID } from "node:crypto";
import { ipcMain, shell, type WebContents } from "electron";
import { BrowserWindow, dialog, ipcMain, shell, type OpenDialogOptions, type WebContents } from "electron";
import { copyFile, mkdir } from "node:fs/promises";
import path from "node:path";
import {
IPC_CHANNELS,
type AppConfig,
type ChatAttachment,
type ChatMessage,
type ChatStreamEvent,
type DesktopApi,
type GatewayStatus,
type PluginSummary,
type ProjectIntentSuggestion,
type ProjectResolvedAttachment,
type RuntimeCloudFetchAction,
type RuntimeCloudStatus,
type RuntimeStatus,
......@@ -25,8 +30,9 @@ import type { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient,
import type { DiagnosticsService } from "./services/diagnostics.js";
import type { DailyReportService } from "./services/daily-report-service.js";
import type { SkillCatalogService } from "./services/skill-catalog.js";
import type { SkillClient } from "./services/skill-client.js";
import type { SkillStoreService } from "./services/skill-store.js";
import type { SkillClient } from "./services/skill-client.js";
import type { ExpertCatalogService } from "./services/expert-catalog.js";
import {
resolveEffectiveGatewayToken,
resolveEffectiveGatewayUrl,
......@@ -50,6 +56,10 @@ import type { ProjectContextService } from "./services/project-context.js";
import type { ProjectExecutionRouter } from "./services/project-execution-router.js";
import type { ProjectSkillRouterService } from "./services/project-skill-router.js";
import type { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js";
import {
buildProjectModelRuntime,
materializeProjectModelRuntime
} from "./services/project-model-runtime.js";
import {
refreshProjectContextAfterExecution,
shouldRefreshProjectContextAfterExecution
......@@ -74,6 +84,7 @@ interface MainServices {
profileClient: ProfileClient;
creditClient: CreditClient;
skillClient: SkillClient;
expertCatalogService: ExpertCatalogService;
skillCatalogService: SkillCatalogService;
skillStore: SkillStoreService;
modelConfigClient: ModelConfigClient;
......@@ -114,6 +125,105 @@ function toControlUiUrl(gatewayUrl: string): string {
return url.toString();
}
function sanitizeAttachmentFileComponent(value: string): string {
const trimmed = value.trim();
const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
return sanitized || "attachment";
}
async function pickImageAttachment(window: BrowserWindow | null): Promise<ChatAttachment | null> {
const dialogOptions: OpenDialogOptions = {
title: "Select image",
properties: ["openFile"],
filters: [
{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "bmp"] }
]
};
const result = window
? await dialog.showOpenDialog(window, dialogOptions)
: await dialog.showOpenDialog(dialogOptions);
if (result.canceled || !result.filePaths.length) {
return null;
}
const localPath = result.filePaths[0]?.trim();
if (!localPath) {
return null;
}
const name = path.basename(localPath) || "image";
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",
name,
mimeType,
localPath
};
}
function normalizeChatAttachments(attachments?: ChatAttachment[]): ChatAttachment[] {
if (!Array.isArray(attachments)) {
return [];
}
return attachments.flatMap((attachment) => {
if (!attachment || attachment.kind !== "image") {
return [];
}
const localPath = attachment.localPath?.trim();
if (!localPath) {
return [];
}
const name = attachment.name?.trim() || path.basename(localPath);
return [{
kind: "image" as const,
name,
mimeType: attachment.mimeType?.trim() || "application/octet-stream",
localPath
}];
});
}
async function materializeProjectAttachments(
projectRoot: string,
sessionId: string,
attachments?: ChatAttachment[]
): Promise<ProjectResolvedAttachment[]> {
const normalized = normalizeChatAttachments(attachments);
if (!normalized.length) {
return [];
}
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) => {
const sourceExt = path.extname(attachment.name || attachment.localPath) || path.extname(attachment.localPath) || ".bin";
const fileName = `${sessionSlug}-${String(index + 1).padStart(2, "0")}${sourceExt.toLowerCase()}`;
const targetPath = path.join(imagesRoot, fileName);
await copyFile(attachment.localPath, targetPath);
return {
...attachment,
localPath: targetPath,
projectPath: targetPath,
relativeProjectPath: path.relative(projectRoot, targetPath).replace(/\\/g, "/")
};
}));
}
const PLUGIN_SPECS = [
{
id: "spreadsheet-tools",
......@@ -226,6 +336,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
profileClient,
secretManager,
skillClient,
expertCatalogService,
skillCatalogService,
modelConfigClient,
runtimeCloudClient,
......@@ -275,7 +386,99 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
gatewayUrl: resolveEffectiveGatewayUrl(config.gatewayUrl, getDiscoveredGatewayUrl(config.runtimeMode)),
apiKeyConfigured: Boolean((await secretManager.getApiKey()) || config.apiKeyConfigured),
gatewayTokenConfigured: Boolean((await getEffectiveGatewayToken(config)) || config.gatewayTokenConfigured),
authTokenConfigured: Boolean((await secretManager.getAuthToken()) || config.authTokenConfigured)
authTokenConfigured: Boolean((await secretManager.getAuthToken()) || config.authTokenConfigured),
expertModelConfig: {
image: {
baseUrl: config.expertModelConfig.image.baseUrl,
apiKeyConfigured: Boolean((await secretManager.getImageModelApiKey()) || config.expertModelConfig.image.apiKeyConfigured),
modelId: config.expertModelConfig.image.modelId
},
video: {
baseUrl: config.expertModelConfig.video.baseUrl,
apiKeyConfigured: Boolean((await secretManager.getVideoModelApiKey()) || config.expertModelConfig.video.apiKeyConfigured),
modelId: config.expertModelConfig.video.modelId
},
copywriting: {
baseUrl: config.expertModelConfig.copywriting.baseUrl,
apiKeyConfigured: Boolean((await secretManager.getCopywritingModelApiKey()) || config.expertModelConfig.copywriting.apiKeyConfigured),
modelId: config.expertModelConfig.copywriting.modelId
},
digitalHuman: {
...config.expertModelConfig.digitalHuman,
volcAccessKeyConfigured: Boolean((await secretManager.getDigitalHumanVolcAccessKey()) || config.expertModelConfig.digitalHuman.volcAccessKeyConfigured),
volcSecretKeyConfigured: Boolean((await secretManager.getDigitalHumanVolcSecretKey()) || config.expertModelConfig.digitalHuman.volcSecretKeyConfigured),
qiniuAccessKeyConfigured: Boolean((await secretManager.getDigitalHumanQiniuAccessKey()) || config.expertModelConfig.digitalHuman.qiniuAccessKeyConfigured),
qiniuSecretKeyConfigured: Boolean((await secretManager.getDigitalHumanQiniuSecretKey()) || config.expertModelConfig.digitalHuman.qiniuSecretKeyConfigured)
}
}
};
};
const prepareProjectModelRuntime = async (projectId: string, projectRoot: string): Promise<Record<string, string>> => {
const config = await configService.load();
const [
copywritingApiKey,
imageApiKey,
videoApiKey,
digitalHumanVolcAccessKey,
digitalHumanVolcSecretKey,
digitalHumanQiniuAccessKey,
digitalHumanQiniuSecretKey
] = await Promise.all([
secretManager.getCopywritingModelApiKey(),
secretManager.getImageModelApiKey(),
secretManager.getVideoModelApiKey(),
secretManager.getDigitalHumanVolcAccessKey(),
secretManager.getDigitalHumanVolcSecretKey(),
secretManager.getDigitalHumanQiniuAccessKey(),
secretManager.getDigitalHumanQiniuSecretKey()
]);
const runtime = buildProjectModelRuntime(projectId, config, {
copywritingApiKey,
imageApiKey,
videoApiKey,
digitalHumanVolcAccessKey,
digitalHumanVolcSecretKey,
digitalHumanQiniuAccessKey,
digitalHumanQiniuSecretKey
});
const envFilePath = await materializeProjectModelRuntime(projectRoot, runtime);
void startupLogger.info("workspace-summary", "project-model-runtime", "Prepared runtime project model config.", {
projectId,
envFilePath,
envKeys: runtime.summary.envKeys,
copywritingBaseUrl: runtime.summary.copywritingBaseUrl,
copywritingModelId: runtime.summary.copywritingModelId,
imageBaseUrl: runtime.summary.imageBaseUrl,
imageModelId: runtime.summary.imageModelId
});
return runtime.env;
};
const resolveConfiguredChatModel = async (config?: AppConfig) => {
const nextConfig = config ?? await getEffectiveConfig();
const baseUrl = nextConfig.expertModelConfig.copywriting.baseUrl.trim();
const modelId = (nextConfig.expertModelConfig.copywriting.modelId ?? "").trim();
const apiKey = (await secretManager.getCopywritingModelApiKey())?.trim();
const missing: string[] = [];
if (!baseUrl) {
missing.push("base_url");
}
if (!apiKey) {
missing.push("api_key");
}
if (!modelId) {
missing.push("model_id");
}
return {
baseUrl,
modelId,
apiKeyConfigured: Boolean(apiKey),
missing
};
};
......@@ -560,6 +763,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
sessions,
skills
} = await loadActiveProjectWorkspaceState(projectStore);
const missingClientChatModel = !config.expertModelConfig.copywriting.baseUrl.trim()
|| !config.expertModelConfig.copywriting.apiKeyConfigured
|| !(config.expertModelConfig.copywriting.modelId ?? "").trim();
const bundleSyncStatus = projectBundleService.getSyncStatus();
const bundleSyncFailed = bundleSyncStatus.state === "error";
const shellReady = !bundleSyncFailed && isWorkspaceShellReady({
......@@ -589,6 +795,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
startupPhase: "error" as const,
startupMessage: bundleSyncStatus.lastError ?? "Workspace project sync failed. Check network access and retry."
}
: currentProject?.isBuiltinHome && missingClientChatModel
? {
chatReady: false,
chatLaunchState: "error" as const,
chatStatusMessage: "请先在客户端设置中配置文案模型(首页对话兜底):base_url、api_key、model_id。",
startupPhase: "error" as const,
startupMessage: "请先在客户端设置中配置文案模型(首页对话兜底):base_url、api_key、model_id。"
}
: shouldWaitForProjectSync
? buildProjectSyncSummary(bundleSyncStatus.lastError ?? EMPTY_PROJECT_INVENTORY_MESSAGE)
: baseChatSummary;
......@@ -607,8 +821,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
employeeId: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.employeeId : undefined,
employeeName: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.employeeName : undefined,
welcomeMessage: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.welcomeMessage : undefined,
modelId: runtimeCloudStatus.config?.modelId ?? config.defaultModel,
modelDisplayName: runtimeCloudStatus.config?.modelDisplayName ?? config.defaultModel,
modelId: config.expertModelConfig.copywriting.modelId || undefined,
modelDisplayName: config.expertModelConfig.copywriting.modelId || undefined,
configVersion: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.configVersion : undefined,
lastFetchedAt: runtimeCloudStatus.lastFetchedAt ?? runtimeCloudStatus.config?.fetchedAt,
runtimeCloudState: runtimeCloudStatus.state,
......@@ -709,40 +923,46 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
});
};
const resolveExecutionPolicy = async (projectId: string, skillId?: string) => {
const resolveExecutionPolicy = async (
projectId: string,
skillId?: string,
executionKind?: "workspace-entry" | "skill" | "chat-fallback"
) => {
const config = await getEffectiveConfig();
const [runtimeCloudStatus, skills] = await Promise.all([
runtimeCloudClient.getStatus(),
projectStore.listProjectSkills(projectId)
]);
const skills = await projectStore.listProjectSkills(projectId);
const selectedSkill = skillId ? skills.find((skill) => skill.id === skillId) : undefined;
const configuredModelId = runtimeCloudStatus.config?.modelId;
const configuredModelLabel = runtimeCloudStatus.config?.modelDisplayName ?? configuredModelId;
if (configuredModelId && configuredModelLabel) {
if (executionKind === "workspace-entry") {
const workspaceEntryModelId = config.expertModelConfig.copywriting.modelId
|| config.expertModelConfig.image.modelId
|| config.expertModelConfig.video.modelId
|| "workspace-entry";
return {
source: skillId ? "cloud-skill-binding" as const : "cloud-default" as const,
modelId: configuredModelId,
modelLabel: configuredModelLabel,
source: "client-config" as const,
modelId: workspaceEntryModelId,
modelLabel: workspaceEntryModelId,
routingMode: "platform-managed" as const,
skillId,
skillName: selectedSkill?.name,
message: skillId
? `Skill ${selectedSkill?.name ?? skillId} is bound to cloud model ${configuredModelLabel}.`
: `Using cloud default model ${configuredModelLabel}.`
message: "Workspace-entry project is using client-configured expert models."
};
}
const chatModel = await resolveConfiguredChatModel(config);
if (chatModel.missing.length > 0) {
throw new Error(`请先在客户端设置中配置文案模型(首页对话兜底):${chatModel.missing.join("、")}`);
}
return {
source: "local-fallback" as const,
modelId: config.defaultModel,
modelLabel: config.defaultModel,
routingMode: "fallback" as const,
source: "client-config" as const,
modelId: chatModel.modelId,
modelLabel: chatModel.modelId,
routingMode: "platform-managed" as const,
skillId,
skillName: selectedSkill?.name,
message: skillId
? `Skill ${selectedSkill?.name ?? skillId} is using local fallback model ${config.defaultModel}.`
: `Using local fallback model ${config.defaultModel}.`
? `Skill ${selectedSkill?.name ?? skillId} is using client copywriting model ${chatModel.modelId}.`
: `Using client copywriting model ${chatModel.modelId}.`
};
};
......@@ -781,11 +1001,18 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return undefined;
};
const createChatMessage = (role: ChatMessage["role"], content: string): ChatMessage => ({
id: randomUUID(),
const createChatMessage = (
role: ChatMessage["role"],
content: string,
overrides: Partial<ChatMessage> = {}
): ChatMessage => ({
id: overrides.id ?? randomUUID(),
role,
content,
createdAt: new Date().toISOString()
createdAt: overrides.createdAt ?? new Date().toISOString(),
streamState: overrides.streamState,
statusLabel: overrides.statusLabel,
statusDetail: overrides.statusDetail
});
const ensureLocalTranscript = async (sessionId: string): Promise<ChatMessage[]> => {
......@@ -805,9 +1032,40 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}
};
const prepareProjectAwareExecution = async (sessionId: string, prompt: string, skillId?: string) => {
const resolveProjectIntentSuggestion = async (prompt: string, currentProjectId?: string): Promise<ProjectIntentSuggestion | null> => {
const route = await projectChatTargetResolver.resolveIntentSuggestion(prompt, currentProjectId);
if (!route) {
return null;
}
const projects = await projectStore.listProjects();
const project = projects.find((item) => item.id === route.projectId);
if (!project || project.isBuiltinHome) {
return null;
}
return {
projectId: project.id,
projectName: project.name,
projectDisplayName: project.displayName?.trim() || project.name,
score: route.score,
confidence: route.confidence,
reason: route.reason,
matchedAliases: route.matchedAliases
};
};
const prepareProjectAwareExecution = async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => {
const requestedSkillId = skillId?.trim() ? skillId.trim() : null;
const target = await projectChatTargetResolver.resolve(sessionId, prompt, requestedSkillId);
const resolvedAttachments = await materializeProjectAttachments(
target.sessionState.projectRoot,
target.sessionState.sessionId,
attachments
);
if (resolvedAttachments.length > 0 && requestedSkillId) {
throw new Error("Attachments are only supported when the project routes through workspace-entry.");
}
const resolvedSessionId = target.sessionState.sessionId;
const projectConfig = await projectStore.getProjectPackageConfig(target.sessionState.projectId);
const snapshot = await projectContextService.getSnapshot(target.sessionState.projectId);
......@@ -820,9 +1078,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
userPrompt: prompt,
context: snapshot,
selectedSkillId: null,
attachments: resolvedAttachments,
projectConfig
});
const preferWorkspaceEntry = declaredWorkspaceEntryDecision?.kind === "workspace-entry";
const preferWorkspaceEntry = resolvedAttachments.length > 0 || declaredWorkspaceEntryDecision?.kind === "workspace-entry";
const autoSkillRoute = requestedSkillId
|| preferWorkspaceEntry
? null
......@@ -847,6 +1106,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
userPrompt: prompt,
context: snapshot,
selectedSkillId: candidateSkillId,
attachments: resolvedAttachments,
projectConfig
})
: (declaredWorkspaceEntryDecision ?? await projectExecutionRouter.decide({
......@@ -856,8 +1116,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
userPrompt: prompt,
context: snapshot,
selectedSkillId: null,
attachments: resolvedAttachments,
projectConfig
}));
if (resolvedAttachments.length > 0 && decision.kind !== "workspace-entry") {
throw new Error("Attachments currently require a project workspace-entry route.");
}
const selectedSkillId = decision.kind === "skill" ? decision.skillId : null;
await projectStore.setSessionSelectedSkill(resolvedSessionId, selectedSkillId);
const reboundSessionState = await projectStore.getSessionState(resolvedSessionId);
......@@ -865,7 +1129,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
await projectStore.bindSessionContextSnapshot(resolvedSessionId, snapshot.snapshotId);
}
const executionSkillId = decision.kind === "skill" ? decision.skillId : undefined;
const executionPolicy = await resolveExecutionPolicy(reboundSessionState.projectId, executionSkillId);
const executionPolicy = await resolveExecutionPolicy(reboundSessionState.projectId, executionSkillId, decision.kind);
const gatewayPrompt = await prepareGatewayPrompt(decision, reboundSessionState.projectId);
return {
sessionState: reboundSessionState,
......@@ -873,6 +1137,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
decision,
executionPolicy,
gatewayPrompt,
attachments: resolvedAttachments,
route: target.route,
autoRouted: target.autoRouted,
previousProjectId: target.previousProjectId,
......@@ -882,8 +1147,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
};
const listChatMessages = async (sessionId: string): Promise<ChatMessage[]> => ensureLocalTranscript(sessionId);
const sendPrompt = async (sessionId: string, prompt: string, skillId?: string) => {
const preparedExecution = await prepareProjectAwareExecution(sessionId, prompt, skillId);
const sendPrompt = async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => {
const preparedExecution = await prepareProjectAwareExecution(sessionId, prompt, skillId, attachments);
const executionSessionId = preparedExecution.sessionState.sessionId;
const executionSkillId = preparedExecution.decision.kind === "skill" ? preparedExecution.decision.skillId : undefined;
const shouldScheduleContextRefresh = shouldRefreshProjectContextAfterExecution(preparedExecution.decision);
......@@ -893,11 +1158,17 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
runtimeCloudSupervisor.noteMessageReceived(executionSessionId, prompt, executionSkillId);
try {
if (preparedExecution.decision.kind === "workspace-entry") {
const projectModelEnv = await prepareProjectModelRuntime(
preparedExecution.sessionState.projectId,
preparedExecution.sessionState.projectRoot
);
const result = await projectWorkspaceExecutor.execute({
sessionId: executionSessionId,
projectRoot: preparedExecution.sessionState.projectRoot,
prompt: preparedExecution.decision.preparedPrompt,
userPrompt: prompt
userPrompt: prompt,
attachments: preparedExecution.attachments,
extraEnv: projectModelEnv
});
await projectStore.appendSessionMessage(executionSessionId, result.reply);
await projectStore.updateSessionLastActive(executionSessionId).catch(() => undefined);
......@@ -934,8 +1205,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}
}
};
const streamPrompt = async (sessionId: string, prompt: string, skillId?: string, sender?: WebContents) => {
const streamPrompt = async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[], sender?: WebContents) => {
const requestId = randomUUID();
const userMessageId = randomUUID();
const assistantMessageId = randomUUID();
let executionPolicy: Awaited<ReturnType<typeof resolveExecutionPolicy>> | null = null;
let executionSkillId: string | undefined;
let executionSessionId = sessionId;
......@@ -946,6 +1219,24 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
let ready = false;
let startedEvent: ChatStreamEvent | null = null;
const queuedEvents: ChatStreamEvent[] = [];
let assistantTranscript = createChatMessage("assistant", "", {
id: assistantMessageId,
streamState: "streaming"
});
let transcriptWriteChain = Promise.resolve();
const queueAssistantTranscriptWrite = (nextMessage: ChatMessage) => {
assistantTranscript = nextMessage;
transcriptWriteChain = transcriptWriteChain
.catch(() => undefined)
.then(async () => {
await projectStore.upsertSessionMessage(executionSessionId, nextMessage);
});
return transcriptWriteChain;
};
const updateAssistantTranscript = (updater: (current: ChatMessage) => ChatMessage) => {
const nextMessage = updater(assistantTranscript);
return queueAssistantTranscriptWrite(nextMessage);
};
const queueProjectContextRefresh = () => {
if (contextRefreshQueued || !shouldScheduleContextRefresh || !refreshProjectId) {
return;
......@@ -982,14 +1273,15 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}, 0);
};
try {
const initialStatusLabel = skillId ? "Preparing project context and skill" : "Preparing project context";
queueOrSend({
type: "status",
requestId,
sessionId,
stage: "prepare-request",
label: skillId ? "Preparing project context and skill" : "Preparing project context"
label: initialStatusLabel
});
const preparedExecution = await prepareProjectAwareExecution(sessionId, prompt, skillId);
const preparedExecution = await prepareProjectAwareExecution(sessionId, prompt, skillId, attachments);
executionSessionId = preparedExecution.sessionState.sessionId;
executionPolicy = preparedExecution.executionPolicy;
executionSkillId = preparedExecution.decision.kind === "skill" ? preparedExecution.decision.skillId : undefined;
......@@ -1017,25 +1309,46 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}
await projectStore.updateSessionLastActive(executionSessionId);
await ensureLocalTranscript(executionSessionId);
await projectStore.appendSessionMessage(executionSessionId, createChatMessage("user", prompt));
await projectStore.appendSessionMessage(executionSessionId, createChatMessage("user", prompt, {
id: userMessageId
}));
await queueAssistantTranscriptWrite(createChatMessage("assistant", "", {
id: assistantMessageId,
createdAt: assistantTranscript.createdAt,
streamState: "streaming",
statusLabel: initialStatusLabel
}));
runtimeCloudSupervisor.noteMessageReceived(executionSessionId, prompt, executionSkillId);
const awaitingLabel = "Question received, preparing response";
await updateAssistantTranscript((current) => ({
...current,
streamState: "streaming",
statusLabel: awaitingLabel,
statusDetail: undefined
}));
queueOrSend({
type: "status",
requestId,
sessionId: executionSessionId,
stage: "await-model",
label: "Question received, preparing response"
label: awaitingLabel
});
if (preparedExecution.decision.kind === "workspace-entry") {
ready = true;
flushQueuedEvents();
void (async () => {
try {
const projectModelEnv = await prepareProjectModelRuntime(
preparedExecution.sessionState.projectId,
preparedExecution.sessionState.projectRoot
);
const result = await projectWorkspaceExecutor.execute({
sessionId: executionSessionId,
projectRoot: preparedExecution.sessionState.projectRoot,
prompt: preparedExecution.decision.preparedPrompt,
userPrompt: prompt
userPrompt: prompt,
attachments: preparedExecution.attachments,
extraEnv: projectModelEnv
}, {
onStarted: (runId) => {
queueOrSend({
......@@ -1047,6 +1360,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
});
},
onStatus: (stage, label, detail) => {
void updateAssistantTranscript((current) => ({
...current,
streamState: "streaming",
statusLabel: label,
statusDetail: detail
}));
queueOrSend({
type: "status",
requestId,
......@@ -1057,6 +1376,15 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
});
},
onDelta: (textDelta, fullText, runId) => {
void updateAssistantTranscript((current) => ({
...current,
content: fullText && fullText.length >= current.content.length
? fullText
: current.content + textDelta,
streamState: "streaming",
statusLabel: undefined,
statusDetail: undefined
}));
queueOrSend({
type: "delta",
requestId,
......@@ -1068,7 +1396,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}
});
settled = true;
await projectStore.appendSessionMessage(executionSessionId, result.reply);
await updateAssistantTranscript((current) => ({
...current,
content: result.reply.content,
createdAt: result.reply.createdAt,
streamState: undefined,
statusLabel: undefined,
statusDetail: undefined
}));
await projectStore.updateSessionLastActive(executionSessionId).catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(executionSessionId, result.reply.content, executionPolicy?.modelId, executionSkillId);
queueOrSend({
......@@ -1082,6 +1417,16 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
} catch (error) {
settled = true;
const message = error instanceof Error ? error.message : String(error);
const errorCategory = error instanceof Error && typeof (error as Error & { errorCategory?: unknown }).errorCategory === "string"
? String((error as Error & { errorCategory?: unknown }).errorCategory).trim()
: "";
await updateAssistantTranscript((current) => ({
...current,
content: current.content.trim() ? current.content : message,
streamState: "error",
statusLabel: undefined,
statusDetail: undefined
}));
runtimeCloudSupervisor.noteError("chat_stream_failed", message, {
modelId: executionPolicy?.modelId,
sessionId: executionSessionId
......@@ -1090,7 +1435,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
type: "error",
requestId,
sessionId: executionSessionId,
message
message,
errorCategory: errorCategory || undefined
});
} finally {
queueProjectContextRefresh();
......@@ -1099,6 +1445,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return {
requestId,
sessionId: executionSessionId,
userMessageId,
assistantMessageId,
executionPolicy: executionPolicy ?? undefined
};
}
......@@ -1106,6 +1454,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
reason: "chat-stream",
execute: () => gatewayClient.streamPrompt(executionSessionId, preparedExecution.gatewayPrompt ?? prompt, {
onStarted: ({ sessionId: nextSessionId, runId }) => {
executionSessionId = nextSessionId;
queueOrSend({
type: "started",
requestId,
......@@ -1115,6 +1464,13 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
});
},
onStatus: ({ sessionId: nextSessionId, runId, stage, label, detail }) => {
executionSessionId = nextSessionId;
void updateAssistantTranscript((current) => ({
...current,
streamState: "streaming",
statusLabel: label,
statusDetail: detail
}));
queueOrSend({
type: "status",
requestId,
......@@ -1126,6 +1482,16 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
});
},
onDelta: ({ sessionId: nextSessionId, runId, textDelta, fullText }) => {
executionSessionId = nextSessionId;
void updateAssistantTranscript((current) => ({
...current,
content: fullText && fullText.length >= current.content.length
? fullText
: current.content + textDelta,
streamState: "streaming",
statusLabel: undefined,
statusDetail: undefined
}));
queueOrSend({
type: "delta",
requestId,
......@@ -1136,9 +1502,17 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
});
},
onCompleted: ({ sessionId: nextSessionId, runId, reply }) => {
executionSessionId = nextSessionId;
settled = true;
void (async () => {
await projectStore.appendSessionMessage(nextSessionId, reply);
await updateAssistantTranscript((current) => ({
...current,
content: reply.content,
createdAt: reply.createdAt,
streamState: undefined,
statusLabel: undefined,
statusDetail: undefined
}));
await projectStore.updateSessionLastActive(nextSessionId).catch(() => undefined);
})().catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(nextSessionId, reply.content, executionPolicy?.modelId, executionSkillId);
......@@ -1153,7 +1527,18 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
queueProjectContextRefresh();
},
onError: ({ sessionId: nextSessionId, runId, error }) => {
executionSessionId = nextSessionId;
settled = true;
const errorCategory = typeof (error as Error & { errorCategory?: unknown }).errorCategory === "string"
? String((error as Error & { errorCategory?: unknown }).errorCategory).trim()
: "";
void updateAssistantTranscript((current) => ({
...current,
content: current.content.trim() ? current.content : error.message,
streamState: "error",
statusLabel: undefined,
statusDetail: undefined
}));
runtimeCloudSupervisor.noteError("chat_stream_failed", error.message, {
modelId: executionPolicy?.modelId,
sessionId: nextSessionId
......@@ -1163,7 +1548,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
requestId,
sessionId: nextSessionId,
runId,
message: error.message
message: error.message,
errorCategory: errorCategory || undefined
});
queueProjectContextRefresh();
}
......@@ -1177,7 +1563,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
runId: stream.runId,
executionPolicy: executionPolicy ?? undefined
});
return { requestId, sessionId: stream.sessionId, runId: stream.runId, executionPolicy: executionPolicy ?? undefined };
return {
requestId,
sessionId: stream.sessionId,
runId: stream.runId,
userMessageId,
assistantMessageId,
executionPolicy: executionPolicy ?? undefined
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!settled) {
......@@ -1192,6 +1585,23 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
};
ipcMain.handle(IPC_CHANNELS.workspaceGetSummary, async () => buildWorkspaceSummary());
ipcMain.handle(IPC_CHANNELS.workspaceWarmup, async () => queueWorkspaceWarmup("workspace-warmup", { action: "init" }));
ipcMain.handle(IPC_CHANNELS.windowMinimize, async (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize();
});
ipcMain.handle(IPC_CHANNELS.windowMaximize, async (event) => {
const window = BrowserWindow.fromWebContents(event.sender);
if (!window) {
return;
}
if (window.isMaximized()) {
window.unmaximize();
return;
}
window.maximize();
});
ipcMain.handle(IPC_CHANNELS.windowClose, async (event) => {
BrowserWindow.fromWebContents(event.sender)?.close();
});
ipcMain.handle(IPC_CHANNELS.gatewayStatus, async () => gatewayClient.status());
ipcMain.handle(IPC_CHANNELS.gatewayConnect, async () => gatewayClient.connect());
ipcMain.handle(IPC_CHANNELS.gatewayDisconnect, async () => gatewayClient.disconnect());
......@@ -1218,6 +1628,11 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
ipcMain.handle(IPC_CHANNELS.configSave, async (_event, input: SaveConfigInput) => {
const previousConfig = await configService.load();
const config = await configService.save(input);
const shouldSyncChatManagedConfig = config.setupMode === "employee-key" && config.runtimeMode !== "external-gateway" && (
typeof input.expertModelConfig?.copywriting?.baseUrl === "string"
|| typeof input.expertModelConfig?.copywriting?.apiKey === "string"
|| typeof input.expertModelConfig?.copywriting?.modelId === "string"
);
if (typeof input.apiKey === "string") {
await secretManager.setApiKey(input.apiKey || undefined);
}
......@@ -1227,6 +1642,27 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
if (typeof input.authToken === "string") {
await secretManager.setAuthToken(input.authToken || undefined);
}
if (typeof input.expertModelConfig?.image?.apiKey === "string") {
await secretManager.setImageModelApiKey(input.expertModelConfig.image.apiKey || undefined);
}
if (typeof input.expertModelConfig?.video?.apiKey === "string") {
await secretManager.setVideoModelApiKey(input.expertModelConfig.video.apiKey || undefined);
}
if (typeof input.expertModelConfig?.copywriting?.apiKey === "string") {
await secretManager.setCopywritingModelApiKey(input.expertModelConfig.copywriting.apiKey || undefined);
}
if (typeof input.expertModelConfig?.digitalHuman?.volcAccessKey === "string") {
await secretManager.setDigitalHumanVolcAccessKey(input.expertModelConfig.digitalHuman.volcAccessKey || undefined);
}
if (typeof input.expertModelConfig?.digitalHuman?.volcSecretKey === "string") {
await secretManager.setDigitalHumanVolcSecretKey(input.expertModelConfig.digitalHuman.volcSecretKey || undefined);
}
if (typeof input.expertModelConfig?.digitalHuman?.qiniuAccessKey === "string") {
await secretManager.setDigitalHumanQiniuAccessKey(input.expertModelConfig.digitalHuman.qiniuAccessKey || undefined);
}
if (typeof input.expertModelConfig?.digitalHuman?.qiniuSecretKey === "string") {
await secretManager.setDigitalHumanQiniuSecretKey(input.expertModelConfig.digitalHuman.qiniuSecretKey || undefined);
}
if (
config.setupMode === "direct-provider"
|| previousConfig.setupMode !== config.setupMode
......@@ -1238,6 +1674,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
await runtimeCloudClient.clearCache().catch(() => undefined);
}
await runtimeManager.setRequestedMode(config.runtimeMode);
if (shouldSyncChatManagedConfig) {
await runtimeManager.syncManagedConfig("sync");
}
if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) {
await reconfigureGatewayClient(config, input.gatewayToken);
......@@ -1263,17 +1702,46 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
ipcMain.handle(IPC_CHANNELS.authGetSession, async () => authClient.getSessionSummary());
ipcMain.handle(IPC_CHANNELS.authSignIn, async (_event, input: SignInInput) => {
const session = await authClient.signIn(input.accessToken);
await configService.save({ ...(await getEffectiveConfig()), authToken: input.accessToken });
const config = await getEffectiveConfig();
await configService.save({
setupMode: config.setupMode,
provider: config.provider,
baseUrl: config.baseUrl,
apiKey: undefined,
gatewayToken: undefined,
authToken: input.accessToken,
defaultModel: config.defaultModel,
workspacePath: config.workspacePath,
gatewayUrl: config.gatewayUrl,
cloudApiBaseUrl: config.cloudApiBaseUrl,
runtimeCloudApiBaseUrl: config.runtimeCloudApiBaseUrl,
runtimeMode: config.runtimeMode
});
return session;
});
ipcMain.handle(IPC_CHANNELS.authSignOut, async () => {
const session = await authClient.signOut();
await configService.save({ ...(await getEffectiveConfig()), authToken: "" });
const config = await getEffectiveConfig();
await configService.save({
setupMode: config.setupMode,
provider: config.provider,
baseUrl: config.baseUrl,
apiKey: undefined,
gatewayToken: undefined,
authToken: "",
defaultModel: config.defaultModel,
workspacePath: config.workspacePath,
gatewayUrl: config.gatewayUrl,
cloudApiBaseUrl: config.cloudApiBaseUrl,
runtimeCloudApiBaseUrl: config.runtimeCloudApiBaseUrl,
runtimeMode: config.runtimeMode
});
return session;
});
ipcMain.handle(IPC_CHANNELS.profileGetSummary, async () => profileClient.getSummary());
ipcMain.handle(IPC_CHANNELS.creditsGetSummary, async () => creditClient.getSummary());
ipcMain.handle(IPC_CHANNELS.skillsList, async () => skillClient.list());
ipcMain.handle(IPC_CHANNELS.expertsList, async () => expertCatalogService.list());
ipcMain.handle(IPC_CHANNELS.modelConfigGetSummary, async () => modelConfigClient.getSummary());
ipcMain.handle(IPC_CHANNELS.systemGetSummary, async () => systemSummary);
ipcMain.handle(IPC_CHANNELS.skillCatalogList, async () => skillCatalogService.listForActiveProject());
......@@ -1283,6 +1751,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
await projectStore.setActiveProject(projectId);
return buildWorkspaceSummary();
});
ipcMain.handle(IPC_CHANNELS.projectsResolveIntent, async (_event, prompt: string, currentProjectId?: string) => {
return resolveProjectIntentSuggestion(prompt, currentProjectId);
});
ipcMain.handle(IPC_CHANNELS.chatListSessions, async () => {
const sessions = await listSessionsForActiveProject(projectStore);
......@@ -1310,11 +1781,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return sessions;
});
ipcMain.handle(IPC_CHANNELS.chatListMessages, async (_event, sessionId: string) => listChatMessages(sessionId));
ipcMain.handle(IPC_CHANNELS.chatSendPrompt, async (_event, sessionId: string, prompt: string, skillId?: string) => {
return sendPrompt(sessionId, prompt, skillId);
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[]) => {
return sendPrompt(sessionId, prompt, skillId, attachments);
});
ipcMain.handle(IPC_CHANNELS.chatStreamPrompt, async (event, sessionId: string, prompt: string, skillId?: string) => {
return streamPrompt(sessionId, prompt, skillId, event.sender);
ipcMain.handle(IPC_CHANNELS.chatStreamPrompt, async (event, sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => {
return streamPrompt(sessionId, prompt, skillId, attachments, event.sender);
});
ipcMain.handle(IPC_CHANNELS.diagnosticsOpenControlUi, async () => {
const config = await getEffectiveConfig();
......@@ -1327,6 +1799,11 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
getSummary: () => buildWorkspaceSummary(),
warmup: () => queueWorkspaceWarmup("workspace-warmup", { action: "init" })
},
window: {
minimize: async () => undefined,
maximize: async () => undefined,
close: async () => undefined
},
gateway: {
status: () => gatewayClient.status(),
connect: () => gatewayClient.connect(),
......@@ -1359,6 +1836,11 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
load: () => getEffectiveConfig(),
save: async (input: SaveConfigInput) => {
const config = await configService.save(input);
const shouldSyncChatManagedConfig = config.setupMode === "employee-key" && config.runtimeMode !== "external-gateway" && (
typeof input.expertModelConfig?.copywriting?.baseUrl === "string"
|| typeof input.expertModelConfig?.copywriting?.apiKey === "string"
|| typeof input.expertModelConfig?.copywriting?.modelId === "string"
);
if (typeof input.apiKey === "string") {
await secretManager.setApiKey(input.apiKey || undefined);
}
......@@ -1368,7 +1850,31 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
if (typeof input.authToken === "string") {
await secretManager.setAuthToken(input.authToken || undefined);
}
if (typeof input.expertModelConfig?.image?.apiKey === "string") {
await secretManager.setImageModelApiKey(input.expertModelConfig.image.apiKey || undefined);
}
if (typeof input.expertModelConfig?.video?.apiKey === "string") {
await secretManager.setVideoModelApiKey(input.expertModelConfig.video.apiKey || undefined);
}
if (typeof input.expertModelConfig?.copywriting?.apiKey === "string") {
await secretManager.setCopywritingModelApiKey(input.expertModelConfig.copywriting.apiKey || undefined);
}
if (typeof input.expertModelConfig?.digitalHuman?.volcAccessKey === "string") {
await secretManager.setDigitalHumanVolcAccessKey(input.expertModelConfig.digitalHuman.volcAccessKey || undefined);
}
if (typeof input.expertModelConfig?.digitalHuman?.volcSecretKey === "string") {
await secretManager.setDigitalHumanVolcSecretKey(input.expertModelConfig.digitalHuman.volcSecretKey || undefined);
}
if (typeof input.expertModelConfig?.digitalHuman?.qiniuAccessKey === "string") {
await secretManager.setDigitalHumanQiniuAccessKey(input.expertModelConfig.digitalHuman.qiniuAccessKey || undefined);
}
if (typeof input.expertModelConfig?.digitalHuman?.qiniuSecretKey === "string") {
await secretManager.setDigitalHumanQiniuSecretKey(input.expertModelConfig.digitalHuman.qiniuSecretKey || undefined);
}
await runtimeManager.setRequestedMode(config.runtimeMode);
if (shouldSyncChatManagedConfig) {
await runtimeManager.syncManagedConfig("sync");
}
if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) {
await reconfigureGatewayClient(config, input.gatewayToken);
......@@ -1391,7 +1897,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
setActive: async (projectId: string) => {
await projectStore.setActiveProject(projectId);
return buildWorkspaceSummary();
}
},
resolveIntent: (prompt: string, currentProjectId?: string) => resolveProjectIntentSuggestion(prompt, currentProjectId)
},
skillCatalog: {
list: () => skillCatalogService.listForActiveProject()
......@@ -1410,6 +1917,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
skills: {
list: () => skillClient.list()
},
experts: {
list: () => Promise.resolve(expertCatalogService.list())
},
modelConfig: {
getSummary: () => modelConfigClient.getSummary()
},
......@@ -1443,8 +1953,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return sessions;
},
listMessages: (sessionId: string) => listChatMessages(sessionId),
sendPrompt: async (sessionId: string, prompt: string, skillId?: string) => sendPrompt(sessionId, prompt, skillId),
streamPrompt: async (sessionId: string, prompt: string, skillId?: string) => streamPrompt(sessionId, prompt, skillId),
pickImageAttachment: async () => pickImageAttachment(BrowserWindow.getFocusedWindow() ?? null),
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),
onStreamEvent: (listener) => {
streamListeners.add(listener);
return () => {
......
......@@ -712,6 +712,17 @@ export class ProjectStoreService {
await writeJsonFile(await this.getSessionMessagesPath(sessionId), messages);
}
async upsertSessionMessage(sessionId: string, message: ChatMessage): Promise<void> {
const messages = await this.listSessionMessages(sessionId);
const existingIndex = messages.findIndex((entry) => entry.id === message.id);
if (existingIndex >= 0) {
messages[existingIndex] = message;
} else {
messages.push(message);
}
await writeJsonFile(await this.getSessionMessagesPath(sessionId), messages);
}
async listCurrentProjectSkills(): Promise<WorkspaceSkillSummary[]> {
const project = await this.getActiveProject();
return this.listProjectSkills(project.id);
......
......@@ -34,6 +34,7 @@ type Tone = "positive" | "warning" | "info";
type MessageStreamState = "streaming" | "error";
type SendPhase = "idle" | "preparing" | "streaming" | "finalizing";
type TraceTone = "info" | "error" | "success";
type MessagesBySession = Record<string, UiChatMessage[]>;
type UiChatMessage = ChatMessage & {
streamState?: MessageStreamState;
......@@ -65,6 +66,7 @@ interface ActiveStreamState {
requestId: string;
assistantMessageId: string;
sessionId: string;
originSessionId: string;
targetText: string;
renderedText: string;
finalReply?: ChatMessage;
......@@ -145,7 +147,12 @@ function createClientMessageId(prefix: string): string {
}
function toUiChatMessage(message: ChatMessage, streamState?: MessageStreamState, statusLabel?: string, statusDetail?: string): UiChatMessage {
return streamState || statusLabel || statusDetail ? { ...message, streamState, statusLabel, statusDetail } : { ...message };
const resolvedStreamState = streamState ?? message.streamState;
const resolvedStatusLabel = statusLabel ?? message.statusLabel;
const resolvedStatusDetail = statusDetail ?? message.statusDetail;
return resolvedStreamState || resolvedStatusLabel || resolvedStatusDetail
? { ...message, streamState: resolvedStreamState, statusLabel: resolvedStatusLabel, statusDetail: resolvedStatusDetail }
: { ...message };
}
function toPlainMessages(items: UiChatMessage[]): ChatMessage[] {
......@@ -156,6 +163,49 @@ function isPrimaryChatMessage(message: ChatMessage): boolean {
return message.role === "user" || message.role === "assistant";
}
function hasLocalTransientMessage(messages: UiChatMessage[]): boolean {
return messages.some((message) => Boolean(message.streamState || message.statusLabel || message.statusDetail));
}
function mergeSessionHistory(current: UiChatMessage[], nextMessages: UiChatMessage[]): UiChatMessage[] {
if (!current.length) {
return nextMessages;
}
if (!hasLocalTransientMessage(current)) {
return nextMessages;
}
if (!nextMessages.length) {
return current;
}
const currentById = new Map(current.map((message) => [message.id, message] as const));
const merged = nextMessages.map((message) => {
const local = currentById.get(message.id);
if (!local) {
return message;
}
const hasLocalStreamingState = Boolean(local.streamState || local.statusLabel || local.statusDetail);
if (!hasLocalStreamingState) {
return message;
}
return {
...message,
content: local.content.length > message.content.length ? local.content : message.content,
createdAt: message.createdAt || local.createdAt,
streamState: local.streamState ?? message.streamState,
statusLabel: local.statusLabel ?? message.statusLabel,
statusDetail: local.statusDetail ?? message.statusDetail
};
});
const nextIds = new Set(nextMessages.map((message) => message.id));
const localOnlyMessages = current.filter((message) => !nextIds.has(message.id));
return localOnlyMessages.length > 0 ? [...merged, ...localOnlyMessages] : merged;
}
function pushTraceItem(current: ConversationTraceItem[], item: ConversationTraceItem): ConversationTraceItem[] {
const lastItem = current.at(-1);
if (lastItem && lastItem.stage === item.stage && lastItem.label === item.label && lastItem.detail === item.detail && lastItem.tone === item.tone) {
......@@ -948,6 +998,8 @@ const mockDesktopApi = {
const requestId = createClientMessageId("mock-request");
const runId = createClientMessageId("mock-run");
const sessionId = _sessionId || "project:xiaohongshu:default";
const userMessageId = createClientMessageId("mock-user");
const assistantMessageId = createClientMessageId("mock-assistant");
const executionPolicy = { source: "client-config" as const, modelId: "qwen3.5-plus", modelLabel: "qwen3.5-plus", routingMode: "platform-managed" as const, skillId, skillName: skillId, message: "mock" };
const replyText = "Mock: " + prompt;
const chunks = replyText.match(/.{1,6}/g) ?? [replyText];
......@@ -971,11 +1023,11 @@ const mockDesktopApi = {
requestId,
sessionId,
runId,
reply: { id: createClientMessageId("mock-reply"), role: "assistant", content: replyText, createdAt: new Date().toISOString() },
reply: { id: assistantMessageId, role: "assistant", content: replyText, createdAt: new Date().toISOString() },
executionPolicy
});
}, 90 * (chunks.length + 1));
return { requestId, sessionId, runId, executionPolicy };
return { requestId, sessionId, runId, userMessageId, assistantMessageId, executionPolicy };
},
onStreamEvent: (listener: ChatStreamListener) => {
mockChatStreamListeners.add(listener);
......@@ -1037,6 +1089,7 @@ declare global {
systemSummary: SystemSummary | null;
sessions: SessionSummary[];
messages: ChatMessage[];
messagesBySession: Record<string, ChatMessage[]>;
logs: LogEntry[];
activeSessionId: string;
expertProjectIds: string[];
......@@ -1098,6 +1151,8 @@ declare global {
expertModelConfig: AppConfig["expertModelConfig"];
apiKeyConfigured: boolean;
}>;
createProjectSession(projectId?: string, title?: string): Promise<SessionSummary>;
openSession(sessionId: string): Promise<{ sessionId: string }>;
clickWindowControl(kind: "minimize" | "maximize" | "close"): Promise<{ kind: "minimize" | "maximize" | "close" }>;
};
}
......@@ -1587,7 +1642,7 @@ export default function App() {
const [gatewayStatus, setGatewayStatus] = useState<GatewayStatus | null>(null);
const [gatewayHealth, setGatewayHealth] = useState<GatewayHealth | null>(null);
const [sessions, setSessions] = useState<WorkspaceSummary["sessions"]>([]);
const [messages, setMessages] = useState<UiChatMessage[]>([]);
const [messagesBySession, setMessagesBySession] = useState<MessagesBySession>({});
const [activeSessionId, setActiveSessionId] = useState(EMPTY_SESSION_ID);
const [projectActionPending, setProjectActionPending] = useState(false);
const [selectedSkillId, setSelectedSkillId] = useState(DEFAULT_SKILL.id);
......@@ -1618,7 +1673,6 @@ export default function App() {
const [sidebarSessionTitles, setSidebarSessionTitles] = useState<Record<string, string>>({});
const [skillMenuOpen, setSkillMenuOpen] = useState(false);
const activeStreamRef = useRef<ActiveStreamState | null>(null);
const activeSessionIdRef = useRef(activeSessionId);
const skillMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const startupWarmupRequestedRef = useRef(false);
......@@ -1699,7 +1753,15 @@ export default function App() {
}
return viewMode === "chat" ? HOME_CHAT_PROJECT_ID : activeProject?.id;
}, [activeProject?.id, bindingRequired, viewMode]);
const resolvedActiveSessionId = useMemo(() => resolvePreferredSessionId(sessions, activeSessionId), [activeSessionId, sessions]);
const preferredSessionId = useMemo(() => resolvePreferredSessionId(sessions, activeSessionId), [activeSessionId, sessions]);
const visibleSessionId = useMemo(
() => activeSessionId || preferredSessionId,
[activeSessionId, preferredSessionId]
);
const messages = useMemo(
() => (visibleSessionId ? messagesBySession[visibleSessionId] ?? [] : []),
[messagesBySession, visibleSessionId]
);
const isBound = !bindingRequired;
const hasConversationProject = viewMode === "chat"
? visibleProjects.length > 0
......@@ -1767,6 +1829,60 @@ export default function App() {
return () => window.clearTimeout(timer);
}, [infoText]);
function updateSessionMessages(sessionId: string, updater: (current: UiChatMessage[]) => UiChatMessage[]) {
if (!sessionId) {
return;
}
setMessagesBySession((current) => {
const currentMessages = current[sessionId] ?? [];
const nextMessages = updater(currentMessages);
if (nextMessages === currentMessages) {
return current;
}
if (!nextMessages.length) {
if (!(sessionId in current)) {
return current;
}
const { [sessionId]: _removed, ...rest } = current;
return rest;
}
return {
...current,
[sessionId]: nextMessages
};
});
}
function clearAllSessionMessages() {
setMessagesBySession({});
}
function moveSessionMessages(sourceSessionId: string, targetSessionId: string, messageIds: string[]) {
if (!sourceSessionId || !targetSessionId || sourceSessionId === targetSessionId || messageIds.length === 0) {
return;
}
setMessagesBySession((current) => {
const sourceMessages = current[sourceSessionId] ?? [];
const targetMessages = current[targetSessionId] ?? [];
const movedMessages = sourceMessages.filter((message) => messageIds.includes(message.id));
if (!movedMessages.length) {
return current;
}
const nextSourceMessages = sourceMessages.filter((message) => !messageIds.includes(message.id));
const targetMessageIds = new Set(targetMessages.map((message) => message.id));
const nextMovedMessages = movedMessages.filter((message) => !targetMessageIds.has(message.id));
return {
...current,
[sourceSessionId]: nextSourceMessages,
[targetSessionId]: nextMovedMessages.length > 0 ? [...targetMessages, ...nextMovedMessages] : targetMessages
};
});
}
async function loadMessages(sessionId: string, canRead: boolean, showError = false) {
if (!canRead) {
return;
......@@ -1776,15 +1892,8 @@ export default function App() {
const nextMessages = (await desktopApi.chat.listMessages(sessionId))
.filter(isPrimaryChatMessage)
.map((message) => toUiChatMessage(message));
if (sessionId !== activeSessionIdRef.current) {
return;
}
setMessages((current) => {
const hasTransientLocalMessages = current.some((message) => Boolean(message.streamState || message.statusLabel));
if (nextMessages.length === 0 && hasTransientLocalMessages) {
return current;
}
return nextMessages;
updateSessionMessages(sessionId, (current) => {
return mergeSessionHistory(current, nextMessages);
});
} catch (error) {
if (showError) {
......@@ -1851,16 +1960,12 @@ export default function App() {
}, []);
useEffect(() => {
activeSessionIdRef.current = activeSessionId;
}, [activeSessionId]);
useEffect(() => {
if (!resolvedActiveSessionId || resolvedActiveSessionId === activeSessionId) {
if (activeSessionId || !preferredSessionId) {
return;
}
setActiveSessionId(resolvedActiveSessionId);
}, [activeSessionId, resolvedActiveSessionId]);
setActiveSessionId(preferredSessionId);
}, [activeSessionId, preferredSessionId]);
useEffect(() => {
let cancelled = false;
......@@ -1871,7 +1976,7 @@ export default function App() {
if (!cancelled) {
setSessions([]);
if (!preserveVisibleConversation) {
setMessages([]);
clearAllSessionMessages();
}
}
return;
......@@ -1884,7 +1989,20 @@ export default function App() {
}
setSessions(nextSessions);
if (preserveVisibleConversation) {
return;
}
const hasActiveSession = activeSessionId
? nextSessions.some((session) => session.id === activeSessionId)
: false;
const hasLocalActiveCache = activeSessionId
? Boolean(messagesBySession[activeSessionId]?.length)
: false;
const nextSessionId = resolvePreferredSessionId(nextSessions, activeSessionId);
if (hasActiveSession || hasLocalActiveCache) {
return;
}
if (nextSessionId) {
setActiveSessionId(nextSessionId);
} else if (sessionScopeProjectId === HOME_CHAT_PROJECT_ID) {
......@@ -1897,7 +2015,7 @@ export default function App() {
} else {
setActiveSessionId(EMPTY_SESSION_ID);
if (!preserveVisibleConversation) {
setMessages([]);
clearAllSessionMessages();
}
}
} catch (error) {
......@@ -1906,7 +2024,7 @@ export default function App() {
}
setSessions([]);
if (!preserveVisibleConversation) {
setMessages([]);
clearAllSessionMessages();
}
setErrorText(err(error));
}
......@@ -1916,7 +2034,7 @@ export default function App() {
return () => {
cancelled = true;
};
}, [activeSessionId, bindingRequired, desktopApi.chat, isBound, sendPhase, sessionScopeProjectId, workspace]);
}, [activeSessionId, bindingRequired, desktopApi.chat, isBound, messagesBySession, sendPhase, sessionScopeProjectId, workspace]);
useEffect(() => {
const shouldPollStartupState = viewMode !== "settings"
......@@ -2022,16 +2140,15 @@ export default function App() {
useEffect(() => {
if (
!isBound
|| !resolvedActiveSessionId
|| !visibleSessionId
|| !workspace?.chatReady
|| sendPhase !== "idle"
|| !canExchangeMessages(workspace, runtimeStatus, gatewayStatus)
) {
return;
}
void loadMessages(resolvedActiveSessionId, true, false);
}, [gatewayStatus, isBound, resolvedActiveSessionId, runtimeStatus, sendPhase, workspace?.chatReady]);
void loadMessages(visibleSessionId, true, false);
}, [gatewayStatus, isBound, runtimeStatus, sendPhase, visibleSessionId, workspace?.chatReady]);
useEffect(() => {
let cancelled = false;
......@@ -2045,8 +2162,9 @@ export default function App() {
}
const nextEntries = await Promise.all(sessions.map(async (session, index) => {
if (session.id === resolvedActiveSessionId && messages.length) {
return [session.id, deriveSidebarSessionTitle(toPlainMessages(messages))] as const;
const cachedMessages = messagesBySession[session.id];
if (cachedMessages?.length) {
return [session.id, deriveSidebarSessionTitle(toPlainMessages(cachedMessages))] as const;
}
try {
......@@ -2067,7 +2185,7 @@ export default function App() {
return () => {
cancelled = true;
};
}, [desktopApi.chat, messages, resolvedActiveSessionId, sessions]);
}, [desktopApi.chat, messagesBySession, sessions]);
async function switchProject(projectId: string) {
if (projectActionPending) {
......@@ -2081,7 +2199,7 @@ export default function App() {
setWorkspace(nextWorkspace);
setSessions([]);
setActiveSessionId(EMPTY_SESSION_ID);
setMessages([]);
clearAllSessionMessages();
} catch (error) {
setErrorText(err(error));
} finally {
......@@ -2110,7 +2228,6 @@ export default function App() {
}
}
setActiveSessionId(session.id);
setMessages([]);
} catch (error) {
setErrorText(err(error));
} finally {
......@@ -2119,7 +2236,7 @@ export default function App() {
}
async function closeProjectSession(sessionId: string) {
if (projectActionPending) {
if (projectActionPending || (sendPhase !== "idle" && activeStreamRef.current?.sessionId === sessionId)) {
return;
}
......@@ -2136,7 +2253,6 @@ export default function App() {
}
const nextSessionId = nextSessions.find((session) => session.id !== sessionId)?.id ?? nextSessions[0]?.id ?? EMPTY_SESSION_ID;
setActiveSessionId(nextSessionId);
setMessages([]);
} catch (error) {
setErrorText(err(error));
} finally {
......@@ -2172,8 +2288,11 @@ export default function App() {
systemSummary,
sessions,
messages: toPlainMessages(messages),
messagesBySession: Object.fromEntries(
Object.entries(messagesBySession).map(([sessionId, sessionMessages]) => [sessionId, toPlainMessages(sessionMessages)])
),
logs: [],
activeSessionId: resolvedActiveSessionId ?? "",
activeSessionId: visibleSessionId ?? "",
expertProjectIds: expertPageProjects.map((project) => project.id),
workspaceSummary: workspace,
streamSmoke,
......@@ -2201,7 +2320,7 @@ export default function App() {
.map((entry) => entry.definition.id)
}
};
}, [bindingRequired, chatLaunchState, config, expertPageProjects, gatewayHealth, gatewayStatus, isBound, messages, resolvedActiveSessionId, runtimeCloudStatus, runtimeStatus, runtimeTelemetry, sessions, shellReady, showBindEntry, showStartupOverlay, startupPhase, streamSmoke, systemSummary, viewMode, workspace]);
}, [bindingRequired, chatLaunchState, config, expertPageProjects, gatewayHealth, gatewayStatus, isBound, messages, messagesBySession, runtimeCloudStatus, runtimeStatus, runtimeTelemetry, sessions, shellReady, showBindEntry, showStartupOverlay, startupPhase, streamSmoke, systemSummary, viewMode, visibleSessionId, workspace]);
useEffect(() => {
if (!smokeEnabled) {
......@@ -2390,6 +2509,17 @@ export default function App() {
apiKeyConfigured: latestConfig.apiKeyConfigured
};
},
createProjectSession: async (projectId?: string, title?: string) => {
const resolvedProjectId = projectId?.trim() || sessionScopeProjectId || HOME_CHAT_PROJECT_ID;
const session = await desktopApi.chat.createSessionForProject(resolvedProjectId, title);
setSessions((current) => [session, ...current.filter((item) => item.id !== session.id)]);
return session;
},
openSession: async (sessionId: string) => {
setViewMode((current) => (current === "experts" ? "experts" : "chat"));
setActiveSessionId(sessionId);
return { sessionId };
},
resolveHomeIntentSuggestion: async () => {
const currentPrompt = prompt.trim();
if (!currentPrompt) {
......@@ -2454,7 +2584,23 @@ export default function App() {
});
function updateMessageById(messageId: string, updater: (message: UiChatMessage) => UiChatMessage) {
setMessages((current) => current.map((message) => (message.id === messageId ? updater(message) : message)));
setMessagesBySession((current) => {
for (const [sessionId, sessionMessages] of Object.entries(current)) {
const messageIndex = sessionMessages.findIndex((message) => message.id === messageId);
if (messageIndex < 0) {
continue;
}
const nextMessages = [...sessionMessages];
nextMessages[messageIndex] = updater(sessionMessages[messageIndex]);
return {
...current,
[sessionId]: nextMessages
};
}
return current;
});
}
function updateStreamSmoke(updater: (current: SmokeStreamSnapshot | null) => SmokeStreamSnapshot | null) {
......@@ -2464,6 +2610,50 @@ export default function App() {
setStreamSmoke((current) => updater(current));
}
function replaceSessionMessageId(sessionId: string, currentMessageId: string, nextMessageId?: string) {
if (!sessionId || !currentMessageId || !nextMessageId || currentMessageId === nextMessageId) {
return;
}
updateSessionMessages(sessionId, (current) => {
const messageIndex = current.findIndex((message) => message.id === currentMessageId);
if (messageIndex < 0 || current.some((message) => message.id === nextMessageId)) {
return current;
}
const nextMessages = [...current];
nextMessages[messageIndex] = {
...nextMessages[messageIndex],
id: nextMessageId
};
return nextMessages;
});
setMessageTraces((current) => {
if (!(currentMessageId in current)) {
return current;
}
const existing = current[currentMessageId];
const { [currentMessageId]: _removed, ...rest } = current;
return {
...rest,
[nextMessageId]: existing
};
});
updateStreamSmoke((current) => current?.assistantMessageId === currentMessageId
? {
...current,
assistantMessageId: nextMessageId
}
: current);
const activeStream = activeStreamRef.current;
if (activeStream?.assistantMessageId === currentMessageId) {
activeStream.assistantMessageId = nextMessageId;
}
}
function initializeMessageTrace(messageId: string, item?: ConversationTraceItem) {
setMessageTraces((current) => ({
...current,
......@@ -2540,7 +2730,9 @@ export default function App() {
return current;
}
const assistantMessage = messages.find((message) => message.id === current.assistantMessageId);
const assistantMessage = Object.values(messagesBySession)
.flat()
.find((message) => message.id === current.assistantMessageId);
if (!assistantMessage) {
return current;
}
......@@ -2557,7 +2749,7 @@ export default function App() {
finalContent: nextFinalContent
};
});
}, [messages]);
}, [messagesBySession]);
function cancelTypewriter() {
const activeStream = activeStreamRef.current;
......@@ -2567,8 +2759,7 @@ export default function App() {
}
}
async function syncChatAfterSend(sessionId: string) {
setActiveSessionId(sessionId);
async function syncChatAfterSend() {
const [telemetry, nextWorkspace, nextGateway] = await Promise.all([
desktopApi.runtimeTelemetry.getStatus().catch(() => null),
desktopApi.workspace.getSummary().catch(() => null),
......@@ -2606,10 +2797,9 @@ export default function App() {
finalContent: activeStream.finalReply?.content ?? activeStream.targetText
} : current);
collapseMessageTrace(activeStream.assistantMessageId);
const sessionId = activeStream.sessionId;
activeStreamRef.current = null;
setSendPhase("idle");
void syncChatAfterSend(sessionId);
void syncChatAfterSend();
}
function scheduleTypewriter() {
......@@ -2699,7 +2889,7 @@ export default function App() {
} : current);
appendTrace(assistantMessageId, "fallback-complete", ui.fallbackComplete, undefined, "success");
collapseMessageTrace(assistantMessageId);
await syncChatAfterSend(result.sessionId);
await syncChatAfterSend();
setSendPhase("idle");
}
......@@ -2712,7 +2902,6 @@ export default function App() {
if (event.type === "started") {
activeStream.sessionId = event.sessionId;
setActiveSessionId(event.sessionId);
setSendPhase("streaming");
appendTrace(activeStream.assistantMessageId, "started", ui.replyStarted);
updateAssistantStatus(activeStream.assistantMessageId, ui.thinking);
......@@ -2993,8 +3182,14 @@ export default function App() {
tone: "info",
createdAt: new Date().toISOString()
});
setMessages((current) => [...current, userMessage, assistantMessage]);
let sessionId = forcedSessionId ?? resolvedActiveSessionId;
let sessionId = forcedSessionId ?? visibleSessionId;
let userMessageId = userMessage.id;
let assistantMessageId = assistantMessage.id;
const optimisticSessionId = sessionId;
if (optimisticSessionId) {
updateSessionMessages(optimisticSessionId, (current) => [...current, userMessage, assistantMessage]);
}
try {
const confirmedWorkspace = await ensureChatAvailable(assistantMessage.id);
......@@ -3017,10 +3212,13 @@ export default function App() {
if (!sessionId) {
const createdSession = await desktopApi.chat.createSessionForProject(effectiveProjectId);
sessionId = createdSession.id;
setActiveSessionId(createdSession.id);
setSessions((current) => [createdSession, ...current.filter((session) => session.id !== createdSession.id)]);
}
setActiveSessionId(sessionId);
if (!optimisticSessionId) {
updateSessionMessages(sessionId, (current) => [...current, userMessage, assistantMessage]);
} else if (optimisticSessionId !== sessionId) {
moveSessionMessages(optimisticSessionId, sessionId, [userMessageId, assistantMessageId]);
}
updateStreamSmoke(() => ({
phase: "requested",
......@@ -3042,15 +3240,20 @@ export default function App() {
statusLabels: [ui.preparingReply]
}));
updateAssistantStatus(assistantMessage.id, ui.waitingReply);
appendTrace(assistantMessage.id, "await-model", ui.waitingReply);
updateAssistantStatus(assistantMessageId, ui.waitingReply);
appendTrace(assistantMessageId, "await-model", ui.waitingReply);
try {
const stream = await desktopApi.chat.streamPrompt(sessionId, trimmedPrompt, skillId, attachmentsToSend);
replaceSessionMessageId(sessionId, userMessageId, stream.userMessageId);
replaceSessionMessageId(sessionId, assistantMessageId, stream.assistantMessageId);
userMessageId = stream.userMessageId ?? userMessageId;
assistantMessageId = stream.assistantMessageId ?? assistantMessageId;
activeStreamRef.current = {
requestId: stream.requestId,
assistantMessageId: assistantMessage.id,
assistantMessageId,
sessionId: stream.sessionId,
originSessionId: sessionId,
targetText: "",
renderedText: ""
};
......@@ -3060,23 +3263,23 @@ export default function App() {
requestId: stream.requestId,
sessionId: stream.sessionId,
runId: stream.runId,
assistantMessageId,
executionPolicySource: stream.executionPolicy?.source ?? current.executionPolicySource,
executionPolicyModel: stream.executionPolicy?.modelLabel ?? current.executionPolicyModel
} : current);
setActiveSessionId(stream.sessionId);
} catch {
setSendPhase("finalizing");
appendTrace(assistantMessage.id, "fallback", ui.fallbackReply);
updateAssistantStatus(assistantMessage.id, ui.generating);
await completeWithFallback(sessionId, trimmedPrompt, skillId, assistantMessage.id, attachmentsToSend);
appendTrace(assistantMessageId, "fallback", ui.fallbackReply);
updateAssistantStatus(assistantMessageId, ui.generating);
await completeWithFallback(sessionId, trimmedPrompt, skillId, assistantMessageId, attachmentsToSend);
clearComposerAttachment();
}
} catch (error) {
setSendPhase("idle");
const message = err(error);
setMessageTraceExpanded(assistantMessage.id, true);
failPendingAssistant(assistantMessage.id, message);
appendTrace(assistantMessage.id, "error", "\u53d1\u9001\u5931\u8d25", message, "error");
setMessageTraceExpanded(assistantMessageId, true);
failPendingAssistant(assistantMessageId, message);
appendTrace(assistantMessageId, "error", "\u53d1\u9001\u5931\u8d25", message, "error");
updateStreamSmoke((current) => current ? {
...current,
phase: "error",
......@@ -3091,7 +3294,7 @@ export default function App() {
requestId: undefined,
sessionId,
runId: undefined,
assistantMessageId: assistantMessage.id,
assistantMessageId,
startedEventCount: 0,
statusEventCount: 0,
deltaEventCount: 0,
......@@ -3323,7 +3526,7 @@ export default function App() {
if (resetConversation) {
setSessions([]);
setActiveSessionId(EMPTY_SESSION_ID);
setMessages([]);
clearAllSessionMessages();
}
return workspace ?? null;
}
......@@ -3335,7 +3538,7 @@ export default function App() {
setWorkspace(nextWorkspace);
setSessions([]);
setActiveSessionId(EMPTY_SESSION_ID);
setMessages([]);
clearAllSessionMessages();
return nextWorkspace;
} catch (error) {
setErrorText(err(error));
......@@ -3790,7 +3993,15 @@ export default function App() {
<strong>{sidebarSessionTitles[session.id] ?? formatSessionTitle(session.title, index)}</strong>
</button>
{sessions.length > 1 ? (
<button type="button" className="sidebar-session-close" aria-label={ui.closeSession} disabled={projectActionPending} onClick={() => void closeProjectSession(session.id)}>x</button>
<button
type="button"
className="sidebar-session-close"
aria-label={ui.closeSession}
disabled={projectActionPending || (sendPhase !== "idle" && activeStreamRef.current?.sessionId === session.id)}
onClick={() => void closeProjectSession(session.id)}
>
x
</button>
) : null}
</div>
))}
......
export const IPC_CHANNELS = {
workspaceGetSummary: "workspace:get-summary",
workspaceWarmup: "workspace:warmup",
windowMinimize: "window:minimize",
windowMaximize: "window:maximize",
windowClose: "window:close",
gatewayStatus: "gateway:status",
gatewayConnect: "gateway:connect",
gatewayDisconnect: "gateway:disconnect",
......@@ -20,6 +23,7 @@
configSave: "config:save",
projectsList: "projects:list",
projectsSetActive: "projects:set-active",
projectsResolveIntent: "projects:resolve-intent",
skillCatalogList: "skill-catalog:list",
chatListSessions: "chat:list-sessions",
chatListSessionsByProject: "chat:list-sessions-by-project",
......@@ -27,6 +31,7 @@
chatCreateSessionForProject: "chat:create-session-for-project",
chatCloseSession: "chat:close-session",
chatListMessages: "chat:list-messages",
chatPickImageAttachment: "chat:pick-image-attachment",
chatSendPrompt: "chat:send-prompt",
chatStreamPrompt: "chat:stream-prompt",
chatStreamEvent: "chat:stream-event",
......@@ -38,6 +43,7 @@
profileGetSummary: "profile:get-summary",
creditsGetSummary: "credits:get-summary",
skillsList: "skills:list",
expertsList: "experts:list",
modelConfigGetSummary: "model-config:get-summary",
systemGetSummary: "system:get-summary"
} as const;
......@@ -66,6 +72,7 @@ export type SetupMode = "employee-key" | "direct-provider";
export type ChatLaunchState = "unbound" | "starting" | "ready" | "error";
export type WorkspaceStartupPhase = "idle" | "syncing-config" | "syncing-projects" | "starting-runtime" | "connecting-gateway" | "ready" | "error";
export type SkillDownloadState = "pending" | "downloading" | "ready" | "failed" | "removed";
export type ExpertEntryMode = "standalone" | "home-chat-shortcut";
export type DailyReportDeliveryState = "draft" | "sent" | "failed";
export interface WorkspaceWarmupResult {
......@@ -256,6 +263,17 @@ export interface WorkspaceSkillSummary {
lastError?: string;
}
export interface ExpertDefinition {
id: string;
name: string;
entryMode: ExpertEntryMode;
description?: string;
starterPrompt?: string;
promptFile?: string;
promptAvailable: boolean;
projectMatchKeywords: string[];
}
export interface PluginSummary {
id: string;
name: string;
......@@ -349,6 +367,16 @@ export interface ProjectSummary {
defaultEntryType?: ProjectPackageEntryType;
}
export interface ProjectIntentSuggestion {
projectId: string;
projectName: string;
projectDisplayName: string;
score: number;
confidence: "low" | "medium" | "high";
reason: string;
matchedAliases: string[];
}
export interface ProjectSessionSummary extends SessionSummary {
projectId: string;
}
......@@ -386,6 +414,18 @@ export interface ProjectSessionState {
draft: string;
}
export interface ChatAttachment {
kind: "image";
name: string;
mimeType: string;
localPath: string;
}
export interface ProjectResolvedAttachment extends ChatAttachment {
projectPath: string;
relativeProjectPath: string;
}
export interface ProjectExecutionRequest {
sessionId: string;
projectId: string;
......@@ -393,6 +433,7 @@ export interface ProjectExecutionRequest {
userPrompt: string;
context: ProjectContextSnapshot;
selectedSkillId: string | null;
attachments?: ProjectResolvedAttachment[];
projectConfig?: ProjectPackageConfig | null;
}
......@@ -418,9 +459,12 @@ export interface ChatMessage {
role: MessageRole;
content: string;
createdAt: string;
streamState?: "streaming" | "error";
statusLabel?: string;
statusDetail?: string;
}
export type ChatExecutionPolicySource = "cloud-default" | "cloud-skill-binding" | "local-fallback";
export type ChatExecutionPolicySource = "cloud-default" | "cloud-skill-binding" | "local-fallback" | "client-config";
export type ChatExecutionRoutingMode = ModelRoutingMode | SkillModelBindingMode | "fallback";
export interface ChatExecutionPolicy {
......@@ -437,6 +481,8 @@ export interface ChatStreamPromptResult {
requestId: string;
sessionId: string;
runId?: string;
userMessageId?: string;
assistantMessageId: string;
executionPolicy?: ChatExecutionPolicy;
}
......@@ -482,6 +528,7 @@ export interface ChatStreamErrorEvent {
sessionId: string;
runId?: string;
message: string;
errorCategory?: string;
}
export type ChatStreamEvent = ChatStreamStartedEvent | ChatStreamStatusEvent | ChatStreamDeltaEvent | ChatStreamCompletedEvent | ChatStreamErrorEvent;
......@@ -494,6 +541,60 @@ export interface PromptResult {
executionPolicy?: ChatExecutionPolicy;
}
export interface ModelEndpointConfig {
baseUrl: string;
apiKeyConfigured: boolean;
modelId?: string;
}
export interface DigitalHumanModelConfig {
volcRegion: string;
volcService: string;
volcHost: string;
volcScheme: string;
ttsVoice: string;
qiniuBucket: string;
qiniuDomain: string;
qiniuKeyPrefix: string;
volcAccessKeyConfigured: boolean;
volcSecretKeyConfigured: boolean;
qiniuAccessKeyConfigured: boolean;
qiniuSecretKeyConfigured: boolean;
}
export interface ExpertModelConfig {
image: ModelEndpointConfig;
video: ModelEndpointConfig;
copywriting: ModelEndpointConfig;
digitalHuman: DigitalHumanModelConfig;
}
export const FIXED_EXPERT_MODEL_ENDPOINTS = {
copywriting: {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
modelId: "qwen3.5-plus"
},
image: {
baseUrl: "https://ark.cn-beijing.volces.com/api/v3/images/generations",
modelId: "doubao-seedream-5-0-260128"
},
video: {
baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
modelId: "doubao-seedance-2-0-260128"
}
} as const;
export const FIXED_DIGITAL_HUMAN_CONFIG = {
volcRegion: "cn-north-1",
volcService: "cv",
volcHost: "visual.volcengineapi.com",
volcScheme: "https",
ttsVoice: "zh-CN-YunxiNeural",
qiniuBucket: "alketas",
qiniuDomain: "http://tcwwu6wg4.hd-bkt.clouddn.com",
qiniuKeyPrefix: "omnihuman"
} as const;
export interface AppConfig {
setupMode: SetupMode;
provider: string;
......@@ -507,6 +608,7 @@ export interface AppConfig {
cloudApiBaseUrl: string;
runtimeCloudApiBaseUrl: string;
runtimeMode: RuntimeModePreference;
expertModelConfig: ExpertModelConfig;
}
export interface DiagnosticsExportResult {
......@@ -515,6 +617,19 @@ export interface DiagnosticsExportResult {
startupLogPath?: string;
}
export interface ModelEndpointInput {
baseUrl?: string;
apiKey?: string;
modelId?: string;
}
export interface DigitalHumanModelInput {
volcAccessKey?: string;
volcSecretKey?: string;
qiniuAccessKey?: string;
qiniuSecretKey?: string;
}
export interface SaveConfigInput {
setupMode: SetupMode;
provider: string;
......@@ -528,6 +643,12 @@ export interface SaveConfigInput {
cloudApiBaseUrl: string;
runtimeCloudApiBaseUrl: string;
runtimeMode: RuntimeModePreference;
expertModelConfig?: {
image?: ModelEndpointInput;
video?: ModelEndpointInput;
copywriting?: ModelEndpointInput;
digitalHuman?: DigitalHumanModelInput;
};
}
export interface AuthSessionSummary {
......@@ -651,6 +772,11 @@ export interface DesktopApi {
getSummary(): Promise<WorkspaceSummary>;
warmup(): Promise<WorkspaceWarmupResult>;
};
window: {
minimize(): Promise<void>;
maximize(): Promise<void>;
close(): Promise<void>;
};
gateway: {
status(): Promise<GatewayStatus>;
connect(): Promise<GatewayStatus>;
......@@ -681,6 +807,7 @@ export interface DesktopApi {
projects: {
list(): Promise<ProjectSummary[]>;
setActive(projectId: string): Promise<WorkspaceSummary>;
resolveIntent(prompt: string, currentProjectId?: string): Promise<ProjectIntentSuggestion | null>;
};
skillCatalog: {
list(): Promise<SkillCatalogItem[]>;
......@@ -699,6 +826,9 @@ export interface DesktopApi {
skills: {
list(): Promise<SkillSummary[]>;
};
experts: {
list(): Promise<ExpertDefinition[]>;
};
modelConfig: {
getSummary(): Promise<ModelConfigSummary>;
};
......@@ -712,8 +842,9 @@ export interface DesktopApi {
createSessionForProject(projectId: string, title?: string): Promise<ProjectSessionSummary>;
closeSession(sessionId: string): Promise<ProjectSessionSummary[]>;
listMessages(sessionId: string): Promise<ChatMessage[]>;
sendPrompt(sessionId: string, prompt: string, skillId?: string): Promise<PromptResult>;
streamPrompt(sessionId: string, prompt: string, skillId?: string): Promise<ChatStreamPromptResult>;
pickImageAttachment(): Promise<ChatAttachment | null>;
sendPrompt(sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]): Promise<PromptResult>;
streamPrompt(sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]): Promise<ChatStreamPromptResult>;
onStreamEvent(listener: ChatStreamListener): () => void;
};
diagnostics: {
......
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