mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-02 13:40:46 +00:00
feat(desktop): complete session websocket chat loop
This commit is contained in:
parent
9b0ec190e7
commit
bbab16f3b8
13 changed files with 1723 additions and 31 deletions
|
|
@ -107,7 +107,7 @@ order, verification, decisions, and remaining work.
|
||||||
|
|
||||||
### Slice 5: WebSocket Chat Loop
|
### Slice 5: WebSocket Chat Loop
|
||||||
|
|
||||||
- Status: in progress
|
- Status: complete
|
||||||
- Goal: add per-session WS connections and send user prompts through ACP.
|
- Goal: add per-session WS connections and send user prompts through ACP.
|
||||||
- Files:
|
- Files:
|
||||||
- `packages/desktop/src/server/ws/SessionSocketHub.ts`
|
- `packages/desktop/src/server/ws/SessionSocketHub.ts`
|
||||||
|
|
@ -122,8 +122,11 @@ order, verification, decisions, and remaining work.
|
||||||
- 2026-04-25: authenticated `/ws/:sessionId` handshake, `ping`/`pong`,
|
- 2026-04-25: authenticated `/ws/:sessionId` handshake, `ping`/`pong`,
|
||||||
`user_message` to ACP `prompt`, `stop_generation` to ACP `cancel`, and
|
`user_message` to ACP `prompt`, `stop_generation` to ACP `cancel`, and
|
||||||
one-active-prompt guard are implemented on the server.
|
one-active-prompt guard are implemented on the server.
|
||||||
- Remaining: ACP `sessionUpdate` normalization, renderer WebSocket client,
|
- 2026-04-25: added `AcpEventRouter` normalization for ACP message, tool,
|
||||||
and chat store integration.
|
plan, mode, commands, and usage updates; routed session updates into the
|
||||||
|
per-session socket hub; added a renderer WebSocket client, chat reducer,
|
||||||
|
and basic workbench wiring for session selection, streaming messages,
|
||||||
|
tool updates, plan updates, usage, stop, and send.
|
||||||
- Verification:
|
- Verification:
|
||||||
- `npm run test --workspace=packages/desktop`
|
- `npm run test --workspace=packages/desktop`
|
||||||
- fake ACP integration test for prompt and stream completion
|
- fake ACP integration test for prompt and stream completion
|
||||||
|
|
@ -193,6 +196,11 @@ order, verification, decisions, and remaining work.
|
||||||
committing to an HTTP framework before the ACP routing shape is known.
|
committing to an HTTP framework before the ACP routing shape is known.
|
||||||
- 2026-04-25: Allow CORS preflight without bearer auth, but only for allowed
|
- 2026-04-25: Allow CORS preflight without bearer auth, but only for allowed
|
||||||
app origins. Actual REST requests remain bearer-token protected.
|
app origins. Actual REST requests remain bearer-token protected.
|
||||||
|
- 2026-04-25: Keep ACP update normalization inside `packages/desktop` for now
|
||||||
|
instead of importing the VS Code session update handler. The desktop protocol
|
||||||
|
needs WebSocket message shapes, while the VS Code handler is callback/UI
|
||||||
|
oriented; shared extraction can happen after permission and settings slices
|
||||||
|
stabilize the common surface.
|
||||||
|
|
||||||
## Verification Log
|
## Verification Log
|
||||||
|
|
||||||
|
|
@ -254,6 +262,11 @@ order, verification, decisions, and remaining work.
|
||||||
- `npm run typecheck` passed across workspaces.
|
- `npm run typecheck` passed across workspaces.
|
||||||
- `npm run build` passed across the configured build order. Existing VS Code
|
- `npm run build` passed across the configured build order. Existing VS Code
|
||||||
companion lint warnings were reported by its build script, with no errors.
|
companion lint warnings were reported by its build script, with no errors.
|
||||||
|
- 2026-04-25 Slice 5b:
|
||||||
|
- `npm run test --workspace=packages/desktop` passed: 3 files, 26 tests.
|
||||||
|
- `npm run lint --workspace=packages/desktop` passed.
|
||||||
|
- `npm run typecheck --workspace=packages/desktop` passed.
|
||||||
|
- `npm run build --workspace=packages/desktop` passed.
|
||||||
|
|
||||||
## Self Review Notes
|
## Self Review Notes
|
||||||
|
|
||||||
|
|
@ -297,11 +310,21 @@ order, verification, decisions, and remaining work.
|
||||||
injected, rather than silently dropping user messages.
|
injected, rather than silently dropping user messages.
|
||||||
- Session update broadcasting is intentionally a follow-up; this keeps the
|
- Session update broadcasting is intentionally a follow-up; this keeps the
|
||||||
prompt/cancel transport independently testable before event normalization.
|
prompt/cancel transport independently testable before event normalization.
|
||||||
|
- 2026-04-25 Slice 5b:
|
||||||
|
- ACP session updates now broadcast only to sockets for the matching session;
|
||||||
|
tests cover a second session socket receiving only its own `pong`.
|
||||||
|
- Renderer chat state consumes the shared desktop WebSocket protocol without
|
||||||
|
Node access and keeps the server token in memory from preload-provided
|
||||||
|
server info.
|
||||||
|
- Main still does not auto-start a real ACP child process; the chat loop is
|
||||||
|
verified with an injected fake ACP client and remains ready for the runtime
|
||||||
|
integration slice.
|
||||||
|
|
||||||
## Remaining Work
|
## Remaining Work
|
||||||
|
|
||||||
- Commit Slice 5a.
|
- Commit Slice 5b.
|
||||||
- Continue with ACP event normalization and renderer WebSocket client for the
|
- Start Slice 6 permission bridge: route ACP permission and ask-user-question
|
||||||
rest of Slice 5.
|
callbacks through the existing per-session WebSocket channel with timeout
|
||||||
- Continue through the ACP, session, WebSocket, permission, settings, and
|
cancellation.
|
||||||
packaging slices until the architecture MVP is fully verified.
|
- Continue through permission, settings/auth/model/mode, real ACP runtime
|
||||||
|
startup, and packaging slices until the architecture MVP is fully verified.
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,32 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import {
|
import {
|
||||||
|
type FormEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useReducer,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
createDesktopSession,
|
||||||
|
listDesktopSessions,
|
||||||
loadDesktopStatus,
|
loadDesktopStatus,
|
||||||
type DesktopConnectionStatus,
|
type DesktopConnectionStatus,
|
||||||
|
type DesktopSessionSummary,
|
||||||
} from './api/client.js';
|
} from './api/client.js';
|
||||||
|
import {
|
||||||
|
connectSessionSocket,
|
||||||
|
type SessionSocketClient,
|
||||||
|
} from './api/websocket.js';
|
||||||
|
import {
|
||||||
|
chatReducer,
|
||||||
|
createInitialChatState,
|
||||||
|
type ChatState,
|
||||||
|
type ChatTimelineItem,
|
||||||
|
} from './stores/chatStore.js';
|
||||||
|
|
||||||
type LoadState =
|
type LoadState =
|
||||||
| { state: 'loading' }
|
| { state: 'loading' }
|
||||||
|
|
@ -17,6 +38,17 @@ type LoadState =
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [loadState, setLoadState] = useState<LoadState>({ state: 'loading' });
|
const [loadState, setLoadState] = useState<LoadState>({ state: 'loading' });
|
||||||
|
const [workspacePath, setWorkspacePath] = useState('');
|
||||||
|
const [sessions, setSessions] = useState<DesktopSessionSummary[]>([]);
|
||||||
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||||
|
const [sessionError, setSessionError] = useState<string | null>(null);
|
||||||
|
const [messageText, setMessageText] = useState('');
|
||||||
|
const [chatState, dispatchChat] = useReducer(
|
||||||
|
chatReducer,
|
||||||
|
undefined,
|
||||||
|
createInitialChatState,
|
||||||
|
);
|
||||||
|
const socketRef = useRef<SessionSocketClient | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
@ -47,6 +79,110 @@ export function App() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadState.state !== 'ready') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
void listDesktopSessions(
|
||||||
|
loadState.status.serverInfo,
|
||||||
|
workspacePath || undefined,
|
||||||
|
)
|
||||||
|
.then((result) => {
|
||||||
|
if (!disposed) {
|
||||||
|
setSessions(result.sessions);
|
||||||
|
setSessionError(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!disposed) {
|
||||||
|
setSessionError(getErrorMessage(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, [loadState, workspacePath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
socketRef.current?.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
|
||||||
|
if (loadState.state !== 'ready' || !activeSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchChat({ type: 'connect' });
|
||||||
|
const socket = connectSessionSocket(
|
||||||
|
loadState.status.serverInfo,
|
||||||
|
activeSessionId,
|
||||||
|
{
|
||||||
|
onMessage: (message) =>
|
||||||
|
dispatchChat({ type: 'server_message', message }),
|
||||||
|
onClose: () => dispatchChat({ type: 'disconnect' }),
|
||||||
|
onError: () => setSessionError('Session socket connection failed.'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.close();
|
||||||
|
if (socketRef.current === socket) {
|
||||||
|
socketRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [activeSessionId, loadState]);
|
||||||
|
|
||||||
|
const chooseWorkspace = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const selectedPath = await window.qwenDesktop.selectDirectory();
|
||||||
|
if (selectedPath) {
|
||||||
|
setWorkspacePath(selectedPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSessionError(getErrorMessage(error));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createSession = useCallback(async () => {
|
||||||
|
if (loadState.state !== 'ready' || !workspacePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await createDesktopSession(
|
||||||
|
loadState.status.serverInfo,
|
||||||
|
workspacePath,
|
||||||
|
);
|
||||||
|
setSessions((current) => [session, ...current]);
|
||||||
|
setActiveSessionId(session.sessionId);
|
||||||
|
setSessionError(null);
|
||||||
|
} catch (error) {
|
||||||
|
setSessionError(getErrorMessage(error));
|
||||||
|
}
|
||||||
|
}, [loadState, workspacePath]);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
(event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const content = messageText.trim();
|
||||||
|
if (!content || !socketRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchChat({ type: 'append_user_message', content });
|
||||||
|
socketRef.current.sendUserMessage(content);
|
||||||
|
setMessageText('');
|
||||||
|
},
|
||||||
|
[messageText],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopGeneration = useCallback(() => {
|
||||||
|
socketRef.current?.stopGeneration();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const statusLabel = useMemo(() => {
|
const statusLabel = useMemo(() => {
|
||||||
if (loadState.state === 'ready') {
|
if (loadState.state === 'ready') {
|
||||||
return 'Connected';
|
return 'Connected';
|
||||||
|
|
@ -74,12 +210,28 @@ export function App() {
|
||||||
|
|
||||||
<section className="sidebar-section">
|
<section className="sidebar-section">
|
||||||
<h2>Workspace</h2>
|
<h2>Workspace</h2>
|
||||||
<div className="empty-row">No folder selected</div>
|
<div className="workspace-path">
|
||||||
|
{workspacePath || 'No folder selected'}
|
||||||
|
</div>
|
||||||
|
<button className="secondary-button" onClick={chooseWorkspace}>
|
||||||
|
Select Folder
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={loadState.state !== 'ready' || !workspacePath}
|
||||||
|
onClick={createSession}
|
||||||
|
>
|
||||||
|
New Session
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="sidebar-section sidebar-section-fill">
|
<section className="sidebar-section sidebar-section-fill">
|
||||||
<h2>Sessions</h2>
|
<h2>Sessions</h2>
|
||||||
<div className="empty-row">No sessions</div>
|
<SessionList
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
sessions={sessions}
|
||||||
|
onSelect={setActiveSessionId}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
@ -96,9 +248,38 @@ export function App() {
|
||||||
<section className="panel panel-main">
|
<section className="panel panel-main">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h3>Conversation</h3>
|
<h3>Conversation</h3>
|
||||||
<span>Idle</span>
|
<span>
|
||||||
|
{chatState.streaming ? 'Streaming' : chatState.connection}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="conversation-empty">No session selected</div>
|
<ChatTimeline state={chatState} activeSessionId={activeSessionId} />
|
||||||
|
<form className="composer" onSubmit={sendMessage}>
|
||||||
|
<textarea
|
||||||
|
aria-label="Message"
|
||||||
|
disabled={!activeSessionId}
|
||||||
|
onChange={(event) => setMessageText(event.target.value)}
|
||||||
|
placeholder={activeSessionId ? 'Message Qwen Code' : ''}
|
||||||
|
rows={3}
|
||||||
|
value={messageText}
|
||||||
|
/>
|
||||||
|
<div className="composer-actions">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
disabled={!chatState.streaming}
|
||||||
|
type="button"
|
||||||
|
onClick={stopGeneration}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={!activeSessionId || messageText.trim().length === 0}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel panel-side">
|
<section className="panel panel-side">
|
||||||
|
|
@ -106,6 +287,11 @@ export function App() {
|
||||||
<h3>Runtime</h3>
|
<h3>Runtime</h3>
|
||||||
</div>
|
</div>
|
||||||
<RuntimeDetails loadState={loadState} />
|
<RuntimeDetails loadState={loadState} />
|
||||||
|
<SessionDetails
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
chatState={chatState}
|
||||||
|
sessionError={sessionError}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -113,6 +299,102 @@ export function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SessionList({
|
||||||
|
activeSessionId,
|
||||||
|
sessions,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
activeSessionId: string | null;
|
||||||
|
sessions: DesktopSessionSummary[];
|
||||||
|
onSelect: (sessionId: string) => void;
|
||||||
|
}) {
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return <div className="empty-row">No sessions</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="session-list">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
session.sessionId === activeSessionId
|
||||||
|
? 'session-row session-row-active'
|
||||||
|
: 'session-row'
|
||||||
|
}
|
||||||
|
key={session.sessionId}
|
||||||
|
onClick={() => onSelect(session.sessionId)}
|
||||||
|
>
|
||||||
|
<span>{session.title || session.sessionId}</span>
|
||||||
|
<small>{session.cwd || session.sessionId}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatTimeline({
|
||||||
|
activeSessionId,
|
||||||
|
state,
|
||||||
|
}: {
|
||||||
|
activeSessionId: string | null;
|
||||||
|
state: ChatState;
|
||||||
|
}) {
|
||||||
|
if (!activeSessionId) {
|
||||||
|
return <div className="conversation-empty">No session selected</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.items.length === 0) {
|
||||||
|
return <div className="conversation-empty">Session ready</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-timeline">
|
||||||
|
{state.items.map((item) => (
|
||||||
|
<TimelineItem item={item} key={item.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineItem({ item }: { item: ChatTimelineItem }) {
|
||||||
|
if (item.type === 'message') {
|
||||||
|
return (
|
||||||
|
<article className={`chat-message chat-message-${item.role}`}>
|
||||||
|
<div className="message-role">{item.role}</div>
|
||||||
|
<p>{item.text}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'tool') {
|
||||||
|
return (
|
||||||
|
<article className="chat-tool">
|
||||||
|
<div className="message-role">{item.toolCall.kind || 'tool'}</div>
|
||||||
|
<strong>{item.toolCall.title || item.toolCall.toolCallId}</strong>
|
||||||
|
{item.toolCall.status ? <span>{item.toolCall.status}</span> : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'plan') {
|
||||||
|
return (
|
||||||
|
<article className="chat-plan">
|
||||||
|
<div className="message-role">plan</div>
|
||||||
|
<ol>
|
||||||
|
{item.entries.map((entry) => (
|
||||||
|
<li key={`${entry.content}-${entry.status}`}>
|
||||||
|
<span>{entry.status}</span>
|
||||||
|
{entry.content}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="chat-event">{item.label}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
function StatusPill({ state }: { state: LoadState['state'] }) {
|
function StatusPill({ state }: { state: LoadState['state'] }) {
|
||||||
return <span className={`status-pill status-pill-${state}`}>{state}</span>;
|
return <span className={`status-pill status-pill-${state}`}>{state}</span>;
|
||||||
}
|
}
|
||||||
|
|
@ -160,3 +442,53 @@ function RuntimeDetails({ loadState }: { loadState: LoadState }) {
|
||||||
</dl>
|
</dl>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SessionDetails({
|
||||||
|
activeSessionId,
|
||||||
|
chatState,
|
||||||
|
sessionError,
|
||||||
|
}: {
|
||||||
|
activeSessionId: string | null;
|
||||||
|
chatState: ChatState;
|
||||||
|
sessionError: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="session-details">
|
||||||
|
<div className="panel-header panel-header-inline">
|
||||||
|
<h3>Session</h3>
|
||||||
|
</div>
|
||||||
|
<dl className="runtime-details">
|
||||||
|
<div>
|
||||||
|
<dt>Active</dt>
|
||||||
|
<dd>{activeSessionId || 'None'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Mode</dt>
|
||||||
|
<dd>{chatState.mode || 'Unknown'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Commands</dt>
|
||||||
|
<dd>{chatState.availableCommands.length}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Skills</dt>
|
||||||
|
<dd>{chatState.availableSkills.length}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Tokens</dt>
|
||||||
|
<dd>{chatState.latestUsage?.usage?.totalTokens ?? 'Unknown'}</dd>
|
||||||
|
</div>
|
||||||
|
{sessionError || chatState.error ? (
|
||||||
|
<div>
|
||||||
|
<dt>Error</dt>
|
||||||
|
<dd className="error-text">{sessionError || chatState.error}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : 'Desktop operation failed.';
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export interface DesktopHealth {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopConnectionStatus {
|
export interface DesktopConnectionStatus {
|
||||||
|
serverInfo: DesktopServerInfo;
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
health: DesktopHealth;
|
health: DesktopHealth;
|
||||||
runtime: DesktopRuntime;
|
runtime: DesktopRuntime;
|
||||||
|
|
@ -42,6 +43,17 @@ export interface DesktopRuntime {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopSessionSummary {
|
||||||
|
sessionId: string;
|
||||||
|
title?: string;
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopSessionList {
|
||||||
|
sessions: DesktopSessionSummary[];
|
||||||
|
nextCursor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadDesktopStatus(): Promise<DesktopConnectionStatus> {
|
export async function loadDesktopStatus(): Promise<DesktopConnectionStatus> {
|
||||||
const serverInfo = await getServerInfo();
|
const serverInfo = await getServerInfo();
|
||||||
const [health, runtime] = await Promise.all([
|
const [health, runtime] = await Promise.all([
|
||||||
|
|
@ -50,12 +62,39 @@ export async function loadDesktopStatus(): Promise<DesktopConnectionStatus> {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
serverInfo,
|
||||||
serverUrl: serverInfo.url,
|
serverUrl: serverInfo.url,
|
||||||
health,
|
health,
|
||||||
runtime,
|
runtime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listDesktopSessions(
|
||||||
|
serverInfo: DesktopServerInfo,
|
||||||
|
cwd?: string,
|
||||||
|
): Promise<DesktopSessionList> {
|
||||||
|
const url = new URL('/api/sessions', serverInfo.url);
|
||||||
|
if (cwd) {
|
||||||
|
url.searchParams.set('cwd', cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getJson(serverInfo, `${url.pathname}${url.search}`, isSessionList);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDesktopSession(
|
||||||
|
serverInfo: DesktopServerInfo,
|
||||||
|
cwd: string,
|
||||||
|
): Promise<DesktopSessionSummary> {
|
||||||
|
const response = await writeJson(
|
||||||
|
serverInfo,
|
||||||
|
'/api/sessions',
|
||||||
|
'POST',
|
||||||
|
{ cwd },
|
||||||
|
isCreateSessionResponse,
|
||||||
|
);
|
||||||
|
return response.session;
|
||||||
|
}
|
||||||
|
|
||||||
async function getJson<T>(
|
async function getJson<T>(
|
||||||
serverInfo: DesktopServerInfo,
|
serverInfo: DesktopServerInfo,
|
||||||
path: string,
|
path: string,
|
||||||
|
|
@ -69,7 +108,31 @@ async function getJson<T>(
|
||||||
const payload = (await response.json()) as unknown;
|
const payload = (await response.json()) as unknown;
|
||||||
|
|
||||||
if (!response.ok || !isExpectedPayload(payload)) {
|
if (!response.ok || !isExpectedPayload(payload)) {
|
||||||
throw new Error(`Desktop service request failed: ${path}`);
|
throw new Error(getResponseErrorMessage(payload, path));
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJson<T>(
|
||||||
|
serverInfo: DesktopServerInfo,
|
||||||
|
path: string,
|
||||||
|
method: 'POST',
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
isExpectedPayload: (value: unknown) => value is T,
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(new URL(path, serverInfo.url), {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${serverInfo.token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const payload = (await response.json()) as unknown;
|
||||||
|
|
||||||
|
if (!response.ok || !isExpectedPayload(payload)) {
|
||||||
|
throw new Error(getResponseErrorMessage(payload, path));
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
|
|
@ -83,6 +146,19 @@ async function getServerInfo(): Promise<DesktopServerInfo> {
|
||||||
return window.qwenDesktop.getServerInfo();
|
return window.qwenDesktop.getServerInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getResponseErrorMessage(payload: unknown, path: string): string {
|
||||||
|
if (
|
||||||
|
payload &&
|
||||||
|
typeof payload === 'object' &&
|
||||||
|
'message' in payload &&
|
||||||
|
typeof payload.message === 'string'
|
||||||
|
) {
|
||||||
|
return payload.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Desktop service request failed: ${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
function isDesktopHealth(value: unknown): value is DesktopHealth {
|
function isDesktopHealth(value: unknown): value is DesktopHealth {
|
||||||
if (!value || typeof value !== 'object') {
|
if (!value || typeof value !== 'object') {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -146,3 +222,41 @@ function isDesktopRuntimePlatform(
|
||||||
typeof value.release === 'string'
|
typeof value.release === 'string'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSessionList(value: unknown): value is DesktopSessionList {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as { sessions?: unknown; nextCursor?: unknown };
|
||||||
|
return (
|
||||||
|
Array.isArray(candidate.sessions) &&
|
||||||
|
candidate.sessions.every(isSessionSummary) &&
|
||||||
|
(typeof candidate.nextCursor === 'string' ||
|
||||||
|
candidate.nextCursor === undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCreateSessionResponse(
|
||||||
|
value: unknown,
|
||||||
|
): value is { ok: true; session: DesktopSessionSummary } {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as { ok?: unknown; session?: unknown };
|
||||||
|
return candidate.ok === true && isSessionSummary(candidate.session);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSessionSummary(value: unknown): value is DesktopSessionSummary {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Partial<DesktopSessionSummary>;
|
||||||
|
return (
|
||||||
|
typeof candidate.sessionId === 'string' &&
|
||||||
|
(typeof candidate.title === 'string' || candidate.title === undefined) &&
|
||||||
|
(typeof candidate.cwd === 'string' || candidate.cwd === undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
98
packages/desktop/src/renderer/api/websocket.ts
Normal file
98
packages/desktop/src/renderer/api/websocket.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DesktopServerInfo } from '../../shared/desktopApi.js';
|
||||||
|
import type {
|
||||||
|
DesktopClientMessage,
|
||||||
|
DesktopServerMessage,
|
||||||
|
} from '../../shared/desktopProtocol.js';
|
||||||
|
|
||||||
|
export interface SessionSocketHandlers {
|
||||||
|
onOpen?: () => void;
|
||||||
|
onMessage: (message: DesktopServerMessage) => void;
|
||||||
|
onClose?: (event: CloseEvent) => void;
|
||||||
|
onError?: (event: Event) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionSocketClient {
|
||||||
|
sendUserMessage(content: string): void;
|
||||||
|
stopGeneration(): void;
|
||||||
|
ping(): void;
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectSessionSocket(
|
||||||
|
serverInfo: DesktopServerInfo,
|
||||||
|
sessionId: string,
|
||||||
|
handlers: SessionSocketHandlers,
|
||||||
|
): SessionSocketClient {
|
||||||
|
const socket = new WebSocket(createSessionSocketUrl(serverInfo, sessionId));
|
||||||
|
|
||||||
|
socket.addEventListener('open', () => handlers.onOpen?.());
|
||||||
|
socket.addEventListener('message', (event) => {
|
||||||
|
const message = parseServerMessage(event.data);
|
||||||
|
if (message) {
|
||||||
|
handlers.onMessage(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.addEventListener('close', (event) => handlers.onClose?.(event));
|
||||||
|
socket.addEventListener('error', (event) => handlers.onError?.(event));
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendUserMessage(content: string): void {
|
||||||
|
sendClientMessage(socket, { type: 'user_message', content });
|
||||||
|
},
|
||||||
|
stopGeneration(): void {
|
||||||
|
sendClientMessage(socket, { type: 'stop_generation' });
|
||||||
|
},
|
||||||
|
ping(): void {
|
||||||
|
sendClientMessage(socket, { type: 'ping' });
|
||||||
|
},
|
||||||
|
close(): void {
|
||||||
|
socket.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionSocketUrl(
|
||||||
|
serverInfo: DesktopServerInfo,
|
||||||
|
sessionId: string,
|
||||||
|
): string {
|
||||||
|
const url = new URL(`/ws/${encodeURIComponent(sessionId)}`, serverInfo.url);
|
||||||
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
url.searchParams.set('token', serverInfo.token);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendClientMessage(
|
||||||
|
socket: WebSocket,
|
||||||
|
message: DesktopClientMessage,
|
||||||
|
): void {
|
||||||
|
if (socket.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseServerMessage(value: unknown): DesktopServerMessage | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(value) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object' || !('type' in parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as DesktopServerMessage;
|
||||||
|
}
|
||||||
308
packages/desktop/src/renderer/stores/chatStore.ts
Normal file
308
packages/desktop/src/renderer/stores/chatStore.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DesktopAvailableCommand,
|
||||||
|
DesktopPlanEntry,
|
||||||
|
DesktopServerMessage,
|
||||||
|
DesktopToolCallUpdate,
|
||||||
|
DesktopUsageStats,
|
||||||
|
} from '../../shared/desktopProtocol.js';
|
||||||
|
|
||||||
|
type ChatConnectionState = 'idle' | 'connecting' | 'connected' | 'closed';
|
||||||
|
|
||||||
|
export type ChatTimelineItem =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
type: 'message';
|
||||||
|
role: 'assistant' | 'thinking' | 'user';
|
||||||
|
text: string;
|
||||||
|
streaming: boolean;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
type: 'tool';
|
||||||
|
toolCall: DesktopToolCallUpdate;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
type: 'plan';
|
||||||
|
entries: DesktopPlanEntry[];
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
type: 'event';
|
||||||
|
label: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ChatState {
|
||||||
|
connection: ChatConnectionState;
|
||||||
|
streaming: boolean;
|
||||||
|
items: ChatTimelineItem[];
|
||||||
|
latestUsage: DesktopUsageStats | null;
|
||||||
|
availableCommands: DesktopAvailableCommand[];
|
||||||
|
availableSkills: string[];
|
||||||
|
mode: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatAction =
|
||||||
|
| { type: 'connect' }
|
||||||
|
| { type: 'disconnect' }
|
||||||
|
| { type: 'append_user_message'; content: string }
|
||||||
|
| { type: 'server_message'; message: DesktopServerMessage };
|
||||||
|
|
||||||
|
export function createInitialChatState(): ChatState {
|
||||||
|
return {
|
||||||
|
connection: 'idle',
|
||||||
|
streaming: false,
|
||||||
|
items: [],
|
||||||
|
latestUsage: null,
|
||||||
|
availableCommands: [],
|
||||||
|
availableSkills: [],
|
||||||
|
mode: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chatReducer(state: ChatState, action: ChatAction): ChatState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'connect':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
connection: 'connecting',
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'disconnect':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
connection: 'closed',
|
||||||
|
streaming: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'append_user_message':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
streaming: true,
|
||||||
|
error: null,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
createMessageItem('user', action.content, false),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'server_message':
|
||||||
|
return applyServerMessage(state, action.message);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyServerMessage(
|
||||||
|
state: ChatState,
|
||||||
|
message: DesktopServerMessage,
|
||||||
|
): ChatState {
|
||||||
|
switch (message.type) {
|
||||||
|
case 'connected':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
connection: 'connected',
|
||||||
|
error: null,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
createEventItem(`Connected to ${message.sessionId}`),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'pong':
|
||||||
|
return state;
|
||||||
|
|
||||||
|
case 'message_delta':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
streaming: true,
|
||||||
|
items: appendMessageDelta(state.items, message.role, message.text),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'tool_call':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: upsertToolCall(state.items, message.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'plan':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: upsertPlan(state.items, message.entries),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'usage':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
latestUsage: message.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'mode_changed':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
mode: message.mode,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'available_commands':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
availableCommands: message.commands,
|
||||||
|
availableSkills: message.skills,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'message_complete':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
streaming: false,
|
||||||
|
items: [
|
||||||
|
...markStreamingMessagesComplete(state.items),
|
||||||
|
createEventItem(
|
||||||
|
message.stopReason
|
||||||
|
? `Turn complete: ${message.stopReason}`
|
||||||
|
: 'Turn complete',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
streaming: false,
|
||||||
|
error: message.message,
|
||||||
|
items: [...state.items, createEventItem(message.message)],
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMessageDelta(
|
||||||
|
items: ChatTimelineItem[],
|
||||||
|
role: 'assistant' | 'thinking' | 'user',
|
||||||
|
text: string,
|
||||||
|
): ChatTimelineItem[] {
|
||||||
|
const lastItem = items[items.length - 1];
|
||||||
|
if (
|
||||||
|
lastItem?.type === 'message' &&
|
||||||
|
lastItem.role === role &&
|
||||||
|
lastItem.streaming
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
...items.slice(0, -1),
|
||||||
|
{
|
||||||
|
...lastItem,
|
||||||
|
text: `${lastItem.text}${text}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...items, createMessageItem(role, text, true)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertToolCall(
|
||||||
|
items: ChatTimelineItem[],
|
||||||
|
update: DesktopToolCallUpdate,
|
||||||
|
): ChatTimelineItem[] {
|
||||||
|
const index = items.findIndex(
|
||||||
|
(item) =>
|
||||||
|
item.type === 'tool' && item.toolCall.toolCallId === update.toolCallId,
|
||||||
|
);
|
||||||
|
if (index === -1) {
|
||||||
|
return [...items, createToolItem(update)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((item, itemIndex) => {
|
||||||
|
if (itemIndex !== index || item.type !== 'tool') {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
toolCall: {
|
||||||
|
...item.toolCall,
|
||||||
|
...update,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertPlan(
|
||||||
|
items: ChatTimelineItem[],
|
||||||
|
entries: DesktopPlanEntry[],
|
||||||
|
): ChatTimelineItem[] {
|
||||||
|
const index = items.findIndex((item) => item.type === 'plan');
|
||||||
|
if (index === -1) {
|
||||||
|
return [...items, createPlanItem(entries)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((item, itemIndex) =>
|
||||||
|
itemIndex === index && item.type === 'plan'
|
||||||
|
? { ...item, entries, timestamp: Date.now() }
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markStreamingMessagesComplete(
|
||||||
|
items: ChatTimelineItem[],
|
||||||
|
): ChatTimelineItem[] {
|
||||||
|
return items.map((item) =>
|
||||||
|
item.type === 'message' ? { ...item, streaming: false } : item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMessageItem(
|
||||||
|
role: 'assistant' | 'thinking' | 'user',
|
||||||
|
text: string,
|
||||||
|
streaming: boolean,
|
||||||
|
): ChatTimelineItem {
|
||||||
|
return {
|
||||||
|
id: `message-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
type: 'message',
|
||||||
|
role,
|
||||||
|
text,
|
||||||
|
streaming,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createToolItem(toolCall: DesktopToolCallUpdate): ChatTimelineItem {
|
||||||
|
return {
|
||||||
|
id: `tool-${toolCall.toolCallId}`,
|
||||||
|
type: 'tool',
|
||||||
|
toolCall,
|
||||||
|
timestamp: toolCall.timestamp ?? Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlanItem(entries: DesktopPlanEntry[]): ChatTimelineItem {
|
||||||
|
return {
|
||||||
|
id: 'plan-current',
|
||||||
|
type: 'plan',
|
||||||
|
entries,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEventItem(label: string): ChatTimelineItem {
|
||||||
|
return {
|
||||||
|
id: `event-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
type: 'event',
|
||||||
|
label,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,17 @@ textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled,
|
||||||
|
textarea:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +125,76 @@ textarea {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace-path {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(238, 240, 237, 0.09);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: #d8dcd6;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.secondary-button {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: #e7bd73;
|
||||||
|
color: #161311;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
border-color: rgba(238, 240, 237, 0.13);
|
||||||
|
background: rgba(238, 240, 237, 0.04);
|
||||||
|
color: #d8dcd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 54px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(238, 240, 237, 0.09);
|
||||||
|
background: transparent;
|
||||||
|
color: #d8dcd6;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row-active {
|
||||||
|
border-color: rgba(231, 189, 115, 0.55);
|
||||||
|
background: rgba(231, 189, 115, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row span,
|
||||||
|
.session-row small {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row span {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row small {
|
||||||
|
color: #858c84;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.workbench {
|
.workbench {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -208,6 +289,133 @@ textarea {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-timeline {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message,
|
||||||
|
.chat-tool,
|
||||||
|
.chat-plan {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: min(760px, 100%);
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(238, 240, 237, 0.09);
|
||||||
|
background: rgba(238, 240, 237, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-user {
|
||||||
|
align-self: flex-end;
|
||||||
|
border-color: rgba(231, 189, 115, 0.36);
|
||||||
|
background: rgba(231, 189, 115, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-thinking {
|
||||||
|
border-color: rgba(113, 169, 239, 0.32);
|
||||||
|
background: rgba(113, 169, 239, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message p,
|
||||||
|
.chat-tool strong,
|
||||||
|
.chat-tool span,
|
||||||
|
.chat-plan ol {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message p {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: #eef0ed;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-role {
|
||||||
|
color: #9ca39b;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool {
|
||||||
|
border-color: rgba(99, 214, 157, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool strong {
|
||||||
|
color: #eef0ed;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool span {
|
||||||
|
color: #96e6ba;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plan ol {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plan li {
|
||||||
|
color: #d8dcd6;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-plan li span {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 88px;
|
||||||
|
color: #9ca39b;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-event {
|
||||||
|
align-self: center;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: #858c84;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border-top: 1px solid rgba(238, 240, 237, 0.08);
|
||||||
|
background: rgba(16, 18, 20, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 76px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid rgba(238, 240, 237, 0.13);
|
||||||
|
background: rgba(238, 240, 237, 0.035);
|
||||||
|
color: #eef0ed;
|
||||||
|
padding: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer textarea:focus {
|
||||||
|
border-color: rgba(231, 189, 115, 0.55);
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.runtime-row,
|
.runtime-row,
|
||||||
.runtime-details {
|
.runtime-details {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|
@ -248,6 +456,14 @@ textarea {
|
||||||
color: #ff9a84;
|
color: #ff9a84;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-details {
|
||||||
|
border-top: 1px solid rgba(238, 240, 237, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header-inline {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.desktop-shell {
|
.desktop-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
|
||||||
175
packages/desktop/src/server/acp/AcpEventRouter.test.ts
Normal file
175
packages/desktop/src/server/acp/AcpEventRouter.test.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
||||||
|
import { normalizeSessionUpdate } from './AcpEventRouter.js';
|
||||||
|
|
||||||
|
describe('normalizeSessionUpdate', () => {
|
||||||
|
it('normalizes assistant text chunks and usage metadata', () => {
|
||||||
|
const messages = normalizeSessionUpdate({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
update: {
|
||||||
|
sessionUpdate: 'agent_message_chunk',
|
||||||
|
content: { type: 'text', text: 'hello' },
|
||||||
|
_meta: {
|
||||||
|
usage: {
|
||||||
|
inputTokens: 11,
|
||||||
|
outputTokens: 7,
|
||||||
|
thoughtTokens: 2,
|
||||||
|
totalTokens: 20,
|
||||||
|
},
|
||||||
|
durationMs: 1500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as SessionNotification);
|
||||||
|
|
||||||
|
expect(messages).toEqual([
|
||||||
|
{ type: 'message_delta', role: 'assistant', text: 'hello' },
|
||||||
|
{
|
||||||
|
type: 'usage',
|
||||||
|
data: {
|
||||||
|
usage: {
|
||||||
|
inputTokens: 11,
|
||||||
|
outputTokens: 7,
|
||||||
|
thoughtTokens: 2,
|
||||||
|
totalTokens: 20,
|
||||||
|
cachedReadTokens: undefined,
|
||||||
|
cachedWriteTokens: undefined,
|
||||||
|
promptTokens: 11,
|
||||||
|
completionTokens: 7,
|
||||||
|
thoughtsTokens: 2,
|
||||||
|
cachedTokens: undefined,
|
||||||
|
},
|
||||||
|
durationMs: 1500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes thought chunks, tool calls, plans, modes, and commands', () => {
|
||||||
|
expect(
|
||||||
|
normalizeSessionUpdate({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
update: {
|
||||||
|
sessionUpdate: 'agent_thought_chunk',
|
||||||
|
content: { type: 'text', text: 'thinking' },
|
||||||
|
},
|
||||||
|
} as SessionNotification),
|
||||||
|
).toEqual([{ type: 'message_delta', role: 'thinking', text: 'thinking' }]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeSessionUpdate({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
update: {
|
||||||
|
sessionUpdate: 'tool_call',
|
||||||
|
toolCallId: 'tool-1',
|
||||||
|
title: 'Read file',
|
||||||
|
kind: 'read',
|
||||||
|
status: 'pending',
|
||||||
|
rawInput: { path: 'README.md' },
|
||||||
|
locations: [{ path: 'README.md', line: 1 }],
|
||||||
|
_meta: { timestamp: 123 },
|
||||||
|
},
|
||||||
|
} as SessionNotification),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
type: 'tool_call',
|
||||||
|
data: {
|
||||||
|
toolCallId: 'tool-1',
|
||||||
|
title: 'Read file',
|
||||||
|
kind: 'read',
|
||||||
|
status: 'pending',
|
||||||
|
rawInput: { path: 'README.md' },
|
||||||
|
rawOutput: undefined,
|
||||||
|
content: undefined,
|
||||||
|
locations: [{ path: 'README.md', line: 1 }],
|
||||||
|
timestamp: 123,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeSessionUpdate({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
update: {
|
||||||
|
sessionUpdate: 'plan',
|
||||||
|
entries: [
|
||||||
|
{ content: 'Implement', priority: 'high', status: 'in_progress' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as SessionNotification),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
type: 'plan',
|
||||||
|
entries: [
|
||||||
|
{ content: 'Implement', priority: 'high', status: 'in_progress' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeSessionUpdate({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
update: {
|
||||||
|
sessionUpdate: 'current_mode_update',
|
||||||
|
currentModeId: 'auto-edit',
|
||||||
|
},
|
||||||
|
} as SessionNotification),
|
||||||
|
).toEqual([{ type: 'mode_changed', mode: 'auto-edit' }]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeSessionUpdate({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
update: {
|
||||||
|
sessionUpdate: 'available_commands_update',
|
||||||
|
availableCommands: [
|
||||||
|
{
|
||||||
|
name: 'help',
|
||||||
|
description: 'Show help',
|
||||||
|
input: { hint: 'topic' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
_meta: { availableSkills: ['review'] },
|
||||||
|
},
|
||||||
|
} as SessionNotification),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
type: 'available_commands',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: 'help',
|
||||||
|
description: 'Show help',
|
||||||
|
input: { hint: 'topic' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: ['review'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes explicit usage updates', () => {
|
||||||
|
const messages = normalizeSessionUpdate({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
update: {
|
||||||
|
sessionUpdate: 'usage_update',
|
||||||
|
used: 4096,
|
||||||
|
size: 128000,
|
||||||
|
},
|
||||||
|
} as SessionNotification);
|
||||||
|
|
||||||
|
expect(messages).toEqual([
|
||||||
|
{
|
||||||
|
type: 'usage',
|
||||||
|
data: {
|
||||||
|
usage: { totalTokens: 4096 },
|
||||||
|
tokenLimit: 128000,
|
||||||
|
cost: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
249
packages/desktop/src/server/acp/AcpEventRouter.ts
Normal file
249
packages/desktop/src/server/acp/AcpEventRouter.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AvailableCommand,
|
||||||
|
SessionNotification,
|
||||||
|
} from '@agentclientprotocol/sdk';
|
||||||
|
import type {
|
||||||
|
DesktopAvailableCommand,
|
||||||
|
DesktopServerMessage,
|
||||||
|
DesktopToolCallUpdate,
|
||||||
|
DesktopUsageStats,
|
||||||
|
} from '../../shared/desktopProtocol.js';
|
||||||
|
|
||||||
|
export interface AcpEventRouterOptions {
|
||||||
|
broadcast(sessionId: string, message: DesktopServerMessage): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AcpEventRouter {
|
||||||
|
constructor(private readonly options: AcpEventRouterOptions) {}
|
||||||
|
|
||||||
|
handleSessionUpdate(notification: SessionNotification): void {
|
||||||
|
const messages = normalizeSessionUpdate(notification);
|
||||||
|
for (const message of messages) {
|
||||||
|
this.options.broadcast(notification.sessionId, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSessionUpdate(
|
||||||
|
notification: SessionNotification,
|
||||||
|
): DesktopServerMessage[] {
|
||||||
|
const { update } = notification;
|
||||||
|
const messages: DesktopServerMessage[] = [];
|
||||||
|
|
||||||
|
switch (update.sessionUpdate) {
|
||||||
|
case 'user_message_chunk': {
|
||||||
|
const text = getTextContent(update.content);
|
||||||
|
if (text) {
|
||||||
|
messages.push({ type: 'message_delta', role: 'user', text });
|
||||||
|
}
|
||||||
|
messages.push(...getUsageMessages(update._meta));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'agent_message_chunk': {
|
||||||
|
const text = getTextContent(update.content);
|
||||||
|
if (text) {
|
||||||
|
messages.push({ type: 'message_delta', role: 'assistant', text });
|
||||||
|
}
|
||||||
|
messages.push(...getUsageMessages(update._meta));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'agent_thought_chunk': {
|
||||||
|
const text = getTextContent(update.content);
|
||||||
|
if (text) {
|
||||||
|
messages.push({ type: 'message_delta', role: 'thinking', text });
|
||||||
|
}
|
||||||
|
messages.push(...getUsageMessages(update._meta));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool_call':
|
||||||
|
case 'tool_call_update':
|
||||||
|
messages.push({
|
||||||
|
type: 'tool_call',
|
||||||
|
data: normalizeToolCall(update),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'plan':
|
||||||
|
messages.push({
|
||||||
|
type: 'plan',
|
||||||
|
entries: update.entries.map((entry) => ({
|
||||||
|
content: entry.content,
|
||||||
|
priority: entry.priority,
|
||||||
|
status: entry.status,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'current_mode_update':
|
||||||
|
messages.push({
|
||||||
|
type: 'mode_changed',
|
||||||
|
mode: update.currentModeId,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'available_commands_update':
|
||||||
|
messages.push({
|
||||||
|
type: 'available_commands',
|
||||||
|
commands: update.availableCommands.map(normalizeAvailableCommand),
|
||||||
|
skills: getStringArray(getRecord(update._meta)?.['availableSkills']),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'usage_update':
|
||||||
|
messages.push({
|
||||||
|
type: 'usage',
|
||||||
|
data: {
|
||||||
|
usage: { totalTokens: update.used },
|
||||||
|
tokenLimit: update.size,
|
||||||
|
cost: update.cost ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'config_option_update':
|
||||||
|
case 'session_info_update':
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextContent(content: { type: string } | undefined): string {
|
||||||
|
if (content?.type !== 'text') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (content as { text?: unknown }).text;
|
||||||
|
return typeof text === 'string' ? text : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolCall(
|
||||||
|
update: Extract<
|
||||||
|
SessionNotification['update'],
|
||||||
|
{ sessionUpdate: 'tool_call' | 'tool_call_update' }
|
||||||
|
>,
|
||||||
|
): DesktopToolCallUpdate {
|
||||||
|
const meta = getRecord(update._meta);
|
||||||
|
const timestamp = getOptionalNumber(meta?.['timestamp']);
|
||||||
|
|
||||||
|
return {
|
||||||
|
toolCallId: update.toolCallId,
|
||||||
|
kind: getOptionalString(update.kind),
|
||||||
|
title: getOptionalString(update.title),
|
||||||
|
status: getOptionalString(update.status),
|
||||||
|
rawInput: update.rawInput,
|
||||||
|
rawOutput: update.rawOutput,
|
||||||
|
content: update.content ?? undefined,
|
||||||
|
locations: update.locations ?? undefined,
|
||||||
|
...(timestamp !== undefined && { timestamp }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAvailableCommand(
|
||||||
|
command: AvailableCommand,
|
||||||
|
): DesktopAvailableCommand {
|
||||||
|
return {
|
||||||
|
name: command.name,
|
||||||
|
description: command.description,
|
||||||
|
input: command.input ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsageMessages(
|
||||||
|
meta: Record<string, unknown> | null | undefined,
|
||||||
|
): DesktopServerMessage[] {
|
||||||
|
const usage = getUsageStats(meta);
|
||||||
|
return usage ? [{ type: 'usage', data: usage }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsageStats(
|
||||||
|
meta: Record<string, unknown> | null | undefined,
|
||||||
|
): DesktopUsageStats | null {
|
||||||
|
if (!meta) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUsage = getRecord(meta['usage']);
|
||||||
|
const durationMs = getNullableNumber(meta['durationMs']);
|
||||||
|
if (!rawUsage && durationMs === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
usage: rawUsage
|
||||||
|
? {
|
||||||
|
inputTokens:
|
||||||
|
getNullableNumber(rawUsage['inputTokens']) ??
|
||||||
|
getNullableNumber(rawUsage['promptTokens']),
|
||||||
|
outputTokens:
|
||||||
|
getNullableNumber(rawUsage['outputTokens']) ??
|
||||||
|
getNullableNumber(rawUsage['completionTokens']),
|
||||||
|
thoughtTokens:
|
||||||
|
getNullableNumber(rawUsage['thoughtTokens']) ??
|
||||||
|
getNullableNumber(rawUsage['thoughtsTokens']),
|
||||||
|
totalTokens: getNullableNumber(rawUsage['totalTokens']),
|
||||||
|
cachedReadTokens:
|
||||||
|
getNullableNumber(rawUsage['cachedReadTokens']) ??
|
||||||
|
getNullableNumber(rawUsage['cachedTokens']),
|
||||||
|
cachedWriteTokens: getNullableNumber(rawUsage['cachedWriteTokens']),
|
||||||
|
promptTokens:
|
||||||
|
getNullableNumber(rawUsage['promptTokens']) ??
|
||||||
|
getNullableNumber(rawUsage['inputTokens']),
|
||||||
|
completionTokens:
|
||||||
|
getNullableNumber(rawUsage['completionTokens']) ??
|
||||||
|
getNullableNumber(rawUsage['outputTokens']),
|
||||||
|
thoughtsTokens:
|
||||||
|
getNullableNumber(rawUsage['thoughtsTokens']) ??
|
||||||
|
getNullableNumber(rawUsage['thoughtTokens']),
|
||||||
|
cachedTokens:
|
||||||
|
getNullableNumber(rawUsage['cachedTokens']) ??
|
||||||
|
getNullableNumber(rawUsage['cachedReadTokens']),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
...(durationMs !== undefined && { durationMs }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecord(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionalString(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionalNumber(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNullableNumber(value: unknown): number | null | undefined {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getOptionalNumber(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringArray(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.filter((item): item is string => typeof item === 'string');
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
|
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
||||||
import { startDesktopServer } from './index.js';
|
import { startDesktopServer } from './index.js';
|
||||||
import type { DesktopServer } from './types.js';
|
import type { DesktopServer } from './types.js';
|
||||||
import type { AcpSessionClient } from './services/sessionService.js';
|
import type { AcpSessionClient } from './services/sessionService.js';
|
||||||
|
|
@ -254,6 +255,95 @@ describe('DesktopServer', () => {
|
||||||
testSocket.socket.close();
|
testSocket.socket.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('broadcasts normalized ACP session updates over WebSocket', async () => {
|
||||||
|
const acpClient = createAcpClient();
|
||||||
|
const server = await createTestServer(acpClient);
|
||||||
|
const testSocket = await connectSocket(server, '/ws/session-1');
|
||||||
|
await testSocket.readMessage();
|
||||||
|
|
||||||
|
acpClient.emitSessionUpdate({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
update: {
|
||||||
|
sessionUpdate: 'agent_message_chunk',
|
||||||
|
content: { type: 'text', text: 'streamed text' },
|
||||||
|
_meta: {
|
||||||
|
usage: {
|
||||||
|
inputTokens: 5,
|
||||||
|
outputTokens: 3,
|
||||||
|
totalTokens: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as SessionNotification);
|
||||||
|
|
||||||
|
expect(await testSocket.readMessage()).toMatchObject({
|
||||||
|
type: 'message_delta',
|
||||||
|
role: 'assistant',
|
||||||
|
text: 'streamed text',
|
||||||
|
});
|
||||||
|
expect(await testSocket.readMessage()).toMatchObject({
|
||||||
|
type: 'usage',
|
||||||
|
data: {
|
||||||
|
usage: {
|
||||||
|
inputTokens: 5,
|
||||||
|
outputTokens: 3,
|
||||||
|
totalTokens: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
testSocket.socket.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('broadcasts ACP tool and plan updates only to the matching session', async () => {
|
||||||
|
const acpClient = createAcpClient();
|
||||||
|
const server = await createTestServer(acpClient);
|
||||||
|
const matchingSocket = await connectSocket(server, '/ws/session-1');
|
||||||
|
const otherSocket = await connectSocket(server, '/ws/session-2');
|
||||||
|
await matchingSocket.readMessage();
|
||||||
|
await otherSocket.readMessage();
|
||||||
|
|
||||||
|
acpClient.emitSessionUpdate({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
update: {
|
||||||
|
sessionUpdate: 'tool_call',
|
||||||
|
toolCallId: 'tool-1',
|
||||||
|
title: 'Run command',
|
||||||
|
kind: 'execute',
|
||||||
|
status: 'in_progress',
|
||||||
|
},
|
||||||
|
} as SessionNotification);
|
||||||
|
acpClient.emitSessionUpdate({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
update: {
|
||||||
|
sessionUpdate: 'plan',
|
||||||
|
entries: [
|
||||||
|
{ content: 'Wire events', priority: 'medium', status: 'completed' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as SessionNotification);
|
||||||
|
|
||||||
|
expect(await matchingSocket.readMessage()).toMatchObject({
|
||||||
|
type: 'tool_call',
|
||||||
|
data: {
|
||||||
|
toolCallId: 'tool-1',
|
||||||
|
title: 'Run command',
|
||||||
|
kind: 'execute',
|
||||||
|
status: 'in_progress',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await matchingSocket.readMessage()).toMatchObject({
|
||||||
|
type: 'plan',
|
||||||
|
entries: [
|
||||||
|
{ content: 'Wire events', priority: 'medium', status: 'completed' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
otherSocket.socket.send(JSON.stringify({ type: 'ping' }));
|
||||||
|
expect(await otherSocket.readMessage()).toMatchObject({ type: 'pong' });
|
||||||
|
matchingSocket.socket.close();
|
||||||
|
otherSocket.socket.close();
|
||||||
|
});
|
||||||
|
|
||||||
it('cancels generation over WebSocket', async () => {
|
it('cancels generation over WebSocket', async () => {
|
||||||
const acpClient = createAcpClient();
|
const acpClient = createAcpClient();
|
||||||
const server = await createTestServer(acpClient);
|
const server = await createTestServer(acpClient);
|
||||||
|
|
@ -369,9 +459,17 @@ async function writeJson(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAcpClient(): AcpSessionClient {
|
interface TestAcpClient extends AcpSessionClient {
|
||||||
return {
|
emitSessionUpdate(notification: SessionNotification): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAcpClient(): TestAcpClient {
|
||||||
|
const client: TestAcpClient = {
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
|
onSessionUpdate: undefined,
|
||||||
|
emitSessionUpdate(notification: SessionNotification): void {
|
||||||
|
client.onSessionUpdate?.(notification);
|
||||||
|
},
|
||||||
listSessions: vi.fn().mockResolvedValue({
|
listSessions: vi.fn().mockResolvedValue({
|
||||||
sessions: [{ sessionId: 'session-1', title: 'Test session' }],
|
sessions: [{ sessionId: 'session-1', title: 'Test session' }],
|
||||||
nextCursor: '3',
|
nextCursor: '3',
|
||||||
|
|
@ -382,6 +480,7 @@ function createAcpClient(): AcpSessionClient {
|
||||||
cancel: vi.fn().mockResolvedValue(undefined),
|
cancel: vi.fn().mockResolvedValue(undefined),
|
||||||
extMethod: vi.fn().mockResolvedValue({ success: true }),
|
extMethod: vi.fn().mockResolvedValue({ success: true }),
|
||||||
};
|
};
|
||||||
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectSocket(
|
async function connectSocket(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
isAllowedOrigin,
|
isAllowedOrigin,
|
||||||
isAuthorized,
|
isAuthorized,
|
||||||
} from './http/auth.js';
|
} from './http/auth.js';
|
||||||
|
import { AcpEventRouter } from './acp/AcpEventRouter.js';
|
||||||
import { isDesktopHttpError, DesktopHttpError } from './http/errors.js';
|
import { isDesktopHttpError, DesktopHttpError } from './http/errors.js';
|
||||||
import { getRuntimeInfo } from './services/runtimeService.js';
|
import { getRuntimeInfo } from './services/runtimeService.js';
|
||||||
import { DesktopSessionService } from './services/sessionService.js';
|
import { DesktopSessionService } from './services/sessionService.js';
|
||||||
|
|
@ -45,6 +46,16 @@ export async function startDesktopServer(
|
||||||
token,
|
token,
|
||||||
acpClient: options.acpClient,
|
acpClient: options.acpClient,
|
||||||
});
|
});
|
||||||
|
const acpEventRouter = new AcpEventRouter({
|
||||||
|
broadcast: (sessionId, message) => socketHub.broadcast(sessionId, message),
|
||||||
|
});
|
||||||
|
const previousSessionUpdateHandler = options.acpClient?.onSessionUpdate;
|
||||||
|
if (options.acpClient) {
|
||||||
|
options.acpClient.onSessionUpdate = (notification) => {
|
||||||
|
previousSessionUpdateHandler?.(notification);
|
||||||
|
acpEventRouter.handleSessionUpdate(notification);
|
||||||
|
};
|
||||||
|
}
|
||||||
const server = createServer((request, response) => {
|
const server = createServer((request, response) => {
|
||||||
void handleRequest(request, response, {
|
void handleRequest(request, response, {
|
||||||
token,
|
token,
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@ import type {
|
||||||
LoadSessionResponse,
|
LoadSessionResponse,
|
||||||
NewSessionResponse,
|
NewSessionResponse,
|
||||||
PromptResponse,
|
PromptResponse,
|
||||||
|
SessionNotification,
|
||||||
} from '@agentclientprotocol/sdk';
|
} from '@agentclientprotocol/sdk';
|
||||||
import { DesktopHttpError } from '../http/errors.js';
|
import { DesktopHttpError } from '../http/errors.js';
|
||||||
|
|
||||||
export interface AcpSessionClient {
|
export interface AcpSessionClient {
|
||||||
readonly isConnected?: boolean;
|
readonly isConnected?: boolean;
|
||||||
|
onSessionUpdate?: (notification: SessionNotification) => void;
|
||||||
connect?(): Promise<unknown>;
|
connect?(): Promise<unknown>;
|
||||||
listSessions(options?: {
|
listSessions(options?: {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,10 @@ import type { Duplex } from 'node:stream';
|
||||||
import { WebSocket, WebSocketServer } from 'ws';
|
import { WebSocket, WebSocketServer } from 'ws';
|
||||||
import { getSingleHeader, isAllowedOrigin } from '../http/auth.js';
|
import { getSingleHeader, isAllowedOrigin } from '../http/auth.js';
|
||||||
import type { AcpSessionClient } from '../services/sessionService.js';
|
import type { AcpSessionClient } from '../services/sessionService.js';
|
||||||
|
import type {
|
||||||
type ClientMessage =
|
DesktopClientMessage,
|
||||||
| { type: 'ping' }
|
DesktopServerMessage,
|
||||||
| { type: 'stop_generation' }
|
} from '../../shared/desktopProtocol.js';
|
||||||
| { type: 'user_message'; content: string };
|
|
||||||
|
|
||||||
type ServerMessage =
|
|
||||||
| { type: 'connected'; sessionId: string }
|
|
||||||
| { type: 'pong' }
|
|
||||||
| { type: 'message_complete'; stopReason?: string }
|
|
||||||
| { type: 'error'; code: string; message: string; retryable?: boolean };
|
|
||||||
|
|
||||||
interface SessionSocketHubOptions {
|
interface SessionSocketHubOptions {
|
||||||
token: string;
|
token: string;
|
||||||
|
|
@ -73,7 +66,7 @@ export class SessionSocketHub {
|
||||||
this.server.close();
|
this.server.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcast(sessionId: string, message: ServerMessage): void {
|
broadcast(sessionId: string, message: DesktopServerMessage): void {
|
||||||
const sockets = this.socketsBySession.get(sessionId);
|
const sockets = this.socketsBySession.get(sessionId);
|
||||||
if (!sockets) {
|
if (!sockets) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -225,7 +218,7 @@ function matchSessionId(pathname: string): string | null {
|
||||||
|
|
||||||
function parseClientMessage(
|
function parseClientMessage(
|
||||||
rawMessage: WebSocket.RawData,
|
rawMessage: WebSocket.RawData,
|
||||||
): ClientMessage | null {
|
): DesktopClientMessage | null {
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(rawMessage.toString()) as unknown;
|
parsed = JSON.parse(rawMessage.toString()) as unknown;
|
||||||
|
|
@ -237,7 +230,7 @@ function parseClientMessage(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = parsed as Partial<ClientMessage>;
|
const candidate = parsed as Partial<DesktopClientMessage>;
|
||||||
if (candidate.type === 'ping' || candidate.type === 'stop_generation') {
|
if (candidate.type === 'ping' || candidate.type === 'stop_generation') {
|
||||||
return { type: candidate.type };
|
return { type: candidate.type };
|
||||||
}
|
}
|
||||||
|
|
@ -252,7 +245,7 @@ function parseClientMessage(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendMessage(socket: WebSocket, message: ServerMessage): void {
|
function sendMessage(socket: WebSocket, message: DesktopServerMessage): void {
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
socket.send(JSON.stringify(message));
|
socket.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
72
packages/desktop/src/shared/desktopProtocol.ts
Normal file
72
packages/desktop/src/shared/desktopProtocol.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DesktopClientMessage =
|
||||||
|
| { type: 'ping' }
|
||||||
|
| { type: 'stop_generation' }
|
||||||
|
| { type: 'user_message'; content: string };
|
||||||
|
|
||||||
|
export interface DesktopPlanEntry {
|
||||||
|
content: string;
|
||||||
|
priority?: 'high' | 'medium' | 'low';
|
||||||
|
status: 'pending' | 'in_progress' | 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopToolCallUpdate {
|
||||||
|
toolCallId: string;
|
||||||
|
kind?: string;
|
||||||
|
title?: string;
|
||||||
|
status?: string;
|
||||||
|
rawInput?: unknown;
|
||||||
|
rawOutput?: unknown;
|
||||||
|
content?: unknown[];
|
||||||
|
locations?: Array<{ path: string; line?: number | null }>;
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopUsageStats {
|
||||||
|
usage?: {
|
||||||
|
inputTokens?: number | null;
|
||||||
|
outputTokens?: number | null;
|
||||||
|
thoughtTokens?: number | null;
|
||||||
|
totalTokens?: number | null;
|
||||||
|
cachedReadTokens?: number | null;
|
||||||
|
cachedWriteTokens?: number | null;
|
||||||
|
promptTokens?: number | null;
|
||||||
|
completionTokens?: number | null;
|
||||||
|
thoughtsTokens?: number | null;
|
||||||
|
cachedTokens?: number | null;
|
||||||
|
} | null;
|
||||||
|
durationMs?: number | null;
|
||||||
|
tokenLimit?: number | null;
|
||||||
|
cost?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopAvailableCommand {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
input?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DesktopServerMessage =
|
||||||
|
| { type: 'connected'; sessionId: string }
|
||||||
|
| { type: 'pong' }
|
||||||
|
| {
|
||||||
|
type: 'message_delta';
|
||||||
|
role: 'assistant' | 'thinking' | 'user';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
| { type: 'tool_call'; data: DesktopToolCallUpdate }
|
||||||
|
| { type: 'plan'; entries: DesktopPlanEntry[] }
|
||||||
|
| { type: 'usage'; data: DesktopUsageStats }
|
||||||
|
| { type: 'mode_changed'; mode: string }
|
||||||
|
| {
|
||||||
|
type: 'available_commands';
|
||||||
|
commands: DesktopAvailableCommand[];
|
||||||
|
skills: string[];
|
||||||
|
}
|
||||||
|
| { type: 'message_complete'; stopReason?: string }
|
||||||
|
| { type: 'error'; code: string; message: string; retryable?: boolean };
|
||||||
Loading…
Add table
Add a link
Reference in a new issue