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

fix(chat): preserve streaming conversation when switching sessions

parent 6dec17bf
import { randomUUID } from "node:crypto"; 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 { import {
IPC_CHANNELS, IPC_CHANNELS,
type AppConfig, type AppConfig,
type ChatAttachment,
type ChatMessage, type ChatMessage,
type ChatStreamEvent, type ChatStreamEvent,
type DesktopApi, type DesktopApi,
type GatewayStatus, type GatewayStatus,
type PluginSummary, type PluginSummary,
type ProjectIntentSuggestion,
type ProjectResolvedAttachment,
type RuntimeCloudFetchAction, type RuntimeCloudFetchAction,
type RuntimeCloudStatus, type RuntimeCloudStatus,
type RuntimeStatus, type RuntimeStatus,
...@@ -25,8 +30,9 @@ import type { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient, ...@@ -25,8 +30,9 @@ import type { AuthClient, CreditClient, ModelConfigClient, OpenClawConfigClient,
import type { DiagnosticsService } from "./services/diagnostics.js"; import type { DiagnosticsService } from "./services/diagnostics.js";
import type { DailyReportService } from "./services/daily-report-service.js"; import type { DailyReportService } from "./services/daily-report-service.js";
import type { SkillCatalogService } from "./services/skill-catalog.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 { SkillStoreService } from "./services/skill-store.js";
import type { SkillClient } from "./services/skill-client.js";
import type { ExpertCatalogService } from "./services/expert-catalog.js";
import { import {
resolveEffectiveGatewayToken, resolveEffectiveGatewayToken,
resolveEffectiveGatewayUrl, resolveEffectiveGatewayUrl,
...@@ -50,6 +56,10 @@ import type { ProjectContextService } from "./services/project-context.js"; ...@@ -50,6 +56,10 @@ import type { ProjectContextService } from "./services/project-context.js";
import type { ProjectExecutionRouter } from "./services/project-execution-router.js"; import type { ProjectExecutionRouter } from "./services/project-execution-router.js";
import type { ProjectSkillRouterService } from "./services/project-skill-router.js"; import type { ProjectSkillRouterService } from "./services/project-skill-router.js";
import type { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js"; import type { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js";
import {
buildProjectModelRuntime,
materializeProjectModelRuntime
} from "./services/project-model-runtime.js";
import { import {
refreshProjectContextAfterExecution, refreshProjectContextAfterExecution,
shouldRefreshProjectContextAfterExecution shouldRefreshProjectContextAfterExecution
...@@ -74,6 +84,7 @@ interface MainServices { ...@@ -74,6 +84,7 @@ interface MainServices {
profileClient: ProfileClient; profileClient: ProfileClient;
creditClient: CreditClient; creditClient: CreditClient;
skillClient: SkillClient; skillClient: SkillClient;
expertCatalogService: ExpertCatalogService;
skillCatalogService: SkillCatalogService; skillCatalogService: SkillCatalogService;
skillStore: SkillStoreService; skillStore: SkillStoreService;
modelConfigClient: ModelConfigClient; modelConfigClient: ModelConfigClient;
...@@ -114,6 +125,105 @@ function toControlUiUrl(gatewayUrl: string): string { ...@@ -114,6 +125,105 @@ function toControlUiUrl(gatewayUrl: string): string {
return url.toString(); 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 = [ const PLUGIN_SPECS = [
{ {
id: "spreadsheet-tools", id: "spreadsheet-tools",
...@@ -226,6 +336,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -226,6 +336,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
profileClient, profileClient,
secretManager, secretManager,
skillClient, skillClient,
expertCatalogService,
skillCatalogService, skillCatalogService,
modelConfigClient, modelConfigClient,
runtimeCloudClient, runtimeCloudClient,
...@@ -275,7 +386,99 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -275,7 +386,99 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
gatewayUrl: resolveEffectiveGatewayUrl(config.gatewayUrl, getDiscoveredGatewayUrl(config.runtimeMode)), gatewayUrl: resolveEffectiveGatewayUrl(config.gatewayUrl, getDiscoveredGatewayUrl(config.runtimeMode)),
apiKeyConfigured: Boolean((await secretManager.getApiKey()) || config.apiKeyConfigured), apiKeyConfigured: Boolean((await secretManager.getApiKey()) || config.apiKeyConfigured),
gatewayTokenConfigured: Boolean((await getEffectiveGatewayToken(config)) || config.gatewayTokenConfigured), 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 ...@@ -560,6 +763,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
sessions, sessions,
skills skills
} = await loadActiveProjectWorkspaceState(projectStore); } = await loadActiveProjectWorkspaceState(projectStore);
const missingClientChatModel = !config.expertModelConfig.copywriting.baseUrl.trim()
|| !config.expertModelConfig.copywriting.apiKeyConfigured
|| !(config.expertModelConfig.copywriting.modelId ?? "").trim();
const bundleSyncStatus = projectBundleService.getSyncStatus(); const bundleSyncStatus = projectBundleService.getSyncStatus();
const bundleSyncFailed = bundleSyncStatus.state === "error"; const bundleSyncFailed = bundleSyncStatus.state === "error";
const shellReady = !bundleSyncFailed && isWorkspaceShellReady({ const shellReady = !bundleSyncFailed && isWorkspaceShellReady({
...@@ -589,6 +795,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -589,6 +795,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
startupPhase: "error" as const, startupPhase: "error" as const,
startupMessage: bundleSyncStatus.lastError ?? "Workspace project sync failed. Check network access and retry." 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 : shouldWaitForProjectSync
? buildProjectSyncSummary(bundleSyncStatus.lastError ?? EMPTY_PROJECT_INVENTORY_MESSAGE) ? buildProjectSyncSummary(bundleSyncStatus.lastError ?? EMPTY_PROJECT_INVENTORY_MESSAGE)
: baseChatSummary; : baseChatSummary;
...@@ -607,8 +821,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -607,8 +821,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
employeeId: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.employeeId : undefined, employeeId: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.employeeId : undefined,
employeeName: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.employeeName : undefined, employeeName: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.employeeName : undefined,
welcomeMessage: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.welcomeMessage : undefined, welcomeMessage: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.welcomeMessage : undefined,
modelId: runtimeCloudStatus.config?.modelId ?? config.defaultModel, modelId: config.expertModelConfig.copywriting.modelId || undefined,
modelDisplayName: runtimeCloudStatus.config?.modelDisplayName ?? config.defaultModel, modelDisplayName: config.expertModelConfig.copywriting.modelId || undefined,
configVersion: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.configVersion : undefined, configVersion: config.setupMode === "employee-key" ? runtimeCloudStatus.config?.configVersion : undefined,
lastFetchedAt: runtimeCloudStatus.lastFetchedAt ?? runtimeCloudStatus.config?.fetchedAt, lastFetchedAt: runtimeCloudStatus.lastFetchedAt ?? runtimeCloudStatus.config?.fetchedAt,
runtimeCloudState: runtimeCloudStatus.state, runtimeCloudState: runtimeCloudStatus.state,
...@@ -709,40 +923,46 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -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 config = await getEffectiveConfig();
const [runtimeCloudStatus, skills] = await Promise.all([ const skills = await projectStore.listProjectSkills(projectId);
runtimeCloudClient.getStatus(),
projectStore.listProjectSkills(projectId)
]);
const selectedSkill = skillId ? skills.find((skill) => skill.id === skillId) : undefined; 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 { return {
source: skillId ? "cloud-skill-binding" as const : "cloud-default" as const, source: "client-config" as const,
modelId: configuredModelId, modelId: workspaceEntryModelId,
modelLabel: configuredModelLabel, modelLabel: workspaceEntryModelId,
routingMode: "platform-managed" as const, routingMode: "platform-managed" as const,
skillId, skillId,
skillName: selectedSkill?.name, skillName: selectedSkill?.name,
message: skillId message: "Workspace-entry project is using client-configured expert models."
? `Skill ${selectedSkill?.name ?? skillId} is bound to cloud model ${configuredModelLabel}.`
: `Using cloud default model ${configuredModelLabel}.`
}; };
} }
const chatModel = await resolveConfiguredChatModel(config);
if (chatModel.missing.length > 0) {
throw new Error(`请先在客户端设置中配置文案模型(首页对话兜底):${chatModel.missing.join("、")}`);
}
return { return {
source: "local-fallback" as const, source: "client-config" as const,
modelId: config.defaultModel, modelId: chatModel.modelId,
modelLabel: config.defaultModel, modelLabel: chatModel.modelId,
routingMode: "fallback" as const, routingMode: "platform-managed" as const,
skillId, skillId,
skillName: selectedSkill?.name, skillName: selectedSkill?.name,
message: skillId message: skillId
? `Skill ${selectedSkill?.name ?? skillId} is using local fallback model ${config.defaultModel}.` ? `Skill ${selectedSkill?.name ?? skillId} is using client copywriting model ${chatModel.modelId}.`
: `Using local fallback model ${config.defaultModel}.` : `Using client copywriting model ${chatModel.modelId}.`
}; };
}; };
...@@ -781,11 +1001,18 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -781,11 +1001,18 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return undefined; return undefined;
}; };
const createChatMessage = (role: ChatMessage["role"], content: string): ChatMessage => ({ const createChatMessage = (
id: randomUUID(), role: ChatMessage["role"],
content: string,
overrides: Partial<ChatMessage> = {}
): ChatMessage => ({
id: overrides.id ?? randomUUID(),
role, role,
content, 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[]> => { const ensureLocalTranscript = async (sessionId: string): Promise<ChatMessage[]> => {
...@@ -805,9 +1032,40 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -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 requestedSkillId = skillId?.trim() ? skillId.trim() : null;
const target = await projectChatTargetResolver.resolve(sessionId, prompt, requestedSkillId); 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 resolvedSessionId = target.sessionState.sessionId;
const projectConfig = await projectStore.getProjectPackageConfig(target.sessionState.projectId); const projectConfig = await projectStore.getProjectPackageConfig(target.sessionState.projectId);
const snapshot = await projectContextService.getSnapshot(target.sessionState.projectId); const snapshot = await projectContextService.getSnapshot(target.sessionState.projectId);
...@@ -820,9 +1078,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -820,9 +1078,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
userPrompt: prompt, userPrompt: prompt,
context: snapshot, context: snapshot,
selectedSkillId: null, selectedSkillId: null,
attachments: resolvedAttachments,
projectConfig projectConfig
}); });
const preferWorkspaceEntry = declaredWorkspaceEntryDecision?.kind === "workspace-entry"; const preferWorkspaceEntry = resolvedAttachments.length > 0 || declaredWorkspaceEntryDecision?.kind === "workspace-entry";
const autoSkillRoute = requestedSkillId const autoSkillRoute = requestedSkillId
|| preferWorkspaceEntry || preferWorkspaceEntry
? null ? null
...@@ -847,6 +1106,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -847,6 +1106,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
userPrompt: prompt, userPrompt: prompt,
context: snapshot, context: snapshot,
selectedSkillId: candidateSkillId, selectedSkillId: candidateSkillId,
attachments: resolvedAttachments,
projectConfig projectConfig
}) })
: (declaredWorkspaceEntryDecision ?? await projectExecutionRouter.decide({ : (declaredWorkspaceEntryDecision ?? await projectExecutionRouter.decide({
...@@ -856,8 +1116,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -856,8 +1116,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
userPrompt: prompt, userPrompt: prompt,
context: snapshot, context: snapshot,
selectedSkillId: null, selectedSkillId: null,
attachments: resolvedAttachments,
projectConfig 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; const selectedSkillId = decision.kind === "skill" ? decision.skillId : null;
await projectStore.setSessionSelectedSkill(resolvedSessionId, selectedSkillId); await projectStore.setSessionSelectedSkill(resolvedSessionId, selectedSkillId);
const reboundSessionState = await projectStore.getSessionState(resolvedSessionId); const reboundSessionState = await projectStore.getSessionState(resolvedSessionId);
...@@ -865,7 +1129,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -865,7 +1129,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
await projectStore.bindSessionContextSnapshot(resolvedSessionId, snapshot.snapshotId); await projectStore.bindSessionContextSnapshot(resolvedSessionId, snapshot.snapshotId);
} }
const executionSkillId = decision.kind === "skill" ? decision.skillId : undefined; 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); const gatewayPrompt = await prepareGatewayPrompt(decision, reboundSessionState.projectId);
return { return {
sessionState: reboundSessionState, sessionState: reboundSessionState,
...@@ -873,6 +1137,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -873,6 +1137,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
decision, decision,
executionPolicy, executionPolicy,
gatewayPrompt, gatewayPrompt,
attachments: resolvedAttachments,
route: target.route, route: target.route,
autoRouted: target.autoRouted, autoRouted: target.autoRouted,
previousProjectId: target.previousProjectId, previousProjectId: target.previousProjectId,
...@@ -882,27 +1147,33 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -882,27 +1147,33 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}; };
const listChatMessages = async (sessionId: string): Promise<ChatMessage[]> => ensureLocalTranscript(sessionId); const listChatMessages = async (sessionId: string): Promise<ChatMessage[]> => ensureLocalTranscript(sessionId);
const sendPrompt = async (sessionId: string, prompt: string, skillId?: string) => { const sendPrompt = async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => {
const preparedExecution = await prepareProjectAwareExecution(sessionId, prompt, skillId); const preparedExecution = await prepareProjectAwareExecution(sessionId, prompt, skillId, attachments);
const executionSessionId = preparedExecution.sessionState.sessionId; const executionSessionId = preparedExecution.sessionState.sessionId;
const executionSkillId = preparedExecution.decision.kind === "skill" ? preparedExecution.decision.skillId : undefined; const executionSkillId = preparedExecution.decision.kind === "skill" ? preparedExecution.decision.skillId : undefined;
const shouldScheduleContextRefresh = shouldRefreshProjectContextAfterExecution(preparedExecution.decision); const shouldScheduleContextRefresh = shouldRefreshProjectContextAfterExecution(preparedExecution.decision);
await projectStore.updateSessionLastActive(executionSessionId); await projectStore.updateSessionLastActive(executionSessionId);
await ensureLocalTranscript(executionSessionId); await ensureLocalTranscript(executionSessionId);
await projectStore.appendSessionMessage(executionSessionId, createChatMessage("user", prompt)); await projectStore.appendSessionMessage(executionSessionId, createChatMessage("user", prompt));
runtimeCloudSupervisor.noteMessageReceived(executionSessionId, prompt, executionSkillId); runtimeCloudSupervisor.noteMessageReceived(executionSessionId, prompt, executionSkillId);
try { try {
if (preparedExecution.decision.kind === "workspace-entry") { if (preparedExecution.decision.kind === "workspace-entry") {
const result = await projectWorkspaceExecutor.execute({ const projectModelEnv = await prepareProjectModelRuntime(
sessionId: executionSessionId, preparedExecution.sessionState.projectId,
projectRoot: preparedExecution.sessionState.projectRoot, preparedExecution.sessionState.projectRoot
prompt: preparedExecution.decision.preparedPrompt, );
userPrompt: prompt const result = await projectWorkspaceExecutor.execute({
}); sessionId: executionSessionId,
await projectStore.appendSessionMessage(executionSessionId, result.reply); projectRoot: preparedExecution.sessionState.projectRoot,
await projectStore.updateSessionLastActive(executionSessionId).catch(() => undefined); prompt: preparedExecution.decision.preparedPrompt,
runtimeCloudSupervisor.noteMessageSent(executionSessionId, result.reply.content, preparedExecution.executionPolicy.modelId, executionSkillId); userPrompt: prompt,
return { attachments: preparedExecution.attachments,
extraEnv: projectModelEnv
});
await projectStore.appendSessionMessage(executionSessionId, result.reply);
await projectStore.updateSessionLastActive(executionSessionId).catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(executionSessionId, result.reply.content, preparedExecution.executionPolicy.modelId, executionSkillId);
return {
sessionId: executionSessionId, sessionId: executionSessionId,
reply: result.reply, reply: result.reply,
executionPolicy: preparedExecution.executionPolicy executionPolicy: preparedExecution.executionPolicy
...@@ -934,8 +1205,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -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 requestId = randomUUID();
const userMessageId = randomUUID();
const assistantMessageId = randomUUID();
let executionPolicy: Awaited<ReturnType<typeof resolveExecutionPolicy>> | null = null; let executionPolicy: Awaited<ReturnType<typeof resolveExecutionPolicy>> | null = null;
let executionSkillId: string | undefined; let executionSkillId: string | undefined;
let executionSessionId = sessionId; let executionSessionId = sessionId;
...@@ -946,6 +1219,24 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -946,6 +1219,24 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
let ready = false; let ready = false;
let startedEvent: ChatStreamEvent | null = null; let startedEvent: ChatStreamEvent | null = null;
const queuedEvents: ChatStreamEvent[] = []; 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 = () => { const queueProjectContextRefresh = () => {
if (contextRefreshQueued || !shouldScheduleContextRefresh || !refreshProjectId) { if (contextRefreshQueued || !shouldScheduleContextRefresh || !refreshProjectId) {
return; return;
...@@ -982,14 +1273,15 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -982,14 +1273,15 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}, 0); }, 0);
}; };
try { try {
const initialStatusLabel = skillId ? "Preparing project context and skill" : "Preparing project context";
queueOrSend({ queueOrSend({
type: "status", type: "status",
requestId, requestId,
sessionId, sessionId,
stage: "prepare-request", 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; executionSessionId = preparedExecution.sessionState.sessionId;
executionPolicy = preparedExecution.executionPolicy; executionPolicy = preparedExecution.executionPolicy;
executionSkillId = preparedExecution.decision.kind === "skill" ? preparedExecution.decision.skillId : undefined; executionSkillId = preparedExecution.decision.kind === "skill" ? preparedExecution.decision.skillId : undefined;
...@@ -1017,25 +1309,46 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1017,25 +1309,46 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
} }
await projectStore.updateSessionLastActive(executionSessionId); await projectStore.updateSessionLastActive(executionSessionId);
await ensureLocalTranscript(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); runtimeCloudSupervisor.noteMessageReceived(executionSessionId, prompt, executionSkillId);
const awaitingLabel = "Question received, preparing response";
await updateAssistantTranscript((current) => ({
...current,
streamState: "streaming",
statusLabel: awaitingLabel,
statusDetail: undefined
}));
queueOrSend({ queueOrSend({
type: "status", type: "status",
requestId, requestId,
sessionId: executionSessionId, sessionId: executionSessionId,
stage: "await-model", stage: "await-model",
label: "Question received, preparing response" label: awaitingLabel
}); });
if (preparedExecution.decision.kind === "workspace-entry") { if (preparedExecution.decision.kind === "workspace-entry") {
ready = true; ready = true;
flushQueuedEvents(); flushQueuedEvents();
void (async () => { void (async () => {
try { try {
const projectModelEnv = await prepareProjectModelRuntime(
preparedExecution.sessionState.projectId,
preparedExecution.sessionState.projectRoot
);
const result = await projectWorkspaceExecutor.execute({ const result = await projectWorkspaceExecutor.execute({
sessionId: executionSessionId, sessionId: executionSessionId,
projectRoot: preparedExecution.sessionState.projectRoot, projectRoot: preparedExecution.sessionState.projectRoot,
prompt: preparedExecution.decision.preparedPrompt, prompt: preparedExecution.decision.preparedPrompt,
userPrompt: prompt userPrompt: prompt,
attachments: preparedExecution.attachments,
extraEnv: projectModelEnv
}, { }, {
onStarted: (runId) => { onStarted: (runId) => {
queueOrSend({ queueOrSend({
...@@ -1047,6 +1360,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1047,6 +1360,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}); });
}, },
onStatus: (stage, label, detail) => { onStatus: (stage, label, detail) => {
void updateAssistantTranscript((current) => ({
...current,
streamState: "streaming",
statusLabel: label,
statusDetail: detail
}));
queueOrSend({ queueOrSend({
type: "status", type: "status",
requestId, requestId,
...@@ -1057,6 +1376,15 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1057,6 +1376,15 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}); });
}, },
onDelta: (textDelta, fullText, runId) => { 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({ queueOrSend({
type: "delta", type: "delta",
requestId, requestId,
...@@ -1068,7 +1396,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1068,7 +1396,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
} }
}); });
settled = true; 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); await projectStore.updateSessionLastActive(executionSessionId).catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(executionSessionId, result.reply.content, executionPolicy?.modelId, executionSkillId); runtimeCloudSupervisor.noteMessageSent(executionSessionId, result.reply.content, executionPolicy?.modelId, executionSkillId);
queueOrSend({ queueOrSend({
...@@ -1082,6 +1417,16 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1082,6 +1417,16 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
} catch (error) { } catch (error) {
settled = true; settled = true;
const message = error instanceof Error ? error.message : String(error); 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, { runtimeCloudSupervisor.noteError("chat_stream_failed", message, {
modelId: executionPolicy?.modelId, modelId: executionPolicy?.modelId,
sessionId: executionSessionId sessionId: executionSessionId
...@@ -1090,7 +1435,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1090,7 +1435,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
type: "error", type: "error",
requestId, requestId,
sessionId: executionSessionId, sessionId: executionSessionId,
message message,
errorCategory: errorCategory || undefined
}); });
} finally { } finally {
queueProjectContextRefresh(); queueProjectContextRefresh();
...@@ -1099,6 +1445,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1099,6 +1445,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return { return {
requestId, requestId,
sessionId: executionSessionId, sessionId: executionSessionId,
userMessageId,
assistantMessageId,
executionPolicy: executionPolicy ?? undefined executionPolicy: executionPolicy ?? undefined
}; };
} }
...@@ -1106,6 +1454,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1106,6 +1454,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
reason: "chat-stream", reason: "chat-stream",
execute: () => gatewayClient.streamPrompt(executionSessionId, preparedExecution.gatewayPrompt ?? prompt, { execute: () => gatewayClient.streamPrompt(executionSessionId, preparedExecution.gatewayPrompt ?? prompt, {
onStarted: ({ sessionId: nextSessionId, runId }) => { onStarted: ({ sessionId: nextSessionId, runId }) => {
executionSessionId = nextSessionId;
queueOrSend({ queueOrSend({
type: "started", type: "started",
requestId, requestId,
...@@ -1115,6 +1464,13 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1115,6 +1464,13 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}); });
}, },
onStatus: ({ sessionId: nextSessionId, runId, stage, label, detail }) => { onStatus: ({ sessionId: nextSessionId, runId, stage, label, detail }) => {
executionSessionId = nextSessionId;
void updateAssistantTranscript((current) => ({
...current,
streamState: "streaming",
statusLabel: label,
statusDetail: detail
}));
queueOrSend({ queueOrSend({
type: "status", type: "status",
requestId, requestId,
...@@ -1126,6 +1482,16 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1126,6 +1482,16 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}); });
}, },
onDelta: ({ sessionId: nextSessionId, runId, textDelta, fullText }) => { 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({ queueOrSend({
type: "delta", type: "delta",
requestId, requestId,
...@@ -1136,9 +1502,17 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1136,9 +1502,17 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}); });
}, },
onCompleted: ({ sessionId: nextSessionId, runId, reply }) => { onCompleted: ({ sessionId: nextSessionId, runId, reply }) => {
executionSessionId = nextSessionId;
settled = true; settled = true;
void (async () => { 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); await projectStore.updateSessionLastActive(nextSessionId).catch(() => undefined);
})().catch(() => undefined); })().catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(nextSessionId, reply.content, executionPolicy?.modelId, executionSkillId); runtimeCloudSupervisor.noteMessageSent(nextSessionId, reply.content, executionPolicy?.modelId, executionSkillId);
...@@ -1153,7 +1527,18 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1153,7 +1527,18 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
queueProjectContextRefresh(); queueProjectContextRefresh();
}, },
onError: ({ sessionId: nextSessionId, runId, error }) => { onError: ({ sessionId: nextSessionId, runId, error }) => {
executionSessionId = nextSessionId;
settled = true; 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, { runtimeCloudSupervisor.noteError("chat_stream_failed", error.message, {
modelId: executionPolicy?.modelId, modelId: executionPolicy?.modelId,
sessionId: nextSessionId sessionId: nextSessionId
...@@ -1163,7 +1548,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1163,7 +1548,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
requestId, requestId,
sessionId: nextSessionId, sessionId: nextSessionId,
runId, runId,
message: error.message message: error.message,
errorCategory: errorCategory || undefined
}); });
queueProjectContextRefresh(); queueProjectContextRefresh();
} }
...@@ -1177,7 +1563,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1177,7 +1563,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
runId: stream.runId, runId: stream.runId,
executionPolicy: executionPolicy ?? undefined 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) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (!settled) { if (!settled) {
...@@ -1192,6 +1585,23 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1192,6 +1585,23 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}; };
ipcMain.handle(IPC_CHANNELS.workspaceGetSummary, async () => buildWorkspaceSummary()); ipcMain.handle(IPC_CHANNELS.workspaceGetSummary, async () => buildWorkspaceSummary());
ipcMain.handle(IPC_CHANNELS.workspaceWarmup, async () => queueWorkspaceWarmup("workspace-warmup", { action: "init" })); 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.gatewayStatus, async () => gatewayClient.status());
ipcMain.handle(IPC_CHANNELS.gatewayConnect, async () => gatewayClient.connect()); ipcMain.handle(IPC_CHANNELS.gatewayConnect, async () => gatewayClient.connect());
ipcMain.handle(IPC_CHANNELS.gatewayDisconnect, async () => gatewayClient.disconnect()); ipcMain.handle(IPC_CHANNELS.gatewayDisconnect, async () => gatewayClient.disconnect());
...@@ -1218,6 +1628,11 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1218,6 +1628,11 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
ipcMain.handle(IPC_CHANNELS.configSave, async (_event, input: SaveConfigInput) => { ipcMain.handle(IPC_CHANNELS.configSave, async (_event, input: SaveConfigInput) => {
const previousConfig = await configService.load(); const previousConfig = await configService.load();
const config = await configService.save(input); 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") { if (typeof input.apiKey === "string") {
await secretManager.setApiKey(input.apiKey || undefined); await secretManager.setApiKey(input.apiKey || undefined);
} }
...@@ -1227,6 +1642,27 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1227,6 +1642,27 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
if (typeof input.authToken === "string") { if (typeof input.authToken === "string") {
await secretManager.setAuthToken(input.authToken || undefined); 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 ( if (
config.setupMode === "direct-provider" config.setupMode === "direct-provider"
|| previousConfig.setupMode !== config.setupMode || previousConfig.setupMode !== config.setupMode
...@@ -1238,6 +1674,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1238,6 +1674,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
await runtimeCloudClient.clearCache().catch(() => undefined); await runtimeCloudClient.clearCache().catch(() => undefined);
} }
await runtimeManager.setRequestedMode(config.runtimeMode); await runtimeManager.setRequestedMode(config.runtimeMode);
if (shouldSyncChatManagedConfig) {
await runtimeManager.syncManagedConfig("sync");
}
if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) { if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) {
await reconfigureGatewayClient(config, input.gatewayToken); await reconfigureGatewayClient(config, input.gatewayToken);
...@@ -1263,17 +1702,46 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1263,17 +1702,46 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
ipcMain.handle(IPC_CHANNELS.authGetSession, async () => authClient.getSessionSummary()); ipcMain.handle(IPC_CHANNELS.authGetSession, async () => authClient.getSessionSummary());
ipcMain.handle(IPC_CHANNELS.authSignIn, async (_event, input: SignInInput) => { ipcMain.handle(IPC_CHANNELS.authSignIn, async (_event, input: SignInInput) => {
const session = await authClient.signIn(input.accessToken); 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; return session;
}); });
ipcMain.handle(IPC_CHANNELS.authSignOut, async () => { ipcMain.handle(IPC_CHANNELS.authSignOut, async () => {
const session = await authClient.signOut(); 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; return session;
}); });
ipcMain.handle(IPC_CHANNELS.profileGetSummary, async () => profileClient.getSummary()); ipcMain.handle(IPC_CHANNELS.profileGetSummary, async () => profileClient.getSummary());
ipcMain.handle(IPC_CHANNELS.creditsGetSummary, async () => creditClient.getSummary()); ipcMain.handle(IPC_CHANNELS.creditsGetSummary, async () => creditClient.getSummary());
ipcMain.handle(IPC_CHANNELS.skillsList, async () => skillClient.list()); 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.modelConfigGetSummary, async () => modelConfigClient.getSummary());
ipcMain.handle(IPC_CHANNELS.systemGetSummary, async () => systemSummary); ipcMain.handle(IPC_CHANNELS.systemGetSummary, async () => systemSummary);
ipcMain.handle(IPC_CHANNELS.skillCatalogList, async () => skillCatalogService.listForActiveProject()); ipcMain.handle(IPC_CHANNELS.skillCatalogList, async () => skillCatalogService.listForActiveProject());
...@@ -1283,6 +1751,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1283,6 +1751,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
await projectStore.setActiveProject(projectId); await projectStore.setActiveProject(projectId);
return buildWorkspaceSummary(); return buildWorkspaceSummary();
}); });
ipcMain.handle(IPC_CHANNELS.projectsResolveIntent, async (_event, prompt: string, currentProjectId?: string) => {
return resolveProjectIntentSuggestion(prompt, currentProjectId);
});
ipcMain.handle(IPC_CHANNELS.chatListSessions, async () => { ipcMain.handle(IPC_CHANNELS.chatListSessions, async () => {
const sessions = await listSessionsForActiveProject(projectStore); const sessions = await listSessionsForActiveProject(projectStore);
...@@ -1310,11 +1781,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1310,11 +1781,12 @@ 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.chatSendPrompt, async (_event, sessionId: string, prompt: string, skillId?: string) => { ipcMain.handle(IPC_CHANNELS.chatPickImageAttachment, async (event) => pickImageAttachment(BrowserWindow.fromWebContents(event.sender)));
return sendPrompt(sessionId, prompt, skillId); 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) => { ipcMain.handle(IPC_CHANNELS.chatStreamPrompt, async (event, sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => {
return streamPrompt(sessionId, prompt, skillId, event.sender); return streamPrompt(sessionId, prompt, skillId, attachments, event.sender);
}); });
ipcMain.handle(IPC_CHANNELS.diagnosticsOpenControlUi, async () => { ipcMain.handle(IPC_CHANNELS.diagnosticsOpenControlUi, async () => {
const config = await getEffectiveConfig(); const config = await getEffectiveConfig();
...@@ -1327,6 +1799,11 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1327,6 +1799,11 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
getSummary: () => buildWorkspaceSummary(), getSummary: () => buildWorkspaceSummary(),
warmup: () => queueWorkspaceWarmup("workspace-warmup", { action: "init" }) warmup: () => queueWorkspaceWarmup("workspace-warmup", { action: "init" })
}, },
window: {
minimize: async () => undefined,
maximize: async () => undefined,
close: async () => undefined
},
gateway: { gateway: {
status: () => gatewayClient.status(), status: () => gatewayClient.status(),
connect: () => gatewayClient.connect(), connect: () => gatewayClient.connect(),
...@@ -1359,6 +1836,11 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1359,6 +1836,11 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
load: () => getEffectiveConfig(), load: () => getEffectiveConfig(),
save: async (input: SaveConfigInput) => { save: async (input: SaveConfigInput) => {
const config = await configService.save(input); 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") { if (typeof input.apiKey === "string") {
await secretManager.setApiKey(input.apiKey || undefined); await secretManager.setApiKey(input.apiKey || undefined);
} }
...@@ -1368,7 +1850,31 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1368,7 +1850,31 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
if (typeof input.authToken === "string") { if (typeof input.authToken === "string") {
await secretManager.setAuthToken(input.authToken || undefined); 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); await runtimeManager.setRequestedMode(config.runtimeMode);
if (shouldSyncChatManagedConfig) {
await runtimeManager.syncManagedConfig("sync");
}
if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) { if (config.runtimeMode !== "external-gateway" && (await secretManager.getApiKey())) {
await reconfigureGatewayClient(config, input.gatewayToken); await reconfigureGatewayClient(config, input.gatewayToken);
...@@ -1391,7 +1897,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1391,7 +1897,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
setActive: async (projectId: string) => { setActive: async (projectId: string) => {
await projectStore.setActiveProject(projectId); await projectStore.setActiveProject(projectId);
return buildWorkspaceSummary(); return buildWorkspaceSummary();
} },
resolveIntent: (prompt: string, currentProjectId?: string) => resolveProjectIntentSuggestion(prompt, currentProjectId)
}, },
skillCatalog: { skillCatalog: {
list: () => skillCatalogService.listForActiveProject() list: () => skillCatalogService.listForActiveProject()
...@@ -1410,6 +1917,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1410,6 +1917,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
skills: { skills: {
list: () => skillClient.list() list: () => skillClient.list()
}, },
experts: {
list: () => Promise.resolve(expertCatalogService.list())
},
modelConfig: { modelConfig: {
getSummary: () => modelConfigClient.getSummary() getSummary: () => modelConfigClient.getSummary()
}, },
...@@ -1443,8 +1953,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1443,8 +1953,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return sessions; return sessions;
}, },
listMessages: (sessionId: string) => listChatMessages(sessionId), listMessages: (sessionId: string) => listChatMessages(sessionId),
sendPrompt: async (sessionId: string, prompt: string, skillId?: string) => sendPrompt(sessionId, prompt, skillId), pickImageAttachment: async () => pickImageAttachment(BrowserWindow.getFocusedWindow() ?? null),
streamPrompt: async (sessionId: string, prompt: string, skillId?: string) => streamPrompt(sessionId, prompt, skillId), 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) => { onStreamEvent: (listener) => {
streamListeners.add(listener); streamListeners.add(listener);
return () => { return () => {
......
...@@ -712,6 +712,17 @@ export class ProjectStoreService { ...@@ -712,6 +712,17 @@ export class ProjectStoreService {
await writeJsonFile(await this.getSessionMessagesPath(sessionId), messages); 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[]> { async listCurrentProjectSkills(): Promise<WorkspaceSkillSummary[]> {
const project = await this.getActiveProject(); const project = await this.getActiveProject();
return this.listProjectSkills(project.id); return this.listProjectSkills(project.id);
......
...@@ -34,6 +34,7 @@ type Tone = "positive" | "warning" | "info"; ...@@ -34,6 +34,7 @@ type Tone = "positive" | "warning" | "info";
type MessageStreamState = "streaming" | "error"; type MessageStreamState = "streaming" | "error";
type SendPhase = "idle" | "preparing" | "streaming" | "finalizing"; type SendPhase = "idle" | "preparing" | "streaming" | "finalizing";
type TraceTone = "info" | "error" | "success"; type TraceTone = "info" | "error" | "success";
type MessagesBySession = Record<string, UiChatMessage[]>;
type UiChatMessage = ChatMessage & { type UiChatMessage = ChatMessage & {
streamState?: MessageStreamState; streamState?: MessageStreamState;
...@@ -65,6 +66,7 @@ interface ActiveStreamState { ...@@ -65,6 +66,7 @@ interface ActiveStreamState {
requestId: string; requestId: string;
assistantMessageId: string; assistantMessageId: string;
sessionId: string; sessionId: string;
originSessionId: string;
targetText: string; targetText: string;
renderedText: string; renderedText: string;
finalReply?: ChatMessage; finalReply?: ChatMessage;
...@@ -145,7 +147,12 @@ function createClientMessageId(prefix: string): string { ...@@ -145,7 +147,12 @@ function createClientMessageId(prefix: string): string {
} }
function toUiChatMessage(message: ChatMessage, streamState?: MessageStreamState, statusLabel?: string, statusDetail?: string): UiChatMessage { 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[] { function toPlainMessages(items: UiChatMessage[]): ChatMessage[] {
...@@ -156,6 +163,49 @@ function isPrimaryChatMessage(message: ChatMessage): boolean { ...@@ -156,6 +163,49 @@ function isPrimaryChatMessage(message: ChatMessage): boolean {
return message.role === "user" || message.role === "assistant"; 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[] { function pushTraceItem(current: ConversationTraceItem[], item: ConversationTraceItem): ConversationTraceItem[] {
const lastItem = current.at(-1); const lastItem = current.at(-1);
if (lastItem && lastItem.stage === item.stage && lastItem.label === item.label && lastItem.detail === item.detail && lastItem.tone === item.tone) { if (lastItem && lastItem.stage === item.stage && lastItem.label === item.label && lastItem.detail === item.detail && lastItem.tone === item.tone) {
...@@ -948,6 +998,8 @@ const mockDesktopApi = { ...@@ -948,6 +998,8 @@ const mockDesktopApi = {
const requestId = createClientMessageId("mock-request"); const requestId = createClientMessageId("mock-request");
const runId = createClientMessageId("mock-run"); const runId = createClientMessageId("mock-run");
const sessionId = _sessionId || "project:xiaohongshu:default"; 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 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 replyText = "Mock: " + prompt;
const chunks = replyText.match(/.{1,6}/g) ?? [replyText]; const chunks = replyText.match(/.{1,6}/g) ?? [replyText];
...@@ -971,11 +1023,11 @@ const mockDesktopApi = { ...@@ -971,11 +1023,11 @@ const mockDesktopApi = {
requestId, requestId,
sessionId, sessionId,
runId, 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 executionPolicy
}); });
}, 90 * (chunks.length + 1)); }, 90 * (chunks.length + 1));
return { requestId, sessionId, runId, executionPolicy }; return { requestId, sessionId, runId, userMessageId, assistantMessageId, executionPolicy };
}, },
onStreamEvent: (listener: ChatStreamListener) => { onStreamEvent: (listener: ChatStreamListener) => {
mockChatStreamListeners.add(listener); mockChatStreamListeners.add(listener);
...@@ -1037,6 +1089,7 @@ declare global { ...@@ -1037,6 +1089,7 @@ declare global {
systemSummary: SystemSummary | null; systemSummary: SystemSummary | null;
sessions: SessionSummary[]; sessions: SessionSummary[];
messages: ChatMessage[]; messages: ChatMessage[];
messagesBySession: Record<string, ChatMessage[]>;
logs: LogEntry[]; logs: LogEntry[];
activeSessionId: string; activeSessionId: string;
expertProjectIds: string[]; expertProjectIds: string[];
...@@ -1098,6 +1151,8 @@ declare global { ...@@ -1098,6 +1151,8 @@ declare global {
expertModelConfig: AppConfig["expertModelConfig"]; expertModelConfig: AppConfig["expertModelConfig"];
apiKeyConfigured: boolean; 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" }>; clickWindowControl(kind: "minimize" | "maximize" | "close"): Promise<{ kind: "minimize" | "maximize" | "close" }>;
}; };
} }
...@@ -1587,7 +1642,7 @@ export default function App() { ...@@ -1587,7 +1642,7 @@ export default function App() {
const [gatewayStatus, setGatewayStatus] = useState<GatewayStatus | null>(null); const [gatewayStatus, setGatewayStatus] = useState<GatewayStatus | null>(null);
const [gatewayHealth, setGatewayHealth] = useState<GatewayHealth | null>(null); const [gatewayHealth, setGatewayHealth] = useState<GatewayHealth | null>(null);
const [sessions, setSessions] = useState<WorkspaceSummary["sessions"]>([]); const [sessions, setSessions] = useState<WorkspaceSummary["sessions"]>([]);
const [messages, setMessages] = useState<UiChatMessage[]>([]); const [messagesBySession, setMessagesBySession] = useState<MessagesBySession>({});
const [activeSessionId, setActiveSessionId] = useState(EMPTY_SESSION_ID); const [activeSessionId, setActiveSessionId] = useState(EMPTY_SESSION_ID);
const [projectActionPending, setProjectActionPending] = useState(false); const [projectActionPending, setProjectActionPending] = useState(false);
const [selectedSkillId, setSelectedSkillId] = useState(DEFAULT_SKILL.id); const [selectedSkillId, setSelectedSkillId] = useState(DEFAULT_SKILL.id);
...@@ -1618,7 +1673,6 @@ export default function App() { ...@@ -1618,7 +1673,6 @@ export default function App() {
const [sidebarSessionTitles, setSidebarSessionTitles] = useState<Record<string, string>>({}); const [sidebarSessionTitles, setSidebarSessionTitles] = useState<Record<string, string>>({});
const [skillMenuOpen, setSkillMenuOpen] = useState(false); const [skillMenuOpen, setSkillMenuOpen] = useState(false);
const activeStreamRef = useRef<ActiveStreamState | null>(null); const activeStreamRef = useRef<ActiveStreamState | null>(null);
const activeSessionIdRef = useRef(activeSessionId);
const skillMenuRef = useRef<HTMLDivElement | null>(null); const skillMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null); const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const startupWarmupRequestedRef = useRef(false); const startupWarmupRequestedRef = useRef(false);
...@@ -1699,7 +1753,15 @@ export default function App() { ...@@ -1699,7 +1753,15 @@ export default function App() {
} }
return viewMode === "chat" ? HOME_CHAT_PROJECT_ID : activeProject?.id; return viewMode === "chat" ? HOME_CHAT_PROJECT_ID : activeProject?.id;
}, [activeProject?.id, bindingRequired, viewMode]); }, [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 isBound = !bindingRequired;
const hasConversationProject = viewMode === "chat" const hasConversationProject = viewMode === "chat"
? visibleProjects.length > 0 ? visibleProjects.length > 0
...@@ -1767,6 +1829,60 @@ export default function App() { ...@@ -1767,6 +1829,60 @@ export default function App() {
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [infoText]); }, [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) { async function loadMessages(sessionId: string, canRead: boolean, showError = false) {
if (!canRead) { if (!canRead) {
return; return;
...@@ -1776,15 +1892,8 @@ export default function App() { ...@@ -1776,15 +1892,8 @@ export default function App() {
const nextMessages = (await desktopApi.chat.listMessages(sessionId)) const nextMessages = (await desktopApi.chat.listMessages(sessionId))
.filter(isPrimaryChatMessage) .filter(isPrimaryChatMessage)
.map((message) => toUiChatMessage(message)); .map((message) => toUiChatMessage(message));
if (sessionId !== activeSessionIdRef.current) { updateSessionMessages(sessionId, (current) => {
return; return mergeSessionHistory(current, nextMessages);
}
setMessages((current) => {
const hasTransientLocalMessages = current.some((message) => Boolean(message.streamState || message.statusLabel));
if (nextMessages.length === 0 && hasTransientLocalMessages) {
return current;
}
return nextMessages;
}); });
} catch (error) { } catch (error) {
if (showError) { if (showError) {
...@@ -1851,16 +1960,12 @@ export default function App() { ...@@ -1851,16 +1960,12 @@ export default function App() {
}, []); }, []);
useEffect(() => { useEffect(() => {
activeSessionIdRef.current = activeSessionId; if (activeSessionId || !preferredSessionId) {
}, [activeSessionId]);
useEffect(() => {
if (!resolvedActiveSessionId || resolvedActiveSessionId === activeSessionId) {
return; return;
} }
setActiveSessionId(resolvedActiveSessionId); setActiveSessionId(preferredSessionId);
}, [activeSessionId, resolvedActiveSessionId]); }, [activeSessionId, preferredSessionId]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -1871,7 +1976,7 @@ export default function App() { ...@@ -1871,7 +1976,7 @@ export default function App() {
if (!cancelled) { if (!cancelled) {
setSessions([]); setSessions([]);
if (!preserveVisibleConversation) { if (!preserveVisibleConversation) {
setMessages([]); clearAllSessionMessages();
} }
} }
return; return;
...@@ -1884,7 +1989,20 @@ export default function App() { ...@@ -1884,7 +1989,20 @@ export default function App() {
} }
setSessions(nextSessions); 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); const nextSessionId = resolvePreferredSessionId(nextSessions, activeSessionId);
if (hasActiveSession || hasLocalActiveCache) {
return;
}
if (nextSessionId) { if (nextSessionId) {
setActiveSessionId(nextSessionId); setActiveSessionId(nextSessionId);
} else if (sessionScopeProjectId === HOME_CHAT_PROJECT_ID) { } else if (sessionScopeProjectId === HOME_CHAT_PROJECT_ID) {
...@@ -1897,7 +2015,7 @@ export default function App() { ...@@ -1897,7 +2015,7 @@ export default function App() {
} else { } else {
setActiveSessionId(EMPTY_SESSION_ID); setActiveSessionId(EMPTY_SESSION_ID);
if (!preserveVisibleConversation) { if (!preserveVisibleConversation) {
setMessages([]); clearAllSessionMessages();
} }
} }
} catch (error) { } catch (error) {
...@@ -1906,7 +2024,7 @@ export default function App() { ...@@ -1906,7 +2024,7 @@ export default function App() {
} }
setSessions([]); setSessions([]);
if (!preserveVisibleConversation) { if (!preserveVisibleConversation) {
setMessages([]); clearAllSessionMessages();
} }
setErrorText(err(error)); setErrorText(err(error));
} }
...@@ -1916,7 +2034,7 @@ export default function App() { ...@@ -1916,7 +2034,7 @@ export default function App() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [activeSessionId, bindingRequired, desktopApi.chat, isBound, sendPhase, sessionScopeProjectId, workspace]); }, [activeSessionId, bindingRequired, desktopApi.chat, isBound, messagesBySession, sendPhase, sessionScopeProjectId, workspace]);
useEffect(() => { useEffect(() => {
const shouldPollStartupState = viewMode !== "settings" const shouldPollStartupState = viewMode !== "settings"
...@@ -2022,16 +2140,15 @@ export default function App() { ...@@ -2022,16 +2140,15 @@ export default function App() {
useEffect(() => { useEffect(() => {
if ( if (
!isBound !isBound
|| !resolvedActiveSessionId || !visibleSessionId
|| !workspace?.chatReady || !workspace?.chatReady
|| sendPhase !== "idle"
|| !canExchangeMessages(workspace, runtimeStatus, gatewayStatus) || !canExchangeMessages(workspace, runtimeStatus, gatewayStatus)
) { ) {
return; return;
} }
void loadMessages(resolvedActiveSessionId, true, false); void loadMessages(visibleSessionId, true, false);
}, [gatewayStatus, isBound, resolvedActiveSessionId, runtimeStatus, sendPhase, workspace?.chatReady]); }, [gatewayStatus, isBound, runtimeStatus, sendPhase, visibleSessionId, workspace?.chatReady]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -2045,8 +2162,9 @@ export default function App() { ...@@ -2045,8 +2162,9 @@ export default function App() {
} }
const nextEntries = await Promise.all(sessions.map(async (session, index) => { const nextEntries = await Promise.all(sessions.map(async (session, index) => {
if (session.id === resolvedActiveSessionId && messages.length) { const cachedMessages = messagesBySession[session.id];
return [session.id, deriveSidebarSessionTitle(toPlainMessages(messages))] as const; if (cachedMessages?.length) {
return [session.id, deriveSidebarSessionTitle(toPlainMessages(cachedMessages))] as const;
} }
try { try {
...@@ -2067,7 +2185,7 @@ export default function App() { ...@@ -2067,7 +2185,7 @@ export default function App() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [desktopApi.chat, messages, resolvedActiveSessionId, sessions]); }, [desktopApi.chat, messagesBySession, sessions]);
async function switchProject(projectId: string) { async function switchProject(projectId: string) {
if (projectActionPending) { if (projectActionPending) {
...@@ -2081,7 +2199,7 @@ export default function App() { ...@@ -2081,7 +2199,7 @@ export default function App() {
setWorkspace(nextWorkspace); setWorkspace(nextWorkspace);
setSessions([]); setSessions([]);
setActiveSessionId(EMPTY_SESSION_ID); setActiveSessionId(EMPTY_SESSION_ID);
setMessages([]); clearAllSessionMessages();
} catch (error) { } catch (error) {
setErrorText(err(error)); setErrorText(err(error));
} finally { } finally {
...@@ -2110,7 +2228,6 @@ export default function App() { ...@@ -2110,7 +2228,6 @@ export default function App() {
} }
} }
setActiveSessionId(session.id); setActiveSessionId(session.id);
setMessages([]);
} catch (error) { } catch (error) {
setErrorText(err(error)); setErrorText(err(error));
} finally { } finally {
...@@ -2119,7 +2236,7 @@ export default function App() { ...@@ -2119,7 +2236,7 @@ export default function App() {
} }
async function closeProjectSession(sessionId: string) { async function closeProjectSession(sessionId: string) {
if (projectActionPending) { if (projectActionPending || (sendPhase !== "idle" && activeStreamRef.current?.sessionId === sessionId)) {
return; return;
} }
...@@ -2136,7 +2253,6 @@ export default function App() { ...@@ -2136,7 +2253,6 @@ export default function App() {
} }
const nextSessionId = nextSessions.find((session) => session.id !== sessionId)?.id ?? nextSessions[0]?.id ?? EMPTY_SESSION_ID; const nextSessionId = nextSessions.find((session) => session.id !== sessionId)?.id ?? nextSessions[0]?.id ?? EMPTY_SESSION_ID;
setActiveSessionId(nextSessionId); setActiveSessionId(nextSessionId);
setMessages([]);
} catch (error) { } catch (error) {
setErrorText(err(error)); setErrorText(err(error));
} finally { } finally {
...@@ -2172,8 +2288,11 @@ export default function App() { ...@@ -2172,8 +2288,11 @@ export default function App() {
systemSummary, systemSummary,
sessions, sessions,
messages: toPlainMessages(messages), messages: toPlainMessages(messages),
messagesBySession: Object.fromEntries(
Object.entries(messagesBySession).map(([sessionId, sessionMessages]) => [sessionId, toPlainMessages(sessionMessages)])
),
logs: [], logs: [],
activeSessionId: resolvedActiveSessionId ?? "", activeSessionId: visibleSessionId ?? "",
expertProjectIds: expertPageProjects.map((project) => project.id), expertProjectIds: expertPageProjects.map((project) => project.id),
workspaceSummary: workspace, workspaceSummary: workspace,
streamSmoke, streamSmoke,
...@@ -2201,7 +2320,7 @@ export default function App() { ...@@ -2201,7 +2320,7 @@ export default function App() {
.map((entry) => entry.definition.id) .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(() => { useEffect(() => {
if (!smokeEnabled) { if (!smokeEnabled) {
...@@ -2390,6 +2509,17 @@ export default function App() { ...@@ -2390,6 +2509,17 @@ export default function App() {
apiKeyConfigured: latestConfig.apiKeyConfigured 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 () => { resolveHomeIntentSuggestion: async () => {
const currentPrompt = prompt.trim(); const currentPrompt = prompt.trim();
if (!currentPrompt) { if (!currentPrompt) {
...@@ -2454,7 +2584,23 @@ export default function App() { ...@@ -2454,7 +2584,23 @@ export default function App() {
}); });
function updateMessageById(messageId: string, updater: (message: UiChatMessage) => UiChatMessage) { 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) { function updateStreamSmoke(updater: (current: SmokeStreamSnapshot | null) => SmokeStreamSnapshot | null) {
...@@ -2464,6 +2610,50 @@ export default function App() { ...@@ -2464,6 +2610,50 @@ export default function App() {
setStreamSmoke((current) => updater(current)); 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) { function initializeMessageTrace(messageId: string, item?: ConversationTraceItem) {
setMessageTraces((current) => ({ setMessageTraces((current) => ({
...current, ...current,
...@@ -2540,7 +2730,9 @@ export default function App() { ...@@ -2540,7 +2730,9 @@ export default function App() {
return current; 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) { if (!assistantMessage) {
return current; return current;
} }
...@@ -2557,7 +2749,7 @@ export default function App() { ...@@ -2557,7 +2749,7 @@ export default function App() {
finalContent: nextFinalContent finalContent: nextFinalContent
}; };
}); });
}, [messages]); }, [messagesBySession]);
function cancelTypewriter() { function cancelTypewriter() {
const activeStream = activeStreamRef.current; const activeStream = activeStreamRef.current;
...@@ -2567,8 +2759,7 @@ export default function App() { ...@@ -2567,8 +2759,7 @@ export default function App() {
} }
} }
async function syncChatAfterSend(sessionId: string) { async function syncChatAfterSend() {
setActiveSessionId(sessionId);
const [telemetry, nextWorkspace, nextGateway] = await Promise.all([ const [telemetry, nextWorkspace, nextGateway] = await Promise.all([
desktopApi.runtimeTelemetry.getStatus().catch(() => null), desktopApi.runtimeTelemetry.getStatus().catch(() => null),
desktopApi.workspace.getSummary().catch(() => null), desktopApi.workspace.getSummary().catch(() => null),
...@@ -2606,10 +2797,9 @@ export default function App() { ...@@ -2606,10 +2797,9 @@ export default function App() {
finalContent: activeStream.finalReply?.content ?? activeStream.targetText finalContent: activeStream.finalReply?.content ?? activeStream.targetText
} : current); } : current);
collapseMessageTrace(activeStream.assistantMessageId); collapseMessageTrace(activeStream.assistantMessageId);
const sessionId = activeStream.sessionId;
activeStreamRef.current = null; activeStreamRef.current = null;
setSendPhase("idle"); setSendPhase("idle");
void syncChatAfterSend(sessionId); void syncChatAfterSend();
} }
function scheduleTypewriter() { function scheduleTypewriter() {
...@@ -2699,7 +2889,7 @@ export default function App() { ...@@ -2699,7 +2889,7 @@ export default function App() {
} : current); } : current);
appendTrace(assistantMessageId, "fallback-complete", ui.fallbackComplete, undefined, "success"); appendTrace(assistantMessageId, "fallback-complete", ui.fallbackComplete, undefined, "success");
collapseMessageTrace(assistantMessageId); collapseMessageTrace(assistantMessageId);
await syncChatAfterSend(result.sessionId); await syncChatAfterSend();
setSendPhase("idle"); setSendPhase("idle");
} }
...@@ -2712,7 +2902,6 @@ export default function App() { ...@@ -2712,7 +2902,6 @@ export default function App() {
if (event.type === "started") { if (event.type === "started") {
activeStream.sessionId = event.sessionId; activeStream.sessionId = event.sessionId;
setActiveSessionId(event.sessionId);
setSendPhase("streaming"); setSendPhase("streaming");
appendTrace(activeStream.assistantMessageId, "started", ui.replyStarted); appendTrace(activeStream.assistantMessageId, "started", ui.replyStarted);
updateAssistantStatus(activeStream.assistantMessageId, ui.thinking); updateAssistantStatus(activeStream.assistantMessageId, ui.thinking);
...@@ -2993,8 +3182,14 @@ export default function App() { ...@@ -2993,8 +3182,14 @@ export default function App() {
tone: "info", tone: "info",
createdAt: new Date().toISOString() createdAt: new Date().toISOString()
}); });
setMessages((current) => [...current, userMessage, assistantMessage]); let sessionId = forcedSessionId ?? visibleSessionId;
let sessionId = forcedSessionId ?? resolvedActiveSessionId; let userMessageId = userMessage.id;
let assistantMessageId = assistantMessage.id;
const optimisticSessionId = sessionId;
if (optimisticSessionId) {
updateSessionMessages(optimisticSessionId, (current) => [...current, userMessage, assistantMessage]);
}
try { try {
const confirmedWorkspace = await ensureChatAvailable(assistantMessage.id); const confirmedWorkspace = await ensureChatAvailable(assistantMessage.id);
...@@ -3017,10 +3212,13 @@ export default function App() { ...@@ -3017,10 +3212,13 @@ export default function App() {
if (!sessionId) { if (!sessionId) {
const createdSession = await desktopApi.chat.createSessionForProject(effectiveProjectId); const createdSession = await desktopApi.chat.createSessionForProject(effectiveProjectId);
sessionId = createdSession.id; sessionId = createdSession.id;
setActiveSessionId(createdSession.id);
setSessions((current) => [createdSession, ...current.filter((session) => session.id !== 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(() => ({ updateStreamSmoke(() => ({
phase: "requested", phase: "requested",
...@@ -3042,15 +3240,20 @@ export default function App() { ...@@ -3042,15 +3240,20 @@ export default function App() {
statusLabels: [ui.preparingReply] statusLabels: [ui.preparingReply]
})); }));
updateAssistantStatus(assistantMessage.id, ui.waitingReply); updateAssistantStatus(assistantMessageId, ui.waitingReply);
appendTrace(assistantMessage.id, "await-model", ui.waitingReply); appendTrace(assistantMessageId, "await-model", ui.waitingReply);
try { try {
const stream = await desktopApi.chat.streamPrompt(sessionId, trimmedPrompt, skillId, attachmentsToSend); 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 = { activeStreamRef.current = {
requestId: stream.requestId, requestId: stream.requestId,
assistantMessageId: assistantMessage.id, assistantMessageId,
sessionId: stream.sessionId, sessionId: stream.sessionId,
originSessionId: sessionId,
targetText: "", targetText: "",
renderedText: "" renderedText: ""
}; };
...@@ -3060,23 +3263,23 @@ export default function App() { ...@@ -3060,23 +3263,23 @@ export default function App() {
requestId: stream.requestId, requestId: stream.requestId,
sessionId: stream.sessionId, sessionId: stream.sessionId,
runId: stream.runId, runId: stream.runId,
assistantMessageId,
executionPolicySource: stream.executionPolicy?.source ?? current.executionPolicySource, executionPolicySource: stream.executionPolicy?.source ?? current.executionPolicySource,
executionPolicyModel: stream.executionPolicy?.modelLabel ?? current.executionPolicyModel executionPolicyModel: stream.executionPolicy?.modelLabel ?? current.executionPolicyModel
} : current); } : current);
setActiveSessionId(stream.sessionId);
} catch { } catch {
setSendPhase("finalizing"); setSendPhase("finalizing");
appendTrace(assistantMessage.id, "fallback", ui.fallbackReply); appendTrace(assistantMessageId, "fallback", ui.fallbackReply);
updateAssistantStatus(assistantMessage.id, ui.generating); updateAssistantStatus(assistantMessageId, ui.generating);
await completeWithFallback(sessionId, trimmedPrompt, skillId, assistantMessage.id, attachmentsToSend); await completeWithFallback(sessionId, trimmedPrompt, skillId, assistantMessageId, attachmentsToSend);
clearComposerAttachment(); clearComposerAttachment();
} }
} catch (error) { } catch (error) {
setSendPhase("idle"); setSendPhase("idle");
const message = err(error); const message = err(error);
setMessageTraceExpanded(assistantMessage.id, true); setMessageTraceExpanded(assistantMessageId, true);
failPendingAssistant(assistantMessage.id, message); failPendingAssistant(assistantMessageId, message);
appendTrace(assistantMessage.id, "error", "\u53d1\u9001\u5931\u8d25", message, "error"); appendTrace(assistantMessageId, "error", "\u53d1\u9001\u5931\u8d25", message, "error");
updateStreamSmoke((current) => current ? { updateStreamSmoke((current) => current ? {
...current, ...current,
phase: "error", phase: "error",
...@@ -3091,7 +3294,7 @@ export default function App() { ...@@ -3091,7 +3294,7 @@ export default function App() {
requestId: undefined, requestId: undefined,
sessionId, sessionId,
runId: undefined, runId: undefined,
assistantMessageId: assistantMessage.id, assistantMessageId,
startedEventCount: 0, startedEventCount: 0,
statusEventCount: 0, statusEventCount: 0,
deltaEventCount: 0, deltaEventCount: 0,
...@@ -3323,7 +3526,7 @@ export default function App() { ...@@ -3323,7 +3526,7 @@ export default function App() {
if (resetConversation) { if (resetConversation) {
setSessions([]); setSessions([]);
setActiveSessionId(EMPTY_SESSION_ID); setActiveSessionId(EMPTY_SESSION_ID);
setMessages([]); clearAllSessionMessages();
} }
return workspace ?? null; return workspace ?? null;
} }
...@@ -3335,7 +3538,7 @@ export default function App() { ...@@ -3335,7 +3538,7 @@ export default function App() {
setWorkspace(nextWorkspace); setWorkspace(nextWorkspace);
setSessions([]); setSessions([]);
setActiveSessionId(EMPTY_SESSION_ID); setActiveSessionId(EMPTY_SESSION_ID);
setMessages([]); clearAllSessionMessages();
return nextWorkspace; return nextWorkspace;
} catch (error) { } catch (error) {
setErrorText(err(error)); setErrorText(err(error));
...@@ -3790,7 +3993,15 @@ export default function App() { ...@@ -3790,7 +3993,15 @@ export default function App() {
<strong>{sidebarSessionTitles[session.id] ?? formatSessionTitle(session.title, index)}</strong> <strong>{sidebarSessionTitles[session.id] ?? formatSessionTitle(session.title, index)}</strong>
</button> </button>
{sessions.length > 1 ? ( {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} ) : null}
</div> </div>
))} ))}
......
export const IPC_CHANNELS = { export const IPC_CHANNELS = {
workspaceGetSummary: "workspace:get-summary", workspaceGetSummary: "workspace:get-summary",
workspaceWarmup: "workspace:warmup", workspaceWarmup: "workspace:warmup",
windowMinimize: "window:minimize",
windowMaximize: "window:maximize",
windowClose: "window:close",
gatewayStatus: "gateway:status", gatewayStatus: "gateway:status",
gatewayConnect: "gateway:connect", gatewayConnect: "gateway:connect",
gatewayDisconnect: "gateway:disconnect", gatewayDisconnect: "gateway:disconnect",
...@@ -20,6 +23,7 @@ ...@@ -20,6 +23,7 @@
configSave: "config:save", configSave: "config:save",
projectsList: "projects:list", projectsList: "projects:list",
projectsSetActive: "projects:set-active", projectsSetActive: "projects:set-active",
projectsResolveIntent: "projects:resolve-intent",
skillCatalogList: "skill-catalog:list", skillCatalogList: "skill-catalog:list",
chatListSessions: "chat:list-sessions", chatListSessions: "chat:list-sessions",
chatListSessionsByProject: "chat:list-sessions-by-project", chatListSessionsByProject: "chat:list-sessions-by-project",
...@@ -27,6 +31,7 @@ ...@@ -27,6 +31,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",
chatPickImageAttachment: "chat:pick-image-attachment",
chatSendPrompt: "chat:send-prompt", chatSendPrompt: "chat:send-prompt",
chatStreamPrompt: "chat:stream-prompt", chatStreamPrompt: "chat:stream-prompt",
chatStreamEvent: "chat:stream-event", chatStreamEvent: "chat:stream-event",
...@@ -38,6 +43,7 @@ ...@@ -38,6 +43,7 @@
profileGetSummary: "profile:get-summary", profileGetSummary: "profile:get-summary",
creditsGetSummary: "credits:get-summary", creditsGetSummary: "credits:get-summary",
skillsList: "skills:list", skillsList: "skills:list",
expertsList: "experts:list",
modelConfigGetSummary: "model-config:get-summary", modelConfigGetSummary: "model-config:get-summary",
systemGetSummary: "system:get-summary" systemGetSummary: "system:get-summary"
} as const; } as const;
...@@ -66,6 +72,7 @@ export type SetupMode = "employee-key" | "direct-provider"; ...@@ -66,6 +72,7 @@ export type SetupMode = "employee-key" | "direct-provider";
export type ChatLaunchState = "unbound" | "starting" | "ready" | "error"; export type ChatLaunchState = "unbound" | "starting" | "ready" | "error";
export type WorkspaceStartupPhase = "idle" | "syncing-config" | "syncing-projects" | "starting-runtime" | "connecting-gateway" | "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 SkillDownloadState = "pending" | "downloading" | "ready" | "failed" | "removed";
export type ExpertEntryMode = "standalone" | "home-chat-shortcut";
export type DailyReportDeliveryState = "draft" | "sent" | "failed"; export type DailyReportDeliveryState = "draft" | "sent" | "failed";
export interface WorkspaceWarmupResult { export interface WorkspaceWarmupResult {
...@@ -256,6 +263,17 @@ export interface WorkspaceSkillSummary { ...@@ -256,6 +263,17 @@ export interface WorkspaceSkillSummary {
lastError?: string; lastError?: string;
} }
export interface ExpertDefinition {
id: string;
name: string;
entryMode: ExpertEntryMode;
description?: string;
starterPrompt?: string;
promptFile?: string;
promptAvailable: boolean;
projectMatchKeywords: string[];
}
export interface PluginSummary { export interface PluginSummary {
id: string; id: string;
name: string; name: string;
...@@ -349,6 +367,16 @@ export interface ProjectSummary { ...@@ -349,6 +367,16 @@ export interface ProjectSummary {
defaultEntryType?: ProjectPackageEntryType; 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 { export interface ProjectSessionSummary extends SessionSummary {
projectId: string; projectId: string;
} }
...@@ -386,6 +414,18 @@ export interface ProjectSessionState { ...@@ -386,6 +414,18 @@ export interface ProjectSessionState {
draft: string; 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 { export interface ProjectExecutionRequest {
sessionId: string; sessionId: string;
projectId: string; projectId: string;
...@@ -393,6 +433,7 @@ export interface ProjectExecutionRequest { ...@@ -393,6 +433,7 @@ export interface ProjectExecutionRequest {
userPrompt: string; userPrompt: string;
context: ProjectContextSnapshot; context: ProjectContextSnapshot;
selectedSkillId: string | null; selectedSkillId: string | null;
attachments?: ProjectResolvedAttachment[];
projectConfig?: ProjectPackageConfig | null; projectConfig?: ProjectPackageConfig | null;
} }
...@@ -418,9 +459,12 @@ export interface ChatMessage { ...@@ -418,9 +459,12 @@ export interface ChatMessage {
role: MessageRole; role: MessageRole;
content: string; content: string;
createdAt: 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 type ChatExecutionRoutingMode = ModelRoutingMode | SkillModelBindingMode | "fallback";
export interface ChatExecutionPolicy { export interface ChatExecutionPolicy {
...@@ -437,6 +481,8 @@ export interface ChatStreamPromptResult { ...@@ -437,6 +481,8 @@ export interface ChatStreamPromptResult {
requestId: string; requestId: string;
sessionId: string; sessionId: string;
runId?: string; runId?: string;
userMessageId?: string;
assistantMessageId: string;
executionPolicy?: ChatExecutionPolicy; executionPolicy?: ChatExecutionPolicy;
} }
...@@ -482,6 +528,7 @@ export interface ChatStreamErrorEvent { ...@@ -482,6 +528,7 @@ export interface ChatStreamErrorEvent {
sessionId: string; sessionId: string;
runId?: string; runId?: string;
message: string; message: string;
errorCategory?: string;
} }
export type ChatStreamEvent = ChatStreamStartedEvent | ChatStreamStatusEvent | ChatStreamDeltaEvent | ChatStreamCompletedEvent | ChatStreamErrorEvent; export type ChatStreamEvent = ChatStreamStartedEvent | ChatStreamStatusEvent | ChatStreamDeltaEvent | ChatStreamCompletedEvent | ChatStreamErrorEvent;
...@@ -494,6 +541,60 @@ export interface PromptResult { ...@@ -494,6 +541,60 @@ export interface PromptResult {
executionPolicy?: ChatExecutionPolicy; 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 { export interface AppConfig {
setupMode: SetupMode; setupMode: SetupMode;
provider: string; provider: string;
...@@ -507,6 +608,7 @@ export interface AppConfig { ...@@ -507,6 +608,7 @@ export interface AppConfig {
cloudApiBaseUrl: string; cloudApiBaseUrl: string;
runtimeCloudApiBaseUrl: string; runtimeCloudApiBaseUrl: string;
runtimeMode: RuntimeModePreference; runtimeMode: RuntimeModePreference;
expertModelConfig: ExpertModelConfig;
} }
export interface DiagnosticsExportResult { export interface DiagnosticsExportResult {
...@@ -515,6 +617,19 @@ export interface DiagnosticsExportResult { ...@@ -515,6 +617,19 @@ export interface DiagnosticsExportResult {
startupLogPath?: string; 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 { export interface SaveConfigInput {
setupMode: SetupMode; setupMode: SetupMode;
provider: string; provider: string;
...@@ -528,6 +643,12 @@ export interface SaveConfigInput { ...@@ -528,6 +643,12 @@ export interface SaveConfigInput {
cloudApiBaseUrl: string; cloudApiBaseUrl: string;
runtimeCloudApiBaseUrl: string; runtimeCloudApiBaseUrl: string;
runtimeMode: RuntimeModePreference; runtimeMode: RuntimeModePreference;
expertModelConfig?: {
image?: ModelEndpointInput;
video?: ModelEndpointInput;
copywriting?: ModelEndpointInput;
digitalHuman?: DigitalHumanModelInput;
};
} }
export interface AuthSessionSummary { export interface AuthSessionSummary {
...@@ -651,6 +772,11 @@ export interface DesktopApi { ...@@ -651,6 +772,11 @@ export interface DesktopApi {
getSummary(): Promise<WorkspaceSummary>; getSummary(): Promise<WorkspaceSummary>;
warmup(): Promise<WorkspaceWarmupResult>; warmup(): Promise<WorkspaceWarmupResult>;
}; };
window: {
minimize(): Promise<void>;
maximize(): Promise<void>;
close(): Promise<void>;
};
gateway: { gateway: {
status(): Promise<GatewayStatus>; status(): Promise<GatewayStatus>;
connect(): Promise<GatewayStatus>; connect(): Promise<GatewayStatus>;
...@@ -681,6 +807,7 @@ export interface DesktopApi { ...@@ -681,6 +807,7 @@ export interface DesktopApi {
projects: { projects: {
list(): Promise<ProjectSummary[]>; list(): Promise<ProjectSummary[]>;
setActive(projectId: string): Promise<WorkspaceSummary>; setActive(projectId: string): Promise<WorkspaceSummary>;
resolveIntent(prompt: string, currentProjectId?: string): Promise<ProjectIntentSuggestion | null>;
}; };
skillCatalog: { skillCatalog: {
list(): Promise<SkillCatalogItem[]>; list(): Promise<SkillCatalogItem[]>;
...@@ -699,6 +826,9 @@ export interface DesktopApi { ...@@ -699,6 +826,9 @@ export interface DesktopApi {
skills: { skills: {
list(): Promise<SkillSummary[]>; list(): Promise<SkillSummary[]>;
}; };
experts: {
list(): Promise<ExpertDefinition[]>;
};
modelConfig: { modelConfig: {
getSummary(): Promise<ModelConfigSummary>; getSummary(): Promise<ModelConfigSummary>;
}; };
...@@ -712,8 +842,9 @@ export interface DesktopApi { ...@@ -712,8 +842,9 @@ 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[]>;
sendPrompt(sessionId: string, prompt: string, skillId?: string): Promise<PromptResult>; pickImageAttachment(): Promise<ChatAttachment | null>;
streamPrompt(sessionId: string, prompt: string, skillId?: string): Promise<ChatStreamPromptResult>; 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; onStreamEvent(listener: ChatStreamListener): () => void;
}; };
diagnostics: { 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