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