Commit 9ae4391b authored by AI-甘富林's avatar AI-甘富林

Update skill sync and chat UI

Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 29bb5e3a
This diff is collapsed.
import { randomUUID } from "node:crypto";
import { ipcMain, shell } from "electron";
import {
IPC_CHANNELS,
type AppConfig,
type ChatStreamEvent,
type DesktopApi,
type GatewayStatus,
type PluginSummary,
......@@ -372,6 +374,10 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
};
};
const emitChatStreamEvent = (sender: Electron.WebContents, payload: ChatStreamEvent) => {
sender.send(IPC_CHANNELS.chatStreamEvent, payload);
};
ipcMain.handle(IPC_CHANNELS.workspaceGetSummary, async () => buildWorkspaceSummary());
ipcMain.handle(IPC_CHANNELS.gatewayStatus, async () => gatewayClient.status());
ipcMain.handle(IPC_CHANNELS.gatewayConnect, async () => gatewayClient.connect());
......@@ -470,7 +476,101 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
throw error;
}
});
ipcMain.handle(IPC_CHANNELS.chatStreamPrompt, async (event, sessionId: string, prompt: string, skillId?: string) => {
const executionPolicy = await resolveExecutionPolicy(skillId);
const requestId = randomUUID();
let settled = false;
let ready = false;
let startedEvent: ChatStreamEvent | null = null;
const queuedEvents: ChatStreamEvent[] = [];
const queueOrSend = (payload: ChatStreamEvent) => {
if (!ready) {
if (payload.type === "started") {
startedEvent = payload;
} else {
queuedEvents.push(payload);
}
return;
}
emitChatStreamEvent(event.sender, payload);
};
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, skillId);
try {
const stream = await gatewayClient.streamPrompt(sessionId, prompt, {
onStarted: ({ sessionId: nextSessionId, runId }) => {
queueOrSend({
type: "started",
requestId,
sessionId: nextSessionId,
runId,
executionPolicy
});
},
onDelta: ({ sessionId: nextSessionId, runId, textDelta, fullText }) => {
queueOrSend({
type: "delta",
requestId,
sessionId: nextSessionId,
runId,
textDelta,
fullText
});
},
onCompleted: ({ sessionId: nextSessionId, runId, reply }) => {
settled = true;
runtimeCloudSupervisor.noteMessageSent(nextSessionId, reply.content, executionPolicy.modelId, skillId);
queueOrSend({
type: "completed",
requestId,
sessionId: nextSessionId,
runId,
reply,
executionPolicy
});
},
onError: ({ sessionId: nextSessionId, runId, error }) => {
settled = true;
runtimeCloudSupervisor.noteError("chat_stream_failed", error.message, {
modelId: executionPolicy.modelId,
sessionId: nextSessionId
});
queueOrSend({
type: "error",
requestId,
sessionId: nextSessionId,
runId,
message: error.message
});
}
});
ready = true;
setTimeout(() => {
emitChatStreamEvent(event.sender, startedEvent ?? {
type: "started",
requestId,
sessionId: stream.sessionId,
runId: stream.runId,
executionPolicy
});
for (const queuedEvent of queuedEvents) {
emitChatStreamEvent(event.sender, queuedEvent);
}
}, 0);
return { requestId, sessionId: stream.sessionId, runId: stream.runId, executionPolicy };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!settled) {
runtimeCloudSupervisor.noteError("chat_stream_failed", message, {
modelId: executionPolicy.modelId,
sessionId
});
}
throw error;
}
});
ipcMain.handle(IPC_CHANNELS.diagnosticsOpenControlUi, async () => {
const config = await getEffectiveConfig();
await shell.openExternal(toControlUiUrl(config.gatewayUrl));
......@@ -588,7 +688,18 @@ export function registerDesktopIpc(services: MainServices): DesktopApi {
});
throw error;
}
}
},
streamPrompt: async (sessionId: string, prompt: string, skillId?: string) => {
const executionPolicy = await resolveExecutionPolicy(skillId);
const stream = await gatewayClient.streamPrompt(sessionId, prompt);
return {
requestId: randomUUID(),
sessionId: stream.sessionId,
runId: stream.runId,
executionPolicy
};
},
onStreamEvent: () => () => undefined
},
diagnostics: {
openControlUi: async () => {
......
import http from "node:http";
function extractPromptText(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
return value
.map((item) => extractPromptText(item))
.filter(Boolean)
.join("\n");
}
if (!value || typeof value !== "object") {
return "";
}
const record = value as Record<string, unknown>;
if (typeof record.text === "string") {
return record.text;
}
if (typeof record.content === "string") {
return record.content;
}
if (record.content) {
return extractPromptText(record.content);
}
if (record.input) {
return extractPromptText(record.input);
}
return "";
}
function buildSmokeReply(body: Record<string, unknown>): string {
const messages = Array.isArray(body.messages) ? body.messages : [];
const prompt = [...messages]
.reverse()
.map((message) => extractPromptText(message))
.find((value) => value.trim().length > 0) || extractPromptText(body.input) || "smoke";
return `Smoke stream ok: ${prompt.trim()}`;
}
export async function startSmokeCloudApiServer(baseUrl: string, token: string, runtimeApiKey = "smoke-runtime-api-key"): Promise<() => Promise<void>> {
const url = new URL(baseUrl);
const hostname = url.hostname;
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
const providerToken = "runtime-provider-token";
const providerBaseUrl = `${baseUrl}/openai/v1`;
const server = http.createServer((req, res) => {
const requestUrl = new URL(req.url || "/", `${url.protocol}//${url.host}`);
......@@ -26,6 +69,70 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
};
const sendChatCompletion = async (body: Record<string, unknown>) => {
const replyText = buildSmokeReply(body);
const created = Math.floor(Date.now() / 1000);
const model = typeof body.model === "string" ? body.model : "gpt-5.4-mini";
const completionId = `chatcmpl-smoke-${Date.now()}`;
if (body.stream === true) {
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive"
});
const parts = replyText.match(/.{1,10}/g) ?? [replyText];
const writeChunk = (payload: unknown) => {
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
writeChunk({
id: completionId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }]
});
for (const part of parts) {
writeChunk({
id: completionId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content: part }, finish_reason: null }]
});
await new Promise((resolve) => setTimeout(resolve, 25));
}
writeChunk({
id: completionId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }]
});
res.write("data: [DONE]\n\n");
res.end();
return;
}
sendJson(200, {
id: completionId,
object: "chat.completion",
created,
model,
choices: [
{
index: 0,
message: { role: "assistant", content: replyText },
finish_reason: "stop"
}
]
});
};
const handleRequest = async () => {
if (req.method === "POST" && requestUrl.pathname === "/openclaw-employee-config") {
const body = await readJsonBody();
......@@ -71,8 +178,8 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r
max_context_length: 200000,
provider: {
name: "Smoke OpenAI Compatible",
base_url: "http://127.0.0.1:11434/v1",
api_key: "runtime-provider-token",
base_url: providerBaseUrl,
api_key: providerToken,
provider_type: "openai_compatible"
}
},
......@@ -148,6 +255,31 @@ export async function startSmokeCloudApiServer(baseUrl: string, token: string, r
return;
}
if (req.method === "GET" && requestUrl.pathname === "/openai/v1/models") {
if (bearerToken !== providerToken) {
sendJson(401, { message: "Invalid provider token." });
return;
}
sendJson(200, {
object: "list",
data: [
{ id: "gpt-5.4-mini", object: "model", created: 0, owned_by: "smoke" },
{ id: "gpt-5.4", object: "model", created: 0, owned_by: "smoke" }
]
});
return;
}
if (req.method === "POST" && requestUrl.pathname === "/openai/v1/chat/completions") {
if (bearerToken !== providerToken) {
sendJson(401, { message: "Invalid provider token." });
return;
}
const body = await readJsonBody();
await sendChatCompletion(body);
return;
}
if (bearerToken !== token) {
sendJson(401, { message: "Invalid cloud access token." });
return;
......
import { contextBridge, ipcRenderer } from "electron";
import { IPC_CHANNELS, type DesktopApi, type RuntimeCloudFetchAction, type SaveConfigInput, type SignInInput } from "@qjclaw/shared-types";
import { contextBridge, ipcRenderer } from "electron";
import {
IPC_CHANNELS,
type ChatStreamListener,
type DesktopApi,
type RuntimeCloudFetchAction,
type SaveConfigInput,
type SignInInput
} from "@qjclaw/shared-types";
const desktopApi: DesktopApi = {
workspace: {
......@@ -55,7 +62,17 @@ const desktopApi: DesktopApi = {
chat: {
listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.chatListSessions),
listMessages: (sessionId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatListMessages, sessionId),
sendPrompt: (sessionId: string, prompt: string, skillId?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatSendPrompt, sessionId, prompt, skillId)
sendPrompt: (sessionId: string, prompt: string, skillId?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatSendPrompt, sessionId, prompt, skillId),
streamPrompt: (sessionId: string, prompt: string, skillId?: string) => ipcRenderer.invoke(IPC_CHANNELS.chatStreamPrompt, sessionId, prompt, skillId),
onStreamEvent: (listener: ChatStreamListener) => {
const wrapped = (_event: Electron.IpcRendererEvent, payload: Parameters<ChatStreamListener>[0]) => {
listener(payload);
};
ipcRenderer.on(IPC_CHANNELS.chatStreamEvent, wrapped);
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.chatStreamEvent, wrapped);
};
}
},
diagnostics: {
openControlUi: () => ipcRenderer.invoke(IPC_CHANNELS.diagnosticsOpenControlUi),
......
This diff is collapsed.
......@@ -277,12 +277,25 @@ strong { font-weight: 600; }
.message-card { padding: 16px; }
.message-card.user { background: #eef5ff; }
.message-card.assistant { background: #eefbf7; }
.message-card.streaming { border-color: #b7e4d5; }
.message-card.error { border-color: rgba(239, 68, 68, 0.24); }
.message-card p {
white-space: pre-wrap;
line-height: 1.7;
margin-top: 6px;
}
.message-cursor {
display: inline-block;
width: 8px;
height: 1.05em;
margin-left: 3px;
border-radius: 999px;
background: #0f7bff;
vertical-align: text-bottom;
animation: cursor-blink 1s steps(1, end) infinite;
}
.composer-shell {
gap: 10px;
padding: 14px;
......@@ -355,6 +368,15 @@ strong { font-weight: 600; }
}
}
@keyframes cursor-blink {
0%, 50% {
opacity: 1;
}
50.1%, 100% {
opacity: 0;
}
}
@media (max-width: 1100px) {
.hero-line { font-size: 21px; }
.composer-meta {
......
......@@ -108,21 +108,39 @@ if (!result.ok) {
throw new Error('Electron smoke failed: ' + message);
}
const sendResult = result.sendResult || {};
const replyPolicy = sendResult.reply && sendResult.reply.executionPolicy;
if (!replyPolicy) {
throw new Error('Execution policy was not returned from chat.sendPrompt.');
const streamSmoke = sendResult.streamSmoke || {};
if (!sendResult.selectedSkillId) {
throw new Error('Smoke did not select a Skill before streaming.');
}
if (replyPolicy.source !== 'cloud-skill-binding') {
throw new Error('Unexpected execution policy source: ' + replyPolicy.source);
if (streamSmoke.phase !== 'completed') {
throw new Error('Renderer stream smoke did not complete successfully: ' + streamSmoke.phase);
}
if (!sendResult.selectedSkillId) {
throw new Error('Smoke did not select a Skill before sendPrompt.');
if (streamSmoke.fallbackUsed) {
throw new Error('Renderer stream smoke fell back to non-streaming sendPrompt.');
}
if (streamSmoke.executionPolicySource !== 'cloud-skill-binding') {
throw new Error('Unexpected stream execution policy source: ' + streamSmoke.executionPolicySource);
}
if (streamSmoke.selectedSkillId !== sendResult.selectedSkillId) {
throw new Error('Renderer stream selectedSkillId does not match smoke selection.');
}
if (Number(streamSmoke.startedEventCount || 0) < 1) {
throw new Error('Renderer stream smoke did not observe a started event.');
}
if (Number(streamSmoke.deltaEventCount || 0) < 1) {
throw new Error('Renderer stream smoke did not observe a delta event.');
}
if (Number(streamSmoke.completedEventCount || 0) < 1) {
throw new Error('Renderer stream smoke did not observe a completed event.');
}
if (Number(streamSmoke.errorEventCount || 0) !== 0) {
throw new Error('Renderer stream smoke observed unexpected error events: ' + streamSmoke.errorEventCount);
}
if (replyPolicy.skillId !== sendResult.selectedSkillId) {
throw new Error('Execution policy skillId does not match selectedSkillId.');
if (!String(streamSmoke.renderedContent || '')) {
throw new Error('Renderer stream smoke did not render assistant content.');
}
if (!sendResult.modelConfig || sendResult.modelConfig.routingMode !== 'skill-bound') {
throw new Error('Unexpected model routing mode: ' + (sendResult.modelConfig && sendResult.modelConfig.routingMode));
if (String(streamSmoke.finalContent || '') !== String(sendResult.lastMessage && sendResult.lastMessage.content || '')) {
throw new Error('Renderer final stream content does not match persisted last message.');
}
if (String(sendResult.system && sendResult.system.userDataPath) !== expectedUserData) {
throw new Error('Smoke ran against an unexpected userData path: ' + (sendResult.system && sendResult.system.userDataPath));
......@@ -180,9 +198,12 @@ const summary = {
userDataPath: expectedUserData,
logsPath: expectedLogs,
selectedSkillId: String(sendResult.selectedSkillId),
executionPolicySource: String(replyPolicy.source),
executionPolicyModel: String(replyPolicy.modelLabel),
executionPolicyRouting: String(replyPolicy.routingMode),
executionPolicySource: String(streamSmoke.executionPolicySource || ''),
executionPolicyModel: String(streamSmoke.executionPolicyModel || ''),
streamPhase: String(streamSmoke.phase || ''),
streamStartedEventCount: Number(streamSmoke.startedEventCount || 0),
streamDeltaEventCount: Number(streamSmoke.deltaEventCount || 0),
streamCompletedEventCount: Number(streamSmoke.completedEventCount || 0),
runtimeActiveMode: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.activeMode || ''),
runtimeProcessState: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.processState || ''),
runtimeGatewayUrl: String(sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.gatewayUrl || ''),
......
......@@ -182,6 +182,7 @@ if (!result.ok) {
throw new Error('Installed smoke failed: ' + (result.error || 'Unknown smoke failure.'));
}
const sendResult = result.sendResult || {};
const streamSmoke = sendResult.streamSmoke || {};
if (!sendResult.system || !sendResult.system.isPackaged) {
throw new Error('Installed smoke did not report packaged mode.');
}
......@@ -203,6 +204,24 @@ if (String(sendResult.system.userDataPath) !== expectedUserData) {
if (String(sendResult.system.logsPath) !== expectedLogs) {
throw new Error('Installed smoke ran against an unexpected logs path: ' + sendResult.system.logsPath);
}
if (streamSmoke.phase !== 'completed') {
throw new Error('Installed renderer stream smoke did not complete successfully: ' + streamSmoke.phase);
}
if (streamSmoke.fallbackUsed) {
throw new Error('Installed renderer stream smoke fell back to non-streaming sendPrompt.');
}
if (Number(streamSmoke.startedEventCount || 0) < 1 || Number(streamSmoke.deltaEventCount || 0) < 1 || Number(streamSmoke.completedEventCount || 0) < 1) {
throw new Error('Installed renderer stream smoke did not observe the expected started/delta/completed events.');
}
if (Number(streamSmoke.errorEventCount || 0) !== 0) {
throw new Error('Installed renderer stream smoke observed unexpected error events: ' + streamSmoke.errorEventCount);
}
if (!String(streamSmoke.renderedContent || '')) {
throw new Error('Installed renderer stream smoke did not render assistant content.');
}
if (String(streamSmoke.finalContent || '') !== String(sendResult.lastMessage && sendResult.lastMessage.content || '')) {
throw new Error('Installed renderer final stream content does not match persisted last message.');
}
if (expectBundled === 'true') {
const runtimeStatus = sendResult.runtimeStatusAfterProbe || {};
const runtimeHealth = sendResult.runtimeHealthAfterProbe || {};
......@@ -248,6 +267,10 @@ const summary = {
runtimePythonPackages: sendResult.runtimeStatusAfterProbe && sendResult.runtimeStatusAfterProbe.installedPythonPackages || [],
authState: String(sendResult.session && sendResult.session.state || ''),
skillCount: Array.isArray(sendResult.skills) ? sendResult.skills.length : 0,
streamPhase: String(streamSmoke.phase || ''),
streamStartedEventCount: Number(streamSmoke.startedEventCount || 0),
streamDeltaEventCount: Number(streamSmoke.deltaEventCount || 0),
streamCompletedEventCount: Number(streamSmoke.completedEventCount || 0),
diagnosticsPath,
runtimeResourceDir,
bundledPythonExecutable: packagedPythonExe,
......
This diff is collapsed.
......@@ -20,6 +20,8 @@ export const IPC_CHANNELS = {
chatListSessions: "chat:list-sessions",
chatListMessages: "chat:list-messages",
chatSendPrompt: "chat:send-prompt",
chatStreamPrompt: "chat:stream-prompt",
chatStreamEvent: "chat:stream-event",
diagnosticsOpenControlUi: "diagnostics:open-control-ui",
diagnosticsExportSnapshot: "diagnostics:export-snapshot",
authGetSession: "auth:get-session",
......@@ -263,6 +265,51 @@ export interface ChatExecutionPolicy {
message: string;
}
export interface ChatStreamPromptResult {
requestId: string;
sessionId: string;
runId?: string;
executionPolicy?: ChatExecutionPolicy;
}
export interface ChatStreamStartedEvent {
type: "started";
requestId: string;
sessionId: string;
runId?: string;
executionPolicy?: ChatExecutionPolicy;
}
export interface ChatStreamDeltaEvent {
type: "delta";
requestId: string;
sessionId: string;
runId: string;
textDelta: string;
fullText?: string;
}
export interface ChatStreamCompletedEvent {
type: "completed";
requestId: string;
sessionId: string;
runId: string;
reply: ChatMessage;
executionPolicy?: ChatExecutionPolicy;
}
export interface ChatStreamErrorEvent {
type: "error";
requestId: string;
sessionId: string;
runId?: string;
message: string;
}
export type ChatStreamEvent = ChatStreamStartedEvent | ChatStreamDeltaEvent | ChatStreamCompletedEvent | ChatStreamErrorEvent;
export type ChatStreamListener = (event: ChatStreamEvent) => void;
export interface PromptResult {
sessionId: string;
reply: ChatMessage;
......@@ -448,6 +495,8 @@ export interface DesktopApi {
listSessions(): Promise<SessionSummary[]>;
listMessages(sessionId: string): Promise<ChatMessage[]>;
sendPrompt(sessionId: string, prompt: string, skillId?: string): Promise<PromptResult>;
streamPrompt(sessionId: string, prompt: string, skillId?: string): Promise<ChatStreamPromptResult>;
onStreamEvent(listener: ChatStreamListener): () => void;
};
diagnostics: {
openControlUi(): Promise<void>;
......
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