mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-09 17:44:43 -06:00
Squashed commit of the following:
commit 3dea4b1cfcb866d50c9c093b797c0f4f48fec23f Author: wenjie <meetwenjie@gmail.com> Date: Fri Apr 10 17:11:07 2026 +0800 fix(picoclaw): clean temp media and surface MCP screenshots in chat - remove shared load_image staging and pass source paths through directly - delete /tmp/picoclaw_media when a gateway session closes - push MCP screenshot observations to downstream websocket clients - hide null tool feedback messages and auto-scroll when screenshots load commit f58ffe1e27f83f64e2b0e8c2f8dcac46af715ace Author: wenjie <meetwenjie@gmail.com> Date: Fri Apr 10 15:55:59 2026 +0800 Harden PicoClaw local API auth and session locking Split PicoClaw routes by caller trust level and require the internal loopback token for local-only endpoints. Update the NanoKVM bridge script to send the internal token for loopback requests. Relax session lock acquisition for screenshot and action calls so the active session can perform local operations without permanently taking over the lock, and stop forcing the runtime dm_scope default. commit 462a0670bbc5138c893fbd1155e72465b82c2065 Author: wenjie <meetwenjie@gmail.com> Date: Fri Apr 10 13:52:39 2026 +0800 fix(picoclaw): improve KVM reliability and sidebar behavior - follow HTTPS loopback redirects in the NanoKVM bridge script - enable MJPEG frame caching only during active PicoClaw gateway sessions - restore legacy screen zoom behavior across MJPEG and H264 renderers - keep the PicoClaw sidebar available on mobile without splitter layout conflicts - hide empty "null"/"undefined" chat messages and keep MJPEG failures on a black screen commit bfda85c6368825c7ddf47d347ba5ec2588135885 Author: wenjie <meetwenjie@gmail.com> Date: Fri Apr 10 11:37:15 2026 +0800 fix(picoclaw): verify runtime downloads and remove unused config API - verify the downloaded runtime archive against the published SHA-512 checksum - remove the unused /api/picoclaw/config endpoints and related frontend state - rename runtime_control.go to runtime_constants.go for clearer intent commit 10b34aaea59e10d70c3cdecb01915444561ceaf7 Author: wenjie <meetwenjie@gmail.com> Date: Thu Apr 9 16:32:30 2026 +0800 fix: stabilize picoclaw runtime defaults and secure local MCP access - persist required NanoKVM startup defaults before launching picoclaw and after saving model config - force-enable the pico channel when loading config so runtime status can recover to ready - derive the local MCP URL from the configured HTTP port and keep loopback-only HTTP access behind an internal token - move loopback HTTP redirect logic into middleware to simplify main server startup - improve runtime/sidebar state handling and add the load-image endpoint for active picoclaw sessions commit d20540195bf45ea290f727b473ee8cd2a1bbb68d Author: wenjie <meetwenjie@gmail.com> Date: Fri Mar 27 17:55:29 2026 +0800 feat(picoclaw): add picoclaw integration
This commit is contained in:
@@ -31,9 +31,11 @@
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-i18next": "^14.1.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-simple-keyboard": "^3.8.164",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.4",
|
||||
"vaul": "^0.9.9",
|
||||
"websocket": "^1.0.35",
|
||||
|
||||
874
web/pnpm-lock.yaml
generated
874
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
130
web/src/api/picoclaw.ts
Normal file
130
web/src/api/picoclaw.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { http } from '@/lib/http.ts';
|
||||
import {
|
||||
picoclawGateway,
|
||||
type GatewayRunState,
|
||||
type GatewayTransportState
|
||||
} from '@/lib/picoclaw-gateway.ts';
|
||||
|
||||
const sessionIDHeader = 'X-PicoClaw-Session-ID';
|
||||
|
||||
type ModelConfigRequest = {
|
||||
model: string;
|
||||
api_base: string;
|
||||
api_key: string;
|
||||
};
|
||||
|
||||
type AgentProfileRequest = {
|
||||
profile: string;
|
||||
};
|
||||
|
||||
export type PicoclawSessionListItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
preview: string;
|
||||
message_count: number;
|
||||
created: string;
|
||||
updated: string;
|
||||
};
|
||||
|
||||
export type PicoclawSessionDetailMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type PicoclawSessionDetail = {
|
||||
id: string;
|
||||
messages: PicoclawSessionDetailMessage[];
|
||||
summary?: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
};
|
||||
|
||||
export function setPicoclawModelConfig(data: ModelConfigRequest) {
|
||||
return http.post('/api/picoclaw/model/config', data);
|
||||
}
|
||||
|
||||
export function setPicoclawAgentProfile(data: AgentProfileRequest) {
|
||||
return http.post('/api/picoclaw/agent/profile', data);
|
||||
}
|
||||
|
||||
export function listPicoclawSessions(params?: { offset?: number; limit?: number }) {
|
||||
return http.get('/api/picoclaw/sessions', { params });
|
||||
}
|
||||
|
||||
export function getPicoclawSession(id: string) {
|
||||
return http.get(`/api/picoclaw/sessions/${id}`);
|
||||
}
|
||||
|
||||
export function deletePicoclawSession(id: string) {
|
||||
return http.delete(`/api/picoclaw/sessions/${id}`);
|
||||
}
|
||||
|
||||
export function getRuntimeStatus() {
|
||||
return http.get('/api/picoclaw/runtime/status');
|
||||
}
|
||||
|
||||
export function startRuntime() {
|
||||
return http.post('/api/picoclaw/runtime/start');
|
||||
}
|
||||
|
||||
export function stopRuntime() {
|
||||
return http.post('/api/picoclaw/runtime/stop');
|
||||
}
|
||||
|
||||
export function installRuntime() {
|
||||
return http.post('/api/picoclaw/runtime/install');
|
||||
}
|
||||
|
||||
export function uninstallRuntime() {
|
||||
return http.post('/api/picoclaw/runtime/uninstall');
|
||||
}
|
||||
|
||||
export function connectGateway(sessionId?: string) {
|
||||
return picoclawGateway.connect({ sessionId });
|
||||
}
|
||||
|
||||
export function releaseRuntimeSession(sessionId?: string) {
|
||||
const activeSessionId = sessionId || picoclawGateway.getSessionId();
|
||||
if (!activeSessionId) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return http.request({
|
||||
method: 'delete',
|
||||
url: '/api/picoclaw/runtime/session',
|
||||
headers: {
|
||||
[sessionIDHeader]: activeSessionId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function sendChatMessage(
|
||||
content: string,
|
||||
options?: { id?: string; maxSteps?: number; maxRuntimeMs?: number; trackState?: boolean }
|
||||
) {
|
||||
return picoclawGateway.sendChatMessage(content, options);
|
||||
}
|
||||
|
||||
export function sendStopMessage() {
|
||||
return picoclawGateway.sendStopMessage();
|
||||
}
|
||||
|
||||
export function closeGateway() {
|
||||
const activeSessionId = picoclawGateway.getSessionId();
|
||||
picoclawGateway.close();
|
||||
if (!activeSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void releaseRuntimeSession(activeSessionId).catch(() => undefined);
|
||||
}
|
||||
|
||||
export function onGatewayConnectionState(listener: (state: GatewayTransportState) => void) {
|
||||
return picoclawGateway.on('transport_state', listener);
|
||||
}
|
||||
|
||||
export function onGatewayRunState(listener: (state: GatewayRunState) => void) {
|
||||
return picoclawGateway.on('run_state', listener);
|
||||
}
|
||||
|
||||
export { picoclawGateway };
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#737373" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-monitor-x"><path d="m14.5 12.5-5-5"/><path d="m9.5 12.5 5-5"/><rect width="20" height="14" x="2" y="3" rx="2"/><path d="M12 17v4"/><path d="M8 21h8"/></svg>
|
||||
|
Before Width: | Height: | Size: 354 B |
@@ -2,7 +2,8 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #000;
|
||||
@@ -23,7 +24,7 @@ html, body {
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear;
|
||||
animation: spin 1s linear;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@@ -34,3 +35,32 @@ animation: spin 1s linear;
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* PicoClaw Chat Custom Scrollbar */
|
||||
.picoclaw-sidebar-scrollbar *::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
.picoclaw-sidebar-scrollbar *::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.picoclaw-sidebar-scrollbar *::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.picoclaw-sidebar-scrollbar *::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* PicoClaw Chat Loading Animation */
|
||||
@keyframes picoclaw-loading-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0.5px);
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-1.5px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
43
web/src/components/icons/icon-base.tsx
Normal file
43
web/src/components/icons/icon-base.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode, SVGProps } from 'react';
|
||||
|
||||
export type IconProps = Omit<SVGProps<SVGSVGElement>, 'color'> & {
|
||||
size?: number | string;
|
||||
color?: string;
|
||||
strokeWidth?: number | string;
|
||||
};
|
||||
|
||||
type IconBaseProps = IconProps & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const IconBase = ({
|
||||
children,
|
||||
className,
|
||||
size = 24,
|
||||
color = 'currentColor',
|
||||
strokeWidth = 2,
|
||||
viewBox = '0 0 24 24',
|
||||
fill = 'none',
|
||||
strokeLinecap = 'round',
|
||||
strokeLinejoin = 'round',
|
||||
...props
|
||||
}: IconBaseProps) => {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden={props['aria-label'] ? undefined : true}
|
||||
className={className}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={viewBox}
|
||||
fill={fill}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap={strokeLinecap}
|
||||
strokeLinejoin={strokeLinejoin}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
12
web/src/components/icons/layout-sidebar-right-collapse.tsx
Normal file
12
web/src/components/icons/layout-sidebar-right-collapse.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IconBase, type IconProps } from './icon-base.tsx';
|
||||
|
||||
export const LayoutSidebarRightCollapse = (props: IconProps) => {
|
||||
return (
|
||||
<IconBase {...props}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12" />
|
||||
<path d="M15 4v16" />
|
||||
<path d="M9 10l2 2l-2 2" />
|
||||
</IconBase>
|
||||
);
|
||||
};
|
||||
18
web/src/components/icons/robot.tsx
Normal file
18
web/src/components/icons/robot.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IconBase, type IconProps } from './icon-base.tsx';
|
||||
|
||||
export const Robot = (props: IconProps) => {
|
||||
return (
|
||||
<IconBase {...props}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M6 6a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2l0 -4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M9 12v9" />
|
||||
<path d="M15 12v9" />
|
||||
<path d="M5 16l4 -2" />
|
||||
<path d="M15 14l4 2" />
|
||||
<path d="M9 18h6" />
|
||||
<path d="M10 8v.01" />
|
||||
<path d="M14 8v.01" />
|
||||
</IconBase>
|
||||
);
|
||||
};
|
||||
@@ -54,7 +54,7 @@ const en = {
|
||||
authDescription: 'Please enter the AP password to continue',
|
||||
authFailed: 'Invalid AP password',
|
||||
passPlaceholder: 'AP password',
|
||||
verifyBtn: 'Verify',
|
||||
verifyBtn: 'Verify'
|
||||
}
|
||||
},
|
||||
screen: {
|
||||
@@ -449,6 +449,127 @@ const en = {
|
||||
cancelBtn: 'No'
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
title: 'PicoClaw Assistant',
|
||||
empty: 'Open the panel and start a task to begin.',
|
||||
inputPlaceholder: 'Describe what you want the PicoClaw to do',
|
||||
newConversation: 'New conversation',
|
||||
processing: 'Processing...',
|
||||
agent: {
|
||||
defaultTitle: 'General Assistant',
|
||||
defaultDescription: 'General chat, search, and workspace help.',
|
||||
kvmTitle: 'Remote Control',
|
||||
kvmDescription: 'Operate the remote host through NanoKVM.',
|
||||
switched: 'Agent role switched',
|
||||
switchFailed: 'Failed to switch agent role'
|
||||
},
|
||||
send: 'Send',
|
||||
cancel: 'Cancel',
|
||||
status: {
|
||||
connecting: 'Connecting to gateway...',
|
||||
connected: 'PicoClaw session connected',
|
||||
disconnected: 'PicoClaw session closed',
|
||||
stopped: 'Stop request sent',
|
||||
runtimeStarted: 'PicoClaw runtime started',
|
||||
runtimeStartFailed: 'Failed to start PicoClaw runtime',
|
||||
runtimeStopped: 'PicoClaw runtime stopped',
|
||||
runtimeStopFailed: 'Failed to stop PicoClaw runtime'
|
||||
},
|
||||
connection: {
|
||||
runtime: {
|
||||
checking: 'Checking',
|
||||
ready: 'Runtime ready',
|
||||
stopped: 'Runtime stopped',
|
||||
unavailable: 'Runtime unavailable',
|
||||
configError: 'Configuration error'
|
||||
},
|
||||
transport: {
|
||||
connecting: 'Connecting',
|
||||
connected: 'Connected'
|
||||
},
|
||||
run: {
|
||||
idle: 'Idle',
|
||||
busy: 'Busy'
|
||||
}
|
||||
},
|
||||
message: {
|
||||
toolAction: 'Action',
|
||||
observation: 'Observation',
|
||||
screenshot: 'Screenshot'
|
||||
},
|
||||
overlay: {
|
||||
locked: 'PicoClaw is controlling the device. Manual input is paused.'
|
||||
},
|
||||
install: {
|
||||
install: 'Install PicoClaw',
|
||||
installing: 'Installing PicoClaw',
|
||||
success: 'PicoClaw installed successfully',
|
||||
failed: 'Failed to install PicoClaw',
|
||||
uninstalling: 'Uninstalling runtime...',
|
||||
uninstalled: 'Runtime uninstalled successfully.',
|
||||
uninstallFailed: 'Uninstall failed.',
|
||||
requiredTitle: 'PicoClaw is not installed',
|
||||
requiredDescription: 'Install PicoClaw before starting the PicoClaw runtime.',
|
||||
progressDescription: 'PicoClaw is being downloaded and installed.',
|
||||
stages: {
|
||||
preparing: 'Preparing',
|
||||
downloading: 'Downloading',
|
||||
extracting: 'Extracting',
|
||||
installing: 'Installing',
|
||||
installed: 'Installed',
|
||||
install_timeout: 'Timed Out',
|
||||
install_failed: 'Failed'
|
||||
}
|
||||
},
|
||||
model: {
|
||||
requiredTitle: 'Model configuration is required',
|
||||
requiredDescription: 'Configure the PicoClaw model before using PicoClaw chat.',
|
||||
docsTitle: 'Configuration Guide',
|
||||
docsDesc: 'Supported models and protocols',
|
||||
menuLabel: 'Configure model',
|
||||
modelIdentifier: 'Model Identifier',
|
||||
modelIdentifierPlaceholder: 'openai/gpt-5.4',
|
||||
apiBase: 'API Base URL',
|
||||
apiBasePlaceholder: 'https://api.example.com/v1',
|
||||
apiKey: 'API Key',
|
||||
apiKeyPlaceholder: 'Enter the model API key',
|
||||
save: 'Save',
|
||||
saving: 'Saving',
|
||||
saved: 'Model configuration saved',
|
||||
saveFailed: 'Failed to save model configuration',
|
||||
invalid: 'Model identifier, API base URL, and API key are required'
|
||||
},
|
||||
uninstall: {
|
||||
menuLabel: 'Uninstall',
|
||||
confirmTitle: 'Uninstall PicoClaw',
|
||||
confirmContent:
|
||||
'Are you sure you want to uninstall PicoClaw? This will delete the executable and all configuration files.',
|
||||
confirmOk: 'Uninstall',
|
||||
confirmCancel: 'Cancel'
|
||||
},
|
||||
history: {
|
||||
title: 'History',
|
||||
loading: 'Loading sessions...',
|
||||
emptyTitle: 'No history yet',
|
||||
emptyDescription: 'Previous PicoClaw sessions will appear here.',
|
||||
loadFailed: 'Failed to load session history',
|
||||
deleteFailed: 'Failed to delete session',
|
||||
deleteConfirmTitle: 'Delete session',
|
||||
deleteConfirmContent: 'Are you sure you want to delete "{{title}}"?',
|
||||
deleteConfirmOk: 'Delete',
|
||||
deleteConfirmCancel: 'Cancel',
|
||||
messageCount_one: '{{count}} message',
|
||||
messageCount_other: '{{count}} messages'
|
||||
},
|
||||
config: {
|
||||
startRuntime: 'Start PicoClaw',
|
||||
stopRuntime: 'Stop PicoClaw'
|
||||
},
|
||||
start: {
|
||||
title: 'Start PicoClaw',
|
||||
description: 'Start the runtime to begin using the PicoClaw assistant.'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
title: "We've ran into an issue",
|
||||
refresh: 'Refresh'
|
||||
|
||||
@@ -52,7 +52,7 @@ const zh = {
|
||||
authDescription: '请输入 AP 密码以继续',
|
||||
authFailed: '密码错误',
|
||||
passPlaceholder: 'AP 密码',
|
||||
verifyBtn: '验证',
|
||||
verifyBtn: '验证'
|
||||
}
|
||||
},
|
||||
screen: {
|
||||
@@ -414,6 +414,126 @@ const zh = {
|
||||
cancel: '取消'
|
||||
}
|
||||
},
|
||||
picoclaw: {
|
||||
title: 'PicoClaw 助手',
|
||||
empty: '打开面板并发送任务后,PicoClaw 会开始工作。',
|
||||
inputPlaceholder: '描述你希望 PicoClaw 执行的操作',
|
||||
newConversation: '新对话',
|
||||
processing: '正在处理中...',
|
||||
agent: {
|
||||
defaultTitle: '通用助手',
|
||||
defaultDescription: '适合普通问答、搜索和工作区辅助。',
|
||||
kvmTitle: '远程控制',
|
||||
kvmDescription: '通过 NanoKVM 操作下游远程主机。',
|
||||
switched: '角色已切换',
|
||||
switchFailed: '切换角色失败'
|
||||
},
|
||||
send: '发送',
|
||||
cancel: '取消',
|
||||
status: {
|
||||
connecting: '正在连接 Gateway...',
|
||||
connected: 'PicoClaw 会话已连接',
|
||||
disconnected: 'PicoClaw 会话已关闭',
|
||||
stopped: '已发送停止请求',
|
||||
runtimeStarted: 'PicoClaw 运行时已启动',
|
||||
runtimeStartFailed: '启动 PicoClaw 运行时失败',
|
||||
runtimeStopped: 'PicoClaw 运行时已停止',
|
||||
runtimeStopFailed: '停止 PicoClaw 运行时失败'
|
||||
},
|
||||
connection: {
|
||||
runtime: {
|
||||
checking: '检查中',
|
||||
ready: '运行时已就绪',
|
||||
stopped: '运行时未启动',
|
||||
unavailable: '运行时不可用',
|
||||
configError: '配置错误'
|
||||
},
|
||||
transport: {
|
||||
connecting: '连接中',
|
||||
connected: '已连接'
|
||||
},
|
||||
run: {
|
||||
idle: '空闲',
|
||||
busy: '执行中'
|
||||
}
|
||||
},
|
||||
message: {
|
||||
toolAction: '动作',
|
||||
observation: '观察',
|
||||
screenshot: '截图'
|
||||
},
|
||||
overlay: {
|
||||
locked: 'PicoClaw 正在控制设备,手动输入已暂停。'
|
||||
},
|
||||
install: {
|
||||
install: '安装 PicoClaw',
|
||||
installing: '正在安装 PicoClaw',
|
||||
success: 'PicoClaw 安装成功',
|
||||
failed: 'PicoClaw 安装失败',
|
||||
uninstalling: '正在卸载运行时...',
|
||||
uninstalled: '运行时卸载成功。',
|
||||
uninstallFailed: '卸载失败。',
|
||||
requiredTitle: '未安装 PicoClaw',
|
||||
requiredDescription: '在启动 PicoClaw 运行时之前,需要先下载安装 PicoClaw。',
|
||||
progressDescription: '正在下载并安装 PicoClaw。',
|
||||
stages: {
|
||||
preparing: '准备中',
|
||||
downloading: '下载中',
|
||||
extracting: '解压中',
|
||||
installing: '安装中',
|
||||
installed: '已安装',
|
||||
install_timeout: '已超时',
|
||||
install_failed: '失败'
|
||||
}
|
||||
},
|
||||
model: {
|
||||
requiredTitle: '需要配置模型',
|
||||
requiredDescription: '在使用 PicoClaw 聊天之前,请先配置 PicoClaw 模型。',
|
||||
docsTitle: '配置指南',
|
||||
docsDesc: '支持的模型与协议',
|
||||
menuLabel: '配置模型',
|
||||
modelIdentifier: '模型标识',
|
||||
modelIdentifierPlaceholder: 'openai/gpt-5.4',
|
||||
apiBase: 'API Base URL',
|
||||
apiBasePlaceholder: 'https://api.example.com/v1',
|
||||
apiKey: 'API Key',
|
||||
apiKeyPlaceholder: '请输入模型 API Key',
|
||||
save: '保存模型配置',
|
||||
saving: '正在保存模型配置',
|
||||
saved: '模型配置已保存',
|
||||
saveFailed: '保存模型配置失败',
|
||||
invalid: '模型标识、API Base URL 和 API Key 不能为空'
|
||||
},
|
||||
uninstall: {
|
||||
menuLabel: '卸载',
|
||||
confirmTitle: '卸载 PicoClaw',
|
||||
confirmContent: '确定要卸载 PicoClaw 吗?这会删除可执行文件和所有配置文件。',
|
||||
confirmOk: '卸载',
|
||||
confirmCancel: '取消'
|
||||
},
|
||||
history: {
|
||||
title: '历史会话',
|
||||
loading: '正在加载历史会话...',
|
||||
emptyTitle: '还没有历史会话',
|
||||
emptyDescription: '之前的 PicoClaw 会话会显示在这里。',
|
||||
loadFailed: '加载会话历史失败',
|
||||
deleteFailed: '删除会话失败',
|
||||
deleteConfirmTitle: '删除会话',
|
||||
deleteConfirmContent: '确定要删除“{{title}}”吗?',
|
||||
deleteConfirmOk: '删除',
|
||||
deleteConfirmCancel: '取消',
|
||||
messageCount_one: '{{count}} 条消息',
|
||||
messageCount_other: '{{count}} 条消息'
|
||||
},
|
||||
config: {
|
||||
startRuntime: '启动 PicoClaw',
|
||||
stopRuntime: '停止 PicoClaw'
|
||||
},
|
||||
start: {
|
||||
title: '启动 PicoClaw',
|
||||
description: '启动运行时后即可开始使用 PicoClaw 助手。'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
title: '我们遇到了问题',
|
||||
refresh: '刷新'
|
||||
|
||||
94
web/src/jotai/picoclaw.ts
Normal file
94
web/src/jotai/picoclaw.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
import { getPicoclawMaxRuntimeMs, getPicoclawMaxSteps } from '@/lib/picoclaw-storage.ts';
|
||||
|
||||
export type PicoclawTransportState = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
export type PicoclawRunState = 'idle' | 'busy';
|
||||
|
||||
export type PicoclawRuntimeStatus = {
|
||||
ready: boolean;
|
||||
installed: boolean;
|
||||
installing: boolean;
|
||||
install_progress?: number;
|
||||
install_stage?: string;
|
||||
install_path?: string;
|
||||
agent_profile?: string;
|
||||
model_configured: boolean;
|
||||
model_name?: string;
|
||||
status: string;
|
||||
config_error?: string;
|
||||
last_error?: string;
|
||||
checked_at?: string;
|
||||
current_session?: string;
|
||||
};
|
||||
|
||||
export type PicoclawRuntimeStartResult = {
|
||||
started: boolean;
|
||||
command: string;
|
||||
output?: string;
|
||||
status: PicoclawRuntimeStatus;
|
||||
};
|
||||
|
||||
export type PicoclawRuntimeInstallResult = {
|
||||
installed: boolean;
|
||||
binary: string;
|
||||
download: string;
|
||||
output?: string;
|
||||
status: PicoclawRuntimeStatus;
|
||||
};
|
||||
|
||||
export type PicoclawMessageKind =
|
||||
| 'user'
|
||||
| 'assistant'
|
||||
| 'status'
|
||||
| 'tool_action'
|
||||
| 'observation'
|
||||
| 'error';
|
||||
|
||||
export type PicoclawChatMessage = {
|
||||
id: string;
|
||||
kind: PicoclawMessageKind;
|
||||
text?: string;
|
||||
imageBase64?: string;
|
||||
createdAt: number;
|
||||
action?: string;
|
||||
pending?: boolean;
|
||||
raw?: unknown;
|
||||
};
|
||||
|
||||
export type PicoclawOverlayState = {
|
||||
visible: boolean;
|
||||
message: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
action?: string;
|
||||
};
|
||||
|
||||
export type PicoclawTakeoverState = {
|
||||
active: boolean;
|
||||
sessionId?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type PicoclawConfigState = {
|
||||
maxSteps: number;
|
||||
maxRuntimeMs: number;
|
||||
};
|
||||
|
||||
export const picoclawChatOpenAtom = atom(false);
|
||||
export const picoclawMessagesAtom = atom<PicoclawChatMessage[]>([]);
|
||||
export const picoclawTransportStateAtom = atom<PicoclawTransportState>('disconnected');
|
||||
export const picoclawRunStateAtom = atom<PicoclawRunState>('idle');
|
||||
export const picoclawRuntimeStatusAtom = atom<PicoclawRuntimeStatus | null>(null);
|
||||
export const picoclawOverlayAtom = atom<PicoclawOverlayState>({
|
||||
visible: false,
|
||||
message: ''
|
||||
});
|
||||
export const picoclawConfigAtom = atom<PicoclawConfigState>({
|
||||
maxSteps: getPicoclawMaxSteps(),
|
||||
maxRuntimeMs: getPicoclawMaxRuntimeMs()
|
||||
});
|
||||
export const picoclawTakeoverStateAtom = atom<PicoclawTakeoverState>({
|
||||
active: false
|
||||
});
|
||||
471
web/src/lib/picoclaw-gateway.ts
Normal file
471
web/src/lib/picoclaw-gateway.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import { clearPicoclawSessionId, setPicoclawSessionId } from '@/lib/picoclaw-storage.ts';
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
|
||||
export type GatewayTransportState = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
export type GatewayRunState = 'idle' | 'busy';
|
||||
|
||||
export type GatewayRuntimeStatus = {
|
||||
ready: boolean;
|
||||
installed: boolean;
|
||||
installing: boolean;
|
||||
install_progress?: number;
|
||||
install_stage?: string;
|
||||
install_path?: string;
|
||||
model_configured: boolean;
|
||||
model_name?: string;
|
||||
status: string;
|
||||
config_error?: string;
|
||||
last_error?: string;
|
||||
checked_at?: string;
|
||||
current_session?: string;
|
||||
};
|
||||
|
||||
export type GatewayAssistantMessage = {
|
||||
id: string;
|
||||
text: string;
|
||||
raw: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GatewayToolAction = {
|
||||
id: string;
|
||||
action: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
raw: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GatewayObservation = {
|
||||
id: string;
|
||||
text?: string;
|
||||
imageBase64?: string;
|
||||
raw: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GatewayError = {
|
||||
code: string;
|
||||
message: string;
|
||||
raw?: unknown;
|
||||
};
|
||||
|
||||
export type GatewayClose = {
|
||||
code: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type GatewayEventMap = {
|
||||
connected: { sessionId: string };
|
||||
transport_state: GatewayTransportState;
|
||||
run_state: GatewayRunState;
|
||||
assistant_message: GatewayAssistantMessage;
|
||||
tool_action: GatewayToolAction;
|
||||
observation: GatewayObservation;
|
||||
error: GatewayError;
|
||||
close: GatewayClose;
|
||||
};
|
||||
|
||||
type EventName = keyof GatewayEventMap;
|
||||
type Listener<T extends EventName> = (payload: GatewayEventMap[T]) => void;
|
||||
|
||||
type ConnectOptions = {
|
||||
sessionId?: string;
|
||||
autoReconnect?: boolean;
|
||||
};
|
||||
|
||||
type SendMessageOptions = {
|
||||
id?: string;
|
||||
maxSteps?: number;
|
||||
maxRuntimeMs?: number;
|
||||
trackState?: boolean;
|
||||
};
|
||||
|
||||
const CLOSE_ERRORS: Record<number, string> = {
|
||||
4001: 'AI_LOCK_HELD',
|
||||
4002: 'RUNTIME_UNAVAILABLE',
|
||||
4003: 'AUTH_FAILED',
|
||||
4004: 'AI_TAKEN_OVER',
|
||||
4005: 'UPSTREAM_CLOSED'
|
||||
};
|
||||
|
||||
export function generateUUIDv4() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => {
|
||||
const random = Math.floor(Math.random() * 16);
|
||||
const value = char === 'x' ? random : (random & 0x3) | 0x8;
|
||||
return value.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
class PicoClawGateway {
|
||||
private ws: WebSocket | null = null;
|
||||
private listeners = new Map<EventName, Set<Listener<EventName>>>();
|
||||
private connectPromise: Promise<string> | null = null;
|
||||
private sessionId = '';
|
||||
private connectionAttemptId = 0;
|
||||
private autoReconnect = false;
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private explicitClose = false;
|
||||
|
||||
public connect(options: ConnectOptions = {}) {
|
||||
if (options.sessionId) {
|
||||
this.sessionId = options.sessionId;
|
||||
setPicoclawSessionId(options.sessionId);
|
||||
} else if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
|
||||
this.sessionId = generateUUIDv4();
|
||||
setPicoclawSessionId(this.sessionId);
|
||||
}
|
||||
|
||||
if (
|
||||
this.ws &&
|
||||
(this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
return this.connectPromise ?? Promise.resolve(this.sessionId);
|
||||
}
|
||||
|
||||
this.autoReconnect = !!options.autoReconnect;
|
||||
this.explicitClose = false;
|
||||
this.clearReconnectTimer();
|
||||
this.setTransportState('connecting');
|
||||
|
||||
const sessionId = this.sessionId || generateUUIDv4();
|
||||
const attemptId = ++this.connectionAttemptId;
|
||||
this.sessionId = sessionId;
|
||||
setPicoclawSessionId(sessionId);
|
||||
const url = `${getBaseUrl('ws')}/api/picoclaw/gateway/ws?session_id=${encodeURIComponent(sessionId)}`;
|
||||
|
||||
this.connectPromise = new Promise<string>((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (this.ws !== ws || this.connectionAttemptId !== attemptId) {
|
||||
return;
|
||||
}
|
||||
this.reconnectAttempts = 0;
|
||||
this.emit('connected', { sessionId });
|
||||
this.setTransportState('connected');
|
||||
this.setRunState('idle');
|
||||
resolve(sessionId);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (this.ws !== ws || this.connectionAttemptId !== attemptId) {
|
||||
return;
|
||||
}
|
||||
this.setTransportState('error');
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (this.ws !== ws || this.connectionAttemptId !== attemptId) {
|
||||
return;
|
||||
}
|
||||
this.ws = null;
|
||||
|
||||
if (event.code !== 1000) {
|
||||
const code = CLOSE_ERRORS[event.code] || 'UNKNOWN';
|
||||
if (code !== 'UNKNOWN') {
|
||||
this.emit('error', {
|
||||
code,
|
||||
message: event.reason || code
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('close', {
|
||||
code: event.code,
|
||||
reason: event.reason
|
||||
});
|
||||
|
||||
this.setTransportState(event.code === 1000 ? 'disconnected' : 'error');
|
||||
this.setRunState('idle');
|
||||
|
||||
if (!this.explicitClose) {
|
||||
reject(new Error(event.reason || 'gateway closed'));
|
||||
if (this.autoReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (this.ws !== ws || this.connectionAttemptId !== attemptId) {
|
||||
return;
|
||||
}
|
||||
this.handleMessage(event.data);
|
||||
};
|
||||
}).finally(() => {
|
||||
if (this.connectionAttemptId === attemptId) {
|
||||
this.connectPromise = null;
|
||||
}
|
||||
});
|
||||
|
||||
return this.connectPromise;
|
||||
}
|
||||
|
||||
public on<T extends EventName>(eventName: T, listener: Listener<T>) {
|
||||
const current = this.listeners.get(eventName) || new Set();
|
||||
current.add(listener as Listener<EventName>);
|
||||
this.listeners.set(eventName, current);
|
||||
|
||||
return () => {
|
||||
current.delete(listener as Listener<EventName>);
|
||||
if (current.size === 0) {
|
||||
this.listeners.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public sendChatMessage(content: string, options: SendMessageOptions = {}) {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = options.id || generateUUIDv4();
|
||||
if (options.trackState !== false) {
|
||||
this.setRunState('busy');
|
||||
}
|
||||
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
id,
|
||||
session_id: this.sessionId,
|
||||
type: 'message.send',
|
||||
payload: {
|
||||
content: trimmed,
|
||||
max_steps: options.maxSteps,
|
||||
max_runtime_ms: options.maxRuntimeMs
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return { id, sessionId: this.sessionId };
|
||||
}
|
||||
|
||||
public sendStopMessage() {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'message.cancel',
|
||||
session_id: this.sessionId,
|
||||
payload: {}
|
||||
})
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
public close(code = 1000, reason = 'NORMAL_CLOSURE') {
|
||||
this.explicitClose = true;
|
||||
this.clearReconnectTimer();
|
||||
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.close(code, reason);
|
||||
} else if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
||||
this.ws.close();
|
||||
}
|
||||
|
||||
this.ws = null;
|
||||
this.setTransportState('disconnected');
|
||||
this.setRunState('idle');
|
||||
}
|
||||
|
||||
public rotateSession() {
|
||||
this.close();
|
||||
clearPicoclawSessionId();
|
||||
this.sessionId = generateUUIDv4();
|
||||
setPicoclawSessionId(this.sessionId);
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
public getSessionId() {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
this.clearReconnectTimer();
|
||||
this.reconnectAttempts += 1;
|
||||
const delay = Math.min(1000 * this.reconnectAttempts, 5000);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.connect({ sessionId: this.sessionId, autoReconnect: true }).catch(() => undefined);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private clearReconnectTimer() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setTransportState(state: GatewayTransportState) {
|
||||
this.emit('transport_state', state);
|
||||
}
|
||||
|
||||
private setRunState(state: GatewayRunState) {
|
||||
this.emit('run_state', state);
|
||||
}
|
||||
|
||||
private handleMessage(rawData: string | ArrayBuffer | Blob) {
|
||||
if (typeof rawData !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
let message: Record<string, unknown>;
|
||||
try {
|
||||
message = JSON.parse(rawData) as Record<string, unknown>;
|
||||
} catch {
|
||||
this.emit('error', {
|
||||
code: 'INVALID_MESSAGE',
|
||||
message: 'Failed to parse gateway message',
|
||||
raw: rawData
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const type = String(message.type || '');
|
||||
if (type === 'typing.start') {
|
||||
this.setRunState('busy');
|
||||
return;
|
||||
}
|
||||
if (type === 'typing.stop') {
|
||||
return;
|
||||
}
|
||||
if (type === 'pong') {
|
||||
return;
|
||||
}
|
||||
if (type === 'error') {
|
||||
this.setTransportState('error');
|
||||
this.setRunState('idle');
|
||||
this.emit('error', {
|
||||
code: String(message.code || 'ERROR'),
|
||||
message: String(message.message || 'Gateway error'),
|
||||
raw: message
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type === 'message.create' || type === 'message.update') {
|
||||
this.setRunState('idle');
|
||||
this.emit('assistant_message', {
|
||||
id: String(message.id || generateUUIDv4()),
|
||||
text: extractText(message),
|
||||
raw: message
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const imageBase64 = extractImageBase64(message);
|
||||
if (imageBase64) {
|
||||
this.emit('observation', {
|
||||
id: String(message.id || generateUUIDv4()),
|
||||
text: extractText(message),
|
||||
imageBase64,
|
||||
raw: message
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const action = extractAction(message);
|
||||
if (action) {
|
||||
this.emit('tool_action', {
|
||||
id: String(message.id || generateUUIDv4()),
|
||||
action: action.action,
|
||||
x: action.x,
|
||||
y: action.y,
|
||||
raw: message
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private emit<T extends EventName>(eventName: T, payload: GatewayEventMap[T]) {
|
||||
const current = this.listeners.get(eventName);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
current.forEach((listener) => {
|
||||
(listener as Listener<T>)(payload);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function extractText(message: Record<string, unknown>) {
|
||||
const payload = (message.payload || {}) as Record<string, unknown>;
|
||||
const content = payload.content ?? message.content ?? payload.text ?? message.text;
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return normalizeLiteralEmptyText(content);
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return normalizeLiteralEmptyText(item);
|
||||
}
|
||||
if (item && typeof item === 'object' && 'text' in item) {
|
||||
return normalizeLiteralEmptyText(String((item as Record<string, unknown>).text || ''));
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractImageBase64(message: Record<string, unknown>) {
|
||||
const payload = (message.payload || {}) as Record<string, unknown>;
|
||||
const data = (payload.data || message.data || {}) as Record<string, unknown>;
|
||||
|
||||
const value = data.image_base64 || payload.image_base64 || message.image_base64;
|
||||
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function extractAction(message: Record<string, unknown>) {
|
||||
const payload = (message.payload || {}) as Record<string, unknown>;
|
||||
const actionValue = payload.action || message.action || payload.tool_name || message.tool_name;
|
||||
|
||||
if (typeof actionValue !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const action = normalizeLiteralEmptyText(actionValue);
|
||||
if (!action) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const x = typeof payload.x === 'number' ? payload.x : undefined;
|
||||
const y = typeof payload.y === 'number' ? payload.y : undefined;
|
||||
|
||||
return {
|
||||
action,
|
||||
x,
|
||||
y
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLiteralEmptyText(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (normalized === 'null' || normalized === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export const picoclawGateway = new PicoClawGateway();
|
||||
123
web/src/lib/picoclaw-storage.ts
Normal file
123
web/src/lib/picoclaw-storage.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
const PICOCLAW_SESSION_ID_KEY = 'nano-kvm-picoclaw-session-id';
|
||||
const LEGACY_AI_SESSION_ID_KEY = 'nano-kvm-ai-session-id';
|
||||
const PICOCLAW_MAX_STEPS_KEY = 'nano-kvm-picoclaw-max-steps';
|
||||
const LEGACY_AI_MAX_STEPS_KEY = 'nano-kvm-ai-max-steps';
|
||||
const PICOCLAW_MAX_RUNTIME_KEY = 'nano-kvm-picoclaw-max-runtime-ms';
|
||||
const LEGACY_AI_MAX_RUNTIME_KEY = 'nano-kvm-ai-max-runtime-ms';
|
||||
const PICOCLAW_RUNTIME_INSTALL_SNAPSHOT_KEY = 'nano-kvm-picoclaw-runtime-install-snapshot';
|
||||
const LEGACY_AI_RUNTIME_INSTALL_SNAPSHOT_KEY = 'nano-kvm-ai-runtime-install-snapshot';
|
||||
|
||||
export type PicoclawRuntimeInstallSnapshot = {
|
||||
installing: boolean;
|
||||
installProgress?: number;
|
||||
installStage?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
function getSessionStore() {
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalValue(primaryKey: string, legacyKey: string) {
|
||||
return localStorage.getItem(primaryKey) || localStorage.getItem(legacyKey);
|
||||
}
|
||||
|
||||
function setLocalValue(primaryKey: string, legacyKey: string, value: string) {
|
||||
localStorage.setItem(primaryKey, value);
|
||||
localStorage.removeItem(legacyKey);
|
||||
}
|
||||
|
||||
function removeLocalValue(primaryKey: string, legacyKey: string) {
|
||||
localStorage.removeItem(primaryKey);
|
||||
localStorage.removeItem(legacyKey);
|
||||
}
|
||||
|
||||
export function getPicoclawSessionId() {
|
||||
const sessionStore = getSessionStore();
|
||||
return (
|
||||
sessionStore?.getItem(PICOCLAW_SESSION_ID_KEY) ||
|
||||
sessionStore?.getItem(LEGACY_AI_SESSION_ID_KEY) ||
|
||||
getLocalValue(PICOCLAW_SESSION_ID_KEY, LEGACY_AI_SESSION_ID_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
export function setPicoclawSessionId(sessionId: string) {
|
||||
const sessionStore = getSessionStore();
|
||||
sessionStore?.setItem(PICOCLAW_SESSION_ID_KEY, sessionId);
|
||||
sessionStore?.removeItem(LEGACY_AI_SESSION_ID_KEY);
|
||||
setLocalValue(PICOCLAW_SESSION_ID_KEY, LEGACY_AI_SESSION_ID_KEY, sessionId);
|
||||
}
|
||||
|
||||
export function clearPicoclawSessionId() {
|
||||
const sessionStore = getSessionStore();
|
||||
sessionStore?.removeItem(PICOCLAW_SESSION_ID_KEY);
|
||||
sessionStore?.removeItem(LEGACY_AI_SESSION_ID_KEY);
|
||||
removeLocalValue(PICOCLAW_SESSION_ID_KEY, LEGACY_AI_SESSION_ID_KEY);
|
||||
}
|
||||
|
||||
export function getPicoclawMaxSteps() {
|
||||
const value = getLocalValue(PICOCLAW_MAX_STEPS_KEY, LEGACY_AI_MAX_STEPS_KEY);
|
||||
const parsed = value ? Number(value) : 20;
|
||||
return Math.max(1, Math.min(50, Number.isFinite(parsed) ? parsed : 20));
|
||||
}
|
||||
|
||||
export function setPicoclawMaxSteps(maxSteps: number) {
|
||||
const value = Math.max(1, Math.min(50, Math.round(maxSteps)));
|
||||
setLocalValue(PICOCLAW_MAX_STEPS_KEY, LEGACY_AI_MAX_STEPS_KEY, String(value));
|
||||
}
|
||||
|
||||
export function getPicoclawMaxRuntimeMs() {
|
||||
const value = getLocalValue(PICOCLAW_MAX_RUNTIME_KEY, LEGACY_AI_MAX_RUNTIME_KEY);
|
||||
const parsed = value ? Number(value) : 120000;
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 120000;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function setPicoclawMaxRuntimeMs(maxRuntimeMs: number) {
|
||||
const value = Math.max(1000, Math.round(maxRuntimeMs));
|
||||
setLocalValue(PICOCLAW_MAX_RUNTIME_KEY, LEGACY_AI_MAX_RUNTIME_KEY, String(value));
|
||||
}
|
||||
|
||||
export function getPicoclawRuntimeInstallSnapshot(): PicoclawRuntimeInstallSnapshot | null {
|
||||
const value = getLocalValue(
|
||||
PICOCLAW_RUNTIME_INSTALL_SNAPSHOT_KEY,
|
||||
LEGACY_AI_RUNTIME_INSTALL_SNAPSHOT_KEY
|
||||
);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as PicoclawRuntimeInstallSnapshot;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
installing: parsed.installing === true,
|
||||
installProgress:
|
||||
typeof parsed.installProgress === 'number' ? parsed.installProgress : undefined,
|
||||
installStage: typeof parsed.installStage === 'string' ? parsed.installStage : undefined,
|
||||
status: typeof parsed.status === 'string' ? parsed.status : undefined
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setPicoclawRuntimeInstallSnapshot(snapshot: PicoclawRuntimeInstallSnapshot) {
|
||||
setLocalValue(
|
||||
PICOCLAW_RUNTIME_INSTALL_SNAPSHOT_KEY,
|
||||
LEGACY_AI_RUNTIME_INSTALL_SNAPSHOT_KEY,
|
||||
JSON.stringify(snapshot)
|
||||
);
|
||||
}
|
||||
|
||||
export function clearPicoclawRuntimeInstallSnapshot() {
|
||||
removeLocalValue(PICOCLAW_RUNTIME_INSTALL_SNAPSHOT_KEY, LEGACY_AI_RUNTIME_INSTALL_SNAPSHOT_KEY);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Splitter } from 'antd';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { client } from '@/lib/websocket.ts';
|
||||
import { picoclawChatOpenAtom } from '@/jotai/picoclaw.ts';
|
||||
import { resolutionAtom, videoModeAtom } from '@/jotai/screen.ts';
|
||||
import { Head } from '@/components/head.tsx';
|
||||
|
||||
@@ -12,15 +14,19 @@ import { Keyboard } from './keyboard';
|
||||
import { Menu } from './menu';
|
||||
import { Mouse } from './mouse';
|
||||
import { Notification } from './notification.tsx';
|
||||
import { Sidebar as PicoclawSidebar } from './picoclaw';
|
||||
import { ActionOverlay } from './picoclaw/action-overlay.tsx';
|
||||
import { Screen } from './screen';
|
||||
import { VirtualKeyboard } from './virtual-keyboard';
|
||||
|
||||
export const Desktop = () => {
|
||||
const { t } = useTranslation();
|
||||
const isBigScreen = useMediaQuery({ minWidth: 850 });
|
||||
const [picoclawSidebarWidth, setPicoclawSidebarWidth] = useState(420);
|
||||
|
||||
const [videoMode, setVideoMode] = useAtom(videoModeAtom);
|
||||
const [resolution, setResolution] = useAtom(resolutionAtom);
|
||||
const isPicoclawChatOpen = useAtomValue(picoclawChatOpenAtom);
|
||||
|
||||
useEffect(() => {
|
||||
client.connect();
|
||||
@@ -50,22 +56,56 @@ export const Desktop = () => {
|
||||
return defaultVideoMode;
|
||||
}
|
||||
|
||||
function handleSplitterResize(sizes: number[]) {
|
||||
const nextSidebarWidth = sizes[1];
|
||||
if (typeof nextSidebarWidth === 'number' && nextSidebarWidth > 0) {
|
||||
setPicoclawSidebarWidth(nextSidebarWidth);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-screen w-screen overflow-hidden bg-neutral-950">
|
||||
<Head title={t('head.desktop')} />
|
||||
|
||||
{isBigScreen && <Notification />}
|
||||
|
||||
{videoMode && resolution && (
|
||||
<>
|
||||
<div className="relative flex h-full min-h-0 w-full min-w-0">
|
||||
<Menu />
|
||||
<Screen />
|
||||
<div className="h-full min-h-0 w-full min-w-0">
|
||||
<Splitter
|
||||
className="h-full w-full"
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
onResize={handleSplitterResize}
|
||||
>
|
||||
<Splitter.Panel min="45%">
|
||||
<div className="relative h-full min-h-0 w-full min-w-0 overflow-hidden bg-black">
|
||||
<Screen />
|
||||
</div>
|
||||
</Splitter.Panel>
|
||||
<Splitter.Panel
|
||||
size={isBigScreen && isPicoclawChatOpen ? picoclawSidebarWidth : 0}
|
||||
min={isBigScreen && isPicoclawChatOpen ? 340 : 0}
|
||||
max="45%"
|
||||
resizable={isBigScreen && isPicoclawChatOpen}
|
||||
>
|
||||
{isBigScreen && isPicoclawChatOpen ? <PicoclawSidebar /> : null}
|
||||
</Splitter.Panel>
|
||||
</Splitter>
|
||||
</div>
|
||||
<ActionOverlay />
|
||||
<Mouse />
|
||||
<Keyboard />
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isBigScreen && isPicoclawChatOpen ? (
|
||||
<div className="fixed inset-x-0 bottom-0 top-14 z-[980] overflow-hidden bg-[#0d0d0f] shadow-2xl">
|
||||
<PicoclawSidebar />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<VirtualKeyboard />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { KeyboardReport } from '@/lib/keyboard.ts';
|
||||
import { isModifier } from '@/lib/keymap';
|
||||
import { client, MessageEvent } from '@/lib/websocket.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
import { picoclawTakeoverStateAtom } from '@/jotai/picoclaw.ts';
|
||||
|
||||
import { Recorder } from './recorder.tsx';
|
||||
import { useAltGr } from './useAltGr.ts';
|
||||
@@ -16,6 +17,7 @@ export const Keyboard = () => {
|
||||
const os = getOperatingSystem();
|
||||
|
||||
const isKeyboardEnabled = useAtomValue(isKeyboardEnableAtom);
|
||||
const picoclawTakeoverState = useAtomValue(picoclawTakeoverStateAtom);
|
||||
|
||||
const keyboardRef = useRef(new KeyboardReport());
|
||||
const pressedKeys = useRef(new Set<string>());
|
||||
@@ -38,6 +40,17 @@ export const Keyboard = () => {
|
||||
const altGrRef = useRef(altGr);
|
||||
altGrRef.current = altGr;
|
||||
|
||||
useEffect(() => {
|
||||
if (!picoclawTakeoverState.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
pressedKeys.current.clear();
|
||||
leaderKeyRef.current.reset();
|
||||
altGrRef.current.reset();
|
||||
sendReport(keyboardRef.current.reset());
|
||||
}, [picoclawTakeoverState.active]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Image } from './image';
|
||||
import { Keyboard } from './keyboard';
|
||||
import { Mouse } from './mouse';
|
||||
import { Collapse, Expand } from './operations';
|
||||
import { Picoclaw } from './picoclaw';
|
||||
import { Power } from './power';
|
||||
import { Screen } from './screen';
|
||||
import { Script } from './script';
|
||||
@@ -101,6 +102,13 @@ export const Menu = () => {
|
||||
<Divider type="vertical" />
|
||||
)}
|
||||
|
||||
{isEnabled('picoclaw') && (
|
||||
<>
|
||||
<Picoclaw />
|
||||
<Divider type="vertical" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEnabled('power') && (
|
||||
<>
|
||||
<Power />
|
||||
|
||||
22
web/src/pages/desktop/menu/picoclaw/index.tsx
Normal file
22
web/src/pages/desktop/menu/picoclaw/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { picoclawChatOpenAtom } from '@/jotai/picoclaw.ts';
|
||||
import { Robot } from '@/components/icons/robot.tsx';
|
||||
|
||||
export const Picoclaw = () => {
|
||||
const { t } = useTranslation();
|
||||
const setIsChatOpen = useSetAtom(picoclawChatOpenAtom);
|
||||
|
||||
return (
|
||||
<Tooltip title={t('picoclaw.title')} mouseEnterDelay={0.6} placement="bottom">
|
||||
<div
|
||||
className="flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded text-neutral-300 hover:bg-neutral-700/80 hover:text-white"
|
||||
onClick={() => setIsChatOpen((open) => !open)}
|
||||
>
|
||||
<Robot size={18} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as ls from '@/lib/localstorage.ts';
|
||||
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
|
||||
import { Robot } from '@/components/icons/robot.tsx';
|
||||
|
||||
export const MenuIcons = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,6 +27,7 @@ export const MenuIcons = () => {
|
||||
{ key: 'script', icon: <FileJsonIcon size={16} /> },
|
||||
{ key: 'terminal', icon: <TerminalSquareIcon size={16} /> },
|
||||
{ key: 'wol', icon: <NetworkIcon size={16} /> },
|
||||
{ key: 'picoclaw', icon: <Robot size={16} /> },
|
||||
{ key: 'power', icon: <PowerIcon size={16} /> },
|
||||
{ key: 'fullscreen', icon: <MaximizeIcon size={16} />, label: 'fullscreen.toggle' },
|
||||
{ key: 'collapse', icon: <XIcon size={16} />, label: 'menu.collapse' }
|
||||
|
||||
@@ -43,21 +43,22 @@ export const Absolute = () => {
|
||||
const VELOCITY_THRESHOLD = 0.3;
|
||||
|
||||
useEffect(() => {
|
||||
const screen = document.getElementById('screen') as HTMLVideoElement;
|
||||
const screen = document.getElementById('screen') as HTMLVideoElement | null;
|
||||
if (!screen) return;
|
||||
const target = screen;
|
||||
|
||||
screen.addEventListener('mousedown', handleMouseDown);
|
||||
screen.addEventListener('mouseup', handleMouseUp);
|
||||
screen.addEventListener('mousemove', handleMouseMove);
|
||||
screen.addEventListener('wheel', handleWheel);
|
||||
screen.addEventListener('click', disableEvent);
|
||||
screen.addEventListener('contextmenu', disableEvent);
|
||||
target.addEventListener('mousedown', handleMouseDown);
|
||||
target.addEventListener('mouseup', handleMouseUp);
|
||||
target.addEventListener('mousemove', handleMouseMove);
|
||||
target.addEventListener('wheel', handleWheel);
|
||||
target.addEventListener('click', disableEvent);
|
||||
target.addEventListener('contextmenu', disableEvent);
|
||||
|
||||
if (isBigScreen) {
|
||||
screen.addEventListener('touchstart', handleTouchStart);
|
||||
screen.addEventListener('touchmove', handleTouchMove);
|
||||
screen.addEventListener('touchend', handleTouchEnd);
|
||||
screen.addEventListener('touchcancel', handleTouchCancel);
|
||||
target.addEventListener('touchstart', handleTouchStart);
|
||||
target.addEventListener('touchmove', handleTouchMove);
|
||||
target.addEventListener('touchend', handleTouchEnd);
|
||||
target.addEventListener('touchcancel', handleTouchCancel);
|
||||
}
|
||||
|
||||
// Mouse down event
|
||||
@@ -257,15 +258,16 @@ export const Absolute = () => {
|
||||
}
|
||||
|
||||
function getCorrectedCoords(clientX: number, clientY: number) {
|
||||
const rect = screen.getBoundingClientRect();
|
||||
const rect = target.getBoundingClientRect();
|
||||
|
||||
if (!screen.videoWidth || !screen.videoHeight) {
|
||||
const mediaSize = getMediaSize(target);
|
||||
if (!mediaSize) {
|
||||
const x = (clientX - rect.left) / rect.width;
|
||||
const y = (clientY - rect.top) / rect.height;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
const videoRatio = screen.videoWidth / screen.videoHeight;
|
||||
const mediaRatio = mediaSize.width / mediaSize.height;
|
||||
const elementRatio = rect.width / rect.height;
|
||||
|
||||
let renderedWidth = rect.width;
|
||||
@@ -273,11 +275,11 @@ export const Absolute = () => {
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
if (videoRatio > elementRatio) {
|
||||
renderedHeight = rect.width / videoRatio;
|
||||
if (mediaRatio > elementRatio) {
|
||||
renderedHeight = rect.width / mediaRatio;
|
||||
offsetY = (rect.height - renderedHeight) / 2;
|
||||
} else {
|
||||
renderedWidth = rect.height * videoRatio;
|
||||
renderedWidth = rect.height * mediaRatio;
|
||||
offsetX = (rect.width - renderedWidth) / 2;
|
||||
}
|
||||
|
||||
@@ -288,16 +290,16 @@ export const Absolute = () => {
|
||||
}
|
||||
|
||||
return () => {
|
||||
screen.removeEventListener('mousemove', handleMouseMove);
|
||||
screen.removeEventListener('mousedown', handleMouseDown);
|
||||
screen.removeEventListener('mouseup', handleMouseUp);
|
||||
screen.removeEventListener('wheel', handleWheel);
|
||||
screen.removeEventListener('click', disableEvent);
|
||||
screen.removeEventListener('contextmenu', disableEvent);
|
||||
screen.removeEventListener('touchstart', handleTouchStart);
|
||||
screen.removeEventListener('touchmove', handleTouchMove);
|
||||
screen.removeEventListener('touchend', handleTouchEnd);
|
||||
screen.removeEventListener('touchcancel', handleTouchCancel);
|
||||
target.removeEventListener('mousemove', handleMouseMove);
|
||||
target.removeEventListener('mousedown', handleMouseDown);
|
||||
target.removeEventListener('mouseup', handleMouseUp);
|
||||
target.removeEventListener('wheel', handleWheel);
|
||||
target.removeEventListener('click', disableEvent);
|
||||
target.removeEventListener('contextmenu', disableEvent);
|
||||
target.removeEventListener('touchstart', handleTouchStart);
|
||||
target.removeEventListener('touchmove', handleTouchMove);
|
||||
target.removeEventListener('touchend', handleTouchEnd);
|
||||
target.removeEventListener('touchcancel', handleTouchCancel);
|
||||
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
@@ -347,3 +349,19 @@ export const Absolute = () => {
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
function getMediaSize(screen: Element) {
|
||||
if (screen instanceof HTMLVideoElement && screen.videoWidth > 0 && screen.videoHeight > 0) {
|
||||
return { width: screen.videoWidth, height: screen.videoHeight };
|
||||
}
|
||||
|
||||
if (screen instanceof HTMLImageElement && screen.naturalWidth > 0 && screen.naturalHeight > 0) {
|
||||
return { width: screen.naturalWidth, height: screen.naturalHeight };
|
||||
}
|
||||
|
||||
if (screen instanceof HTMLCanvasElement && screen.width > 0 && screen.height > 0) {
|
||||
return { width: screen.width, height: screen.height };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
68
web/src/pages/desktop/picoclaw/action-overlay.tsx
Normal file
68
web/src/pages/desktop/picoclaw/action-overlay.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { picoclawOverlayAtom, picoclawTakeoverStateAtom } from '@/jotai/picoclaw.ts';
|
||||
|
||||
type ScreenRect = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export const ActionOverlay = () => {
|
||||
const { t } = useTranslation();
|
||||
const overlay = useAtomValue(picoclawOverlayAtom);
|
||||
const takeover = useAtomValue(picoclawTakeoverStateAtom);
|
||||
const [rect, setRect] = useState<ScreenRect | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const screen = document.getElementById('screen');
|
||||
if (!screen) {
|
||||
setRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateRect = () => {
|
||||
const bounds = screen.getBoundingClientRect();
|
||||
setRect({
|
||||
left: bounds.left,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
height: bounds.height
|
||||
});
|
||||
};
|
||||
|
||||
updateRect();
|
||||
|
||||
const observer = new ResizeObserver(updateRect);
|
||||
observer.observe(screen);
|
||||
window.addEventListener('resize', updateRect);
|
||||
window.addEventListener('scroll', updateRect, true);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', updateRect);
|
||||
window.removeEventListener('scroll', updateRect, true);
|
||||
};
|
||||
}, [takeover.active]);
|
||||
|
||||
if (!takeover.active || !rect || rect.width <= 0 || rect.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[950]"
|
||||
style={{
|
||||
left: rect.left + 12,
|
||||
top: rect.top + 12
|
||||
}}
|
||||
>
|
||||
<div className="rounded-full bg-neutral-950/85 px-3 py-1 text-xs text-sky-100 shadow-lg ring-1 ring-sky-400/30">
|
||||
{overlay.message || t('picoclaw.overlay.locked')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
web/src/pages/desktop/picoclaw/index.ts
Normal file
1
web/src/pages/desktop/picoclaw/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Sidebar } from './sidebar.tsx';
|
||||
84
web/src/pages/desktop/picoclaw/markdown-content.tsx
Normal file
84
web/src/pages/desktop/picoclaw/markdown-content.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
type MarkdownContentProps = {
|
||||
content?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const MarkdownContent = ({ content, className }: MarkdownContentProps) => {
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => (
|
||||
<p className="my-2 whitespace-pre-wrap break-words leading-6 first:mt-0 last:mb-0">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
className="text-sky-300 underline decoration-sky-400/40 underline-offset-2 hover:text-sky-200"
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => <ul className="my-2 list-disc pl-5">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="my-2 list-decimal pl-5">{children}</ol>,
|
||||
li: ({ children }) => <li className="my-1 break-words leading-6">{children}</li>,
|
||||
h1: ({ children }) => (
|
||||
<h1 className="mt-3 text-base font-semibold first:mt-0">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="mt-3 text-sm font-semibold first:mt-0">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => <h3 className="mt-2 text-sm font-medium first:mt-0">{children}</h3>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="my-2 border-l-2 border-white/15 pl-3 text-neutral-300">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
code: ({ children, className: codeClassName, ...props }: any) =>
|
||||
!String(codeClassName || '').includes('language-') ? (
|
||||
<code
|
||||
className="rounded bg-black/25 px-1.5 py-0.5 font-mono text-[0.92em]"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className={codeClassName} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="my-2 overflow-x-auto rounded-xl bg-black/30 px-3 py-2 text-xs leading-6 text-neutral-100">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="my-2 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left text-sm">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border-b border-white/10 px-2 py-1 font-semibold">{children}</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border-b border-white/5 px-2 py-1 align-top">{children}</td>
|
||||
)
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
92
web/src/pages/desktop/picoclaw/message-input.tsx
Normal file
92
web/src/pages/desktop/picoclaw/message-input.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { PlusIcon, SendIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
import type { PicoclawTransportState } from '@/jotai/picoclaw.ts';
|
||||
|
||||
type MessageInputProps = {
|
||||
transportState: PicoclawTransportState;
|
||||
onSend: (content: string) => void | Promise<void>;
|
||||
onNewConversation: () => void | Promise<void>;
|
||||
disableNewConversation?: boolean;
|
||||
};
|
||||
|
||||
export const MessageInput = ({
|
||||
transportState,
|
||||
onSend,
|
||||
onNewConversation,
|
||||
disableNewConversation
|
||||
}: MessageInputProps) => {
|
||||
const { t } = useTranslation();
|
||||
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
const isComposingRef = useRef(false);
|
||||
|
||||
const isReady = transportState === 'connected';
|
||||
|
||||
useEffect(() => {
|
||||
setIsKeyboardEnable(false);
|
||||
return () => {
|
||||
setIsKeyboardEnable(true);
|
||||
};
|
||||
}, [setIsKeyboardEnable]);
|
||||
|
||||
async function submit() {
|
||||
const content = value.trim();
|
||||
if (!content || !isReady) return;
|
||||
await onSend(content);
|
||||
setValue('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
rows={3}
|
||||
value={value}
|
||||
disabled={!isReady}
|
||||
placeholder={isReady ? t('picoclaw.inputPlaceholder') : '...'}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onCompositionStart={() => {
|
||||
isComposingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
isComposingRef.current = false;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
const nativeEvent = e.nativeEvent as KeyboardEvent & {
|
||||
isComposing?: boolean;
|
||||
keyCode?: number;
|
||||
};
|
||||
if (isComposingRef.current || nativeEvent.isComposing || nativeEvent.keyCode === 229) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
className="w-full resize-none rounded-2xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 pr-12 text-sm text-neutral-100 placeholder-neutral-600 outline-none transition-colors focus:border-white/[0.15] focus:bg-white/[0.06] disabled:opacity-40"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusIcon size={14} />}
|
||||
onClick={() => void onNewConversation()}
|
||||
disabled={disableNewConversation}
|
||||
className="absolute bottom-2.5 right-11 !flex !h-7 !w-7 !items-center !justify-center !rounded-lg !border !border-white/[0.08]"
|
||||
title={t('picoclaw.newConversation')}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendIcon size={14} />}
|
||||
onClick={() => void submit()}
|
||||
disabled={!isReady || !value.trim()}
|
||||
className="absolute bottom-2.5 right-2.5 !flex !h-7 !w-7 !items-center !justify-center !rounded-lg"
|
||||
title={t('picoclaw.send')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
91
web/src/pages/desktop/picoclaw/message-list.tsx
Normal file
91
web/src/pages/desktop/picoclaw/message-list.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { BotIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { PicoclawChatMessage, PicoclawRunState } from '@/jotai/picoclaw.ts';
|
||||
|
||||
import { MessageLoading } from './message-loading.tsx';
|
||||
import { Message } from './message.tsx';
|
||||
|
||||
type MessageListProps = {
|
||||
messages: PicoclawChatMessage[];
|
||||
runState: PicoclawRunState;
|
||||
};
|
||||
|
||||
export const MessageList = ({ messages, runState }: MessageListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isLoading = runState === 'busy';
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const shouldAutoScrollRef = useRef(true);
|
||||
|
||||
function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
|
||||
const container = containerRef.current;
|
||||
if (!container || !shouldAutoScrollRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const content = contentRef.current;
|
||||
if (!content || typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
scrollToBottom('auto');
|
||||
});
|
||||
observer.observe(content);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleScroll() {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
shouldAutoScrollRef.current = distanceFromBottom <= 24;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="min-h-0 flex-1 overflow-y-auto px-4 py-4"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div ref={contentRef} className={messages.length === 0 ? 'min-h-full' : 'space-y-3'}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl border border-white/[0.07] bg-white/[0.04]">
|
||||
<BotIcon className="text-neutral-600" size={18} />
|
||||
</div>
|
||||
<span className="text-xs text-neutral-600">{t('picoclaw.empty')}</span>
|
||||
{isLoading && <MessageLoading text={t('picoclaw.processing')} />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<Message key={message.id} message={message} />
|
||||
))}
|
||||
{isLoading && <MessageLoading text={t('picoclaw.processing')} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
25
web/src/pages/desktop/picoclaw/message-loading.tsx
Normal file
25
web/src/pages/desktop/picoclaw/message-loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { SparklesIcon } from 'lucide-react';
|
||||
|
||||
type MessageLoadingProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const MessageLoading = ({ text }: MessageLoadingProps) => (
|
||||
<div className="flex justify-start">
|
||||
<div className="flex items-center gap-2.5 rounded-xl border border-white/[0.06] bg-white/[0.03] px-3 py-2 text-[13px] text-neutral-300 shadow-sm backdrop-blur-sm">
|
||||
<SparklesIcon size={14} className="animate-pulse text-sky-400/80" />
|
||||
<span className="animate-pulse font-medium tracking-wide">{text}</span>
|
||||
<div className="ml-0.5 flex items-center gap-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="h-1 w-1 rounded-full bg-sky-400/60"
|
||||
style={{
|
||||
animation: `picoclaw-loading-bounce 1.4s ease-in-out ${i * 0.16}s infinite`
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
97
web/src/pages/desktop/picoclaw/message-utils.ts
Normal file
97
web/src/pages/desktop/picoclaw/message-utils.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type {
|
||||
GatewayAssistantMessage,
|
||||
GatewayError,
|
||||
GatewayObservation,
|
||||
GatewayToolAction
|
||||
} from '@/lib/picoclaw-gateway.ts';
|
||||
import { generateUUIDv4 } from '@/lib/picoclaw-gateway.ts';
|
||||
import type { PicoclawChatMessage } from '@/jotai/picoclaw.ts';
|
||||
|
||||
export const HIDDEN_OVERLAY = {
|
||||
visible: false,
|
||||
message: ''
|
||||
} as const;
|
||||
|
||||
export function createStatusMessage(text: string): PicoclawChatMessage {
|
||||
return {
|
||||
id: generateUUIDv4(),
|
||||
kind: 'status',
|
||||
text,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
export function createErrorMessage(error: GatewayError): PicoclawChatMessage {
|
||||
return {
|
||||
id: generateUUIDv4(),
|
||||
kind: 'error',
|
||||
text: error.message,
|
||||
createdAt: Date.now(),
|
||||
raw: error.raw
|
||||
};
|
||||
}
|
||||
|
||||
export function createAssistantMessage(message: GatewayAssistantMessage): PicoclawChatMessage {
|
||||
return {
|
||||
id: message.id,
|
||||
kind: 'assistant',
|
||||
text: message.text,
|
||||
createdAt: Date.now(),
|
||||
raw: message.raw
|
||||
};
|
||||
}
|
||||
|
||||
export function createToolActionMessage(action: GatewayToolAction): PicoclawChatMessage {
|
||||
return {
|
||||
id: action.id,
|
||||
kind: 'tool_action',
|
||||
text: action.action,
|
||||
action: action.action,
|
||||
createdAt: Date.now(),
|
||||
raw: action.raw
|
||||
};
|
||||
}
|
||||
|
||||
export function createObservationMessage(observation: GatewayObservation): PicoclawChatMessage {
|
||||
return {
|
||||
id: observation.id,
|
||||
kind: 'observation',
|
||||
text: observation.text,
|
||||
imageBase64: observation.imageBase64,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
export function retainLatestObservationScreenshot(
|
||||
messages: PicoclawChatMessage[]
|
||||
): PicoclawChatMessage[] {
|
||||
let latestObservationIndex = -1;
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.kind === 'observation' && message.imageBase64) {
|
||||
latestObservationIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (latestObservationIndex < 0) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages.map((message, index) => {
|
||||
if (
|
||||
message.kind !== 'observation' ||
|
||||
!message.imageBase64 ||
|
||||
index === latestObservationIndex
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
imageBase64: undefined,
|
||||
raw: undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
179
web/src/pages/desktop/picoclaw/message.tsx
Normal file
179
web/src/pages/desktop/picoclaw/message.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import clsx from 'clsx';
|
||||
import { ImageIcon, SparklesIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { PicoclawChatMessage } from '@/jotai/picoclaw.ts';
|
||||
|
||||
import { MarkdownContent } from './markdown-content.tsx';
|
||||
|
||||
type MessageProps = {
|
||||
message: PicoclawChatMessage;
|
||||
};
|
||||
|
||||
const hiddenAssistantMessagePrefix = '🔧 `message`';
|
||||
const nullToolFeedbackPattern = /^🔧\s*`[^`]+`\s*```(?:[\w-]+)?\s*null\s*```$/s;
|
||||
|
||||
function extractDisplayText(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = normalizeLiteralEmptyText(value);
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(normalized) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const record = parsed as Record<string, unknown>;
|
||||
if (typeof record.content === 'string') {
|
||||
const content = normalizeLiteralEmptyText(record.content);
|
||||
if (content) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
if (typeof record.text === 'string') {
|
||||
const text = normalizeLiteralEmptyText(record.text);
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.content === 'string') {
|
||||
const content = normalizeLiteralEmptyText(record.content);
|
||||
if (content) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
if (typeof record.text === 'string') {
|
||||
const text = normalizeLiteralEmptyText(record.text);
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeLiteralEmptyText(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (normalized === 'null' || normalized === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function shouldHideAssistantMessage(value?: string): boolean {
|
||||
const normalized = normalizeLiteralEmptyText(value || '');
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
normalized.startsWith(hiddenAssistantMessagePrefix) || nullToolFeedbackPattern.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
export const Message = ({ message }: MessageProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (message.kind === 'status') {
|
||||
return <div className="py-1 text-center text-xs text-neutral-500">{message.text}</div>;
|
||||
}
|
||||
|
||||
if (message.kind === 'error') {
|
||||
if (message.text === 'unexpected EOF') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
<MarkdownContent content={message.text} className="break-words text-red-200/90" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.kind === 'tool_action') {
|
||||
const toolText =
|
||||
extractDisplayText(message.text) ||
|
||||
extractDisplayText(message.action) ||
|
||||
extractDisplayText(message.raw);
|
||||
|
||||
if (!toolText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-white/8 rounded-xl border bg-white/[0.03] px-3 py-2 text-xs text-neutral-300">
|
||||
<div className="mb-1 flex items-center gap-2 text-[11px] uppercase tracking-wide text-neutral-500">
|
||||
<SparklesIcon size={14} />
|
||||
<span>{t('picoclaw.message.toolAction')}</span>
|
||||
</div>
|
||||
<MarkdownContent content={toolText} className="break-words" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.kind === 'observation') {
|
||||
const observationText = extractDisplayText(message.text);
|
||||
if (!observationText && !message.imageBase64) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-sky-400/20 bg-sky-400/10 px-3 py-2 text-sm text-sky-50">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs uppercase tracking-wide text-sky-200">
|
||||
<ImageIcon size={14} />
|
||||
<span>{t('picoclaw.message.observation')}</span>
|
||||
</div>
|
||||
{observationText && (
|
||||
<MarkdownContent content={observationText} className="mb-2 break-words" />
|
||||
)}
|
||||
{message.imageBase64 && (
|
||||
<img
|
||||
alt={t('picoclaw.message.screenshot')}
|
||||
className="max-h-44 w-full rounded-md object-cover"
|
||||
src={`data:image/jpeg;base64,${message.imageBase64}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.kind === 'assistant' && shouldHideAssistantMessage(message.text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isUser = message.kind === 'user';
|
||||
const messageText = isUser ? message.text : extractDisplayText(message.text);
|
||||
|
||||
if (!messageText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={clsx(
|
||||
'max-w-[85%] rounded-2xl px-3 py-2 text-sm shadow-sm',
|
||||
isUser ? 'bg-sky-500 text-white' : 'bg-neutral-800 text-neutral-100'
|
||||
)}
|
||||
>
|
||||
<MarkdownContent content={messageText} className="break-words" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
130
web/src/pages/desktop/picoclaw/runtime-view.ts
Normal file
130
web/src/pages/desktop/picoclaw/runtime-view.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import type {
|
||||
PicoclawRunState,
|
||||
PicoclawRuntimeStatus,
|
||||
PicoclawTransportState
|
||||
} from '@/jotai/picoclaw.ts';
|
||||
|
||||
export type PicoclawSidebarMode = 'loading' | 'install' | 'model' | 'chat';
|
||||
|
||||
export function isPicoclawRuntimeInstalling(
|
||||
runtimeStatus: PicoclawRuntimeStatus | null | undefined
|
||||
) {
|
||||
return runtimeStatus?.installing === true || runtimeStatus?.status === 'installing';
|
||||
}
|
||||
|
||||
export function getPicoclawSidebarMode(
|
||||
runtimeStatus: PicoclawRuntimeStatus | null,
|
||||
isInitializing: boolean
|
||||
): PicoclawSidebarMode {
|
||||
if (isInitializing || !runtimeStatus) {
|
||||
return 'loading';
|
||||
}
|
||||
if (runtimeStatus?.installed === false) {
|
||||
return 'install';
|
||||
}
|
||||
if (runtimeStatus?.model_configured === false) {
|
||||
return 'model';
|
||||
}
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
export function canConnectGateway(runtimeStatus: PicoclawRuntimeStatus | null) {
|
||||
if (!runtimeStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPicoclawRuntimeInstalling(runtimeStatus)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (runtimeStatus.installed === false || runtimeStatus.model_configured === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return runtimeStatus.ready === true;
|
||||
}
|
||||
|
||||
export function getPicoclawSidebarStatusColor(
|
||||
runtimeStatus: PicoclawRuntimeStatus | null,
|
||||
transportState: PicoclawTransportState,
|
||||
runState: PicoclawRunState,
|
||||
isInitializing: boolean
|
||||
) {
|
||||
if (isInitializing || !runtimeStatus) {
|
||||
return '#38bdf8';
|
||||
}
|
||||
|
||||
if (isPicoclawRuntimeInstalling(runtimeStatus) || transportState === 'connecting') {
|
||||
return '#38bdf8';
|
||||
}
|
||||
|
||||
if (runtimeStatus.installed === false || runtimeStatus.model_configured === false) {
|
||||
return '#f59e0b';
|
||||
}
|
||||
|
||||
if (runtimeStatus.ready !== true || transportState === 'error') {
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
if (transportState === 'connected' && runState === 'busy') {
|
||||
return '#38bdf8';
|
||||
}
|
||||
|
||||
return '#22c55e';
|
||||
}
|
||||
|
||||
export function getPicoclawSidebarConnectionLabel(
|
||||
runtimeStatus: PicoclawRuntimeStatus | null,
|
||||
transportState: PicoclawTransportState,
|
||||
runState: PicoclawRunState,
|
||||
isInitializing: boolean,
|
||||
t: TFunction
|
||||
) {
|
||||
if (isInitializing || !runtimeStatus) {
|
||||
return t('picoclaw.connection.runtime.checking');
|
||||
}
|
||||
|
||||
if (isPicoclawRuntimeInstalling(runtimeStatus)) {
|
||||
return t('picoclaw.install.installing');
|
||||
}
|
||||
|
||||
if (runtimeStatus.installed === false) {
|
||||
return t('picoclaw.install.requiredTitle');
|
||||
}
|
||||
|
||||
if (runtimeStatus.model_configured === false) {
|
||||
return t('picoclaw.model.requiredTitle');
|
||||
}
|
||||
|
||||
if (runtimeStatus.ready !== true) {
|
||||
switch (runtimeStatus.status) {
|
||||
case 'checking':
|
||||
return t('picoclaw.connection.runtime.checking');
|
||||
case 'config_error':
|
||||
return t('picoclaw.connection.runtime.configError');
|
||||
case 'unavailable':
|
||||
return t('picoclaw.connection.runtime.unavailable');
|
||||
case 'stopped':
|
||||
case 'installed':
|
||||
case 'ready':
|
||||
default:
|
||||
return t('picoclaw.connection.runtime.stopped');
|
||||
}
|
||||
}
|
||||
|
||||
if (transportState === 'connected') {
|
||||
return `${t('picoclaw.connection.transport.connected')} · ${t(`picoclaw.connection.run.${runState}`)}`;
|
||||
}
|
||||
|
||||
if (transportState === 'connecting') {
|
||||
return t('picoclaw.connection.transport.connecting');
|
||||
}
|
||||
|
||||
if (transportState === 'error') {
|
||||
return t('picoclaw.connection.runtime.unavailable');
|
||||
}
|
||||
|
||||
return t('picoclaw.connection.runtime.ready');
|
||||
}
|
||||
456
web/src/pages/desktop/picoclaw/sidebar-actions.ts
Normal file
456
web/src/pages/desktop/picoclaw/sidebar-actions.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import {
|
||||
closeGateway,
|
||||
connectGateway,
|
||||
getRuntimeStatus,
|
||||
installRuntime,
|
||||
picoclawGateway,
|
||||
setPicoclawAgentProfile,
|
||||
setPicoclawModelConfig,
|
||||
startRuntime,
|
||||
stopRuntime,
|
||||
uninstallRuntime
|
||||
} from '@/api/picoclaw.ts';
|
||||
import {
|
||||
clearPicoclawRuntimeInstallSnapshot,
|
||||
setPicoclawRuntimeInstallSnapshot,
|
||||
type PicoclawRuntimeInstallSnapshot
|
||||
} from '@/lib/picoclaw-storage.ts';
|
||||
import type {
|
||||
PicoclawChatMessage,
|
||||
PicoclawOverlayState,
|
||||
PicoclawRunState,
|
||||
PicoclawRuntimeStatus,
|
||||
PicoclawTakeoverState,
|
||||
PicoclawTransportState
|
||||
} from '@/jotai/picoclaw.ts';
|
||||
|
||||
import { createErrorMessage, createStatusMessage, HIDDEN_OVERLAY } from './message-utils.ts';
|
||||
|
||||
type RuntimeStatusSetter = Dispatch<SetStateAction<PicoclawRuntimeStatus | null>>;
|
||||
type MessageSetter = Dispatch<SetStateAction<PicoclawChatMessage[]>>;
|
||||
type TakeoverSetter = Dispatch<SetStateAction<PicoclawTakeoverState>>;
|
||||
|
||||
type PicoclawSidebarActionOptions = {
|
||||
t: TFunction;
|
||||
runtimeStatus: PicoclawRuntimeStatus | null;
|
||||
transportState: PicoclawTransportState;
|
||||
installSnapshot: PicoclawRuntimeInstallSnapshot | null;
|
||||
modelApiBase: string;
|
||||
modelApiKey: string;
|
||||
modelIdentifier: string;
|
||||
setRuntimeStatus: RuntimeStatusSetter;
|
||||
setMessages: MessageSetter;
|
||||
setTakeover: TakeoverSetter;
|
||||
setOverlay: Dispatch<SetStateAction<PicoclawOverlayState>>;
|
||||
setTransportState: Dispatch<SetStateAction<PicoclawTransportState>>;
|
||||
setRunState: Dispatch<SetStateAction<PicoclawRunState>>;
|
||||
setIsModelConfigOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsTogglingRuntime: Dispatch<SetStateAction<boolean>>;
|
||||
setIsInstallRequestPending: Dispatch<SetStateAction<boolean>>;
|
||||
setInstallSnapshot: Dispatch<SetStateAction<PicoclawRuntimeInstallSnapshot | null>>;
|
||||
setIsSavingModelConfig: Dispatch<SetStateAction<boolean>>;
|
||||
setIsSwitchingAgent: Dispatch<SetStateAction<boolean>>;
|
||||
setIsUninstallRequestPending: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptions) {
|
||||
const {
|
||||
t,
|
||||
runtimeStatus,
|
||||
transportState,
|
||||
installSnapshot,
|
||||
modelApiBase,
|
||||
modelApiKey,
|
||||
modelIdentifier,
|
||||
setRuntimeStatus,
|
||||
setMessages,
|
||||
setTakeover,
|
||||
setOverlay,
|
||||
setTransportState,
|
||||
setRunState,
|
||||
setIsModelConfigOpen,
|
||||
setIsTogglingRuntime,
|
||||
setIsInstallRequestPending,
|
||||
setInstallSnapshot,
|
||||
setIsSavingModelConfig,
|
||||
setIsSwitchingAgent,
|
||||
setIsUninstallRequestPending
|
||||
} = options;
|
||||
|
||||
async function refreshState() {
|
||||
let nextRuntimeStatus = runtimeStatus;
|
||||
let runtimeRsp;
|
||||
|
||||
try {
|
||||
runtimeRsp = await getRuntimeStatus();
|
||||
} catch {
|
||||
return nextRuntimeStatus;
|
||||
}
|
||||
|
||||
if (runtimeRsp.code === 0) {
|
||||
nextRuntimeStatus = runtimeRsp.data;
|
||||
setRuntimeStatus(runtimeRsp.data);
|
||||
}
|
||||
|
||||
return nextRuntimeStatus;
|
||||
}
|
||||
|
||||
function isUnexpectedEOFError(error: unknown) {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
return message.includes('unexpected eof') || message === 'eof';
|
||||
}
|
||||
|
||||
function isExpectedRuntimeState(status: PicoclawRuntimeStatus | null, shouldStop: boolean) {
|
||||
if (!status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldStop) {
|
||||
return status.ready !== true;
|
||||
}
|
||||
|
||||
return status.ready === true;
|
||||
}
|
||||
|
||||
async function handleStartRuntime() {
|
||||
setIsTogglingRuntime(true);
|
||||
|
||||
try {
|
||||
const isRuntimeReady = runtimeStatus?.ready === true;
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = isRuntimeReady ? await stopRuntime() : await startRuntime();
|
||||
} catch (error) {
|
||||
const latestRuntimeStatus = await refreshState();
|
||||
if (
|
||||
isUnexpectedEOFError(error) &&
|
||||
isExpectedRuntimeState(latestRuntimeStatus ?? null, isRuntimeReady)
|
||||
) {
|
||||
setRuntimeStatus(latestRuntimeStatus ?? null);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createStatusMessage(
|
||||
t(
|
||||
isRuntimeReady ? 'picoclaw.status.runtimeStopped' : 'picoclaw.status.runtimeStarted'
|
||||
)
|
||||
)
|
||||
]);
|
||||
|
||||
if (isRuntimeReady) {
|
||||
closeGateway();
|
||||
setTakeover({
|
||||
active: false,
|
||||
sessionId: picoclawGateway.getSessionId(),
|
||||
reason: 'runtime_stopped'
|
||||
});
|
||||
setOverlay(HIDDEN_OVERLAY);
|
||||
setTransportState('disconnected');
|
||||
setRunState('idle');
|
||||
} else if (transportState !== 'connected') {
|
||||
try {
|
||||
await connectGateway();
|
||||
} catch {
|
||||
// handled by gateway events
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t(
|
||||
isRuntimeReady
|
||||
? 'picoclaw.status.runtimeStopFailed'
|
||||
: 'picoclaw.status.runtimeStartFailed'
|
||||
);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: isRuntimeReady ? 'RUNTIME_STOP_FAILED' : 'RUNTIME_START_FAILED',
|
||||
message: errorMessage,
|
||||
raw: error
|
||||
})
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.code === 0) {
|
||||
setRuntimeStatus(response.data.status);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createStatusMessage(
|
||||
t(isRuntimeReady ? 'picoclaw.status.runtimeStopped' : 'picoclaw.status.runtimeStarted')
|
||||
)
|
||||
]);
|
||||
|
||||
if (isRuntimeReady) {
|
||||
closeGateway();
|
||||
setTakeover({
|
||||
active: false,
|
||||
sessionId: picoclawGateway.getSessionId(),
|
||||
reason: 'runtime_stopped'
|
||||
});
|
||||
setOverlay(HIDDEN_OVERLAY);
|
||||
setTransportState('disconnected');
|
||||
setRunState('idle');
|
||||
} else if (transportState !== 'connected') {
|
||||
try {
|
||||
await connectGateway();
|
||||
} catch {
|
||||
// handled by gateway events
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const errorMessage =
|
||||
(response as { message?: string; msg?: string }).message ||
|
||||
(response as { message?: string; msg?: string }).msg ||
|
||||
t(
|
||||
isRuntimeReady
|
||||
? 'picoclaw.status.runtimeStopFailed'
|
||||
: 'picoclaw.status.runtimeStartFailed'
|
||||
);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: isRuntimeReady ? 'RUNTIME_STOP_FAILED' : 'RUNTIME_START_FAILED',
|
||||
message: errorMessage,
|
||||
raw: response
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
await refreshState();
|
||||
} finally {
|
||||
setIsTogglingRuntime(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstallRuntime() {
|
||||
setIsInstallRequestPending(true);
|
||||
const pendingSnapshot = {
|
||||
installing: true,
|
||||
installProgress: runtimeStatus?.install_progress ?? installSnapshot?.installProgress ?? 0,
|
||||
installStage: runtimeStatus?.install_stage ?? installSnapshot?.installStage ?? 'preparing',
|
||||
status: 'installing'
|
||||
};
|
||||
setInstallSnapshot(pendingSnapshot);
|
||||
setPicoclawRuntimeInstallSnapshot(pendingSnapshot);
|
||||
|
||||
try {
|
||||
const response = await installRuntime();
|
||||
if (response.code === 0) {
|
||||
setRuntimeStatus(response.data.status);
|
||||
const installFinished =
|
||||
response.data.status.status === 'installed' &&
|
||||
response.data.status.installing !== true &&
|
||||
response.data.installed === true;
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createStatusMessage(
|
||||
installFinished ? t('picoclaw.install.success') : t('picoclaw.install.installing')
|
||||
)
|
||||
]);
|
||||
await refreshState();
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
(response as { message?: string; msg?: string }).message ||
|
||||
(response as { message?: string; msg?: string }).msg ||
|
||||
t('picoclaw.install.failed');
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: 'RUNTIME_INSTALL_FAILED',
|
||||
message: errorMessage,
|
||||
raw: response
|
||||
})
|
||||
]);
|
||||
} catch (error) {
|
||||
setInstallSnapshot(null);
|
||||
clearPicoclawRuntimeInstallSnapshot();
|
||||
const errorMessage = error instanceof Error ? error.message : t('picoclaw.install.failed');
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: 'RUNTIME_INSTALL_FAILED',
|
||||
message: errorMessage,
|
||||
raw: error
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
setIsInstallRequestPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveModelConfig() {
|
||||
const apiBase = modelApiBase.trim();
|
||||
const apiKey = modelApiKey.trim();
|
||||
const model = modelIdentifier.trim();
|
||||
if (!apiBase || !apiKey || !model) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: 'MODEL_CONFIG_INVALID',
|
||||
message: t('picoclaw.model.invalid')
|
||||
})
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingModelConfig(true);
|
||||
try {
|
||||
const response = await setPicoclawModelConfig({
|
||||
model,
|
||||
api_base: apiBase,
|
||||
api_key: apiKey
|
||||
});
|
||||
if (response.code === 0) {
|
||||
setRuntimeStatus(response.data.status);
|
||||
setIsModelConfigOpen(false);
|
||||
setMessages((current) => [...current, createStatusMessage(t('picoclaw.model.saved'))]);
|
||||
await refreshState();
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
(response as { message?: string; msg?: string }).message ||
|
||||
(response as { message?: string; msg?: string }).msg ||
|
||||
t('picoclaw.model.saveFailed');
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: 'MODEL_CONFIG_SAVE_FAILED',
|
||||
message: errorMessage,
|
||||
raw: response
|
||||
})
|
||||
]);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : t('picoclaw.model.saveFailed');
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: 'MODEL_CONFIG_SAVE_FAILED',
|
||||
message: errorMessage,
|
||||
raw: error
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
setIsSavingModelConfig(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAgentProfileChange(profile: string) {
|
||||
if (!profile || profile === runtimeStatus?.agent_profile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSwitchingAgent(true);
|
||||
try {
|
||||
const response = await setPicoclawAgentProfile({ profile });
|
||||
if (response.code === 0) {
|
||||
setRuntimeStatus(response.data.status);
|
||||
setMessages((current) => [...current, createStatusMessage(t('picoclaw.agent.switched'))]);
|
||||
await refreshState();
|
||||
|
||||
if (response.data.status?.ready === true && transportState !== 'connected') {
|
||||
try {
|
||||
await connectGateway();
|
||||
} catch {
|
||||
// handled by gateway events
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
(response as { message?: string; msg?: string }).message ||
|
||||
(response as { message?: string; msg?: string }).msg ||
|
||||
t('picoclaw.agent.switchFailed');
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: 'AGENT_PROFILE_SWITCH_FAILED',
|
||||
message: errorMessage,
|
||||
raw: response
|
||||
})
|
||||
]);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t('picoclaw.agent.switchFailed');
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: 'AGENT_PROFILE_SWITCH_FAILED',
|
||||
message: errorMessage,
|
||||
raw: error
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
setIsSwitchingAgent(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUninstallRuntime() {
|
||||
setIsUninstallRequestPending(true);
|
||||
setMessages((current) => [...current, createStatusMessage(t('picoclaw.install.uninstalling'))]);
|
||||
|
||||
try {
|
||||
const response = await uninstallRuntime();
|
||||
if (response.code === 0) {
|
||||
setRuntimeStatus(response.data.status);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createStatusMessage(t('picoclaw.install.uninstalled'))
|
||||
]);
|
||||
await refreshState();
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
(response as { message?: string; msg?: string }).message ||
|
||||
(response as { message?: string; msg?: string }).msg ||
|
||||
t('picoclaw.install.uninstallFailed');
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: 'RUNTIME_UNINSTALL_FAILED',
|
||||
message: errorMessage,
|
||||
raw: response
|
||||
})
|
||||
]);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t('picoclaw.install.uninstallFailed');
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: 'RUNTIME_UNINSTALL_FAILED',
|
||||
message: errorMessage,
|
||||
raw: error
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
setIsUninstallRequestPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
refreshState,
|
||||
handleStartRuntime,
|
||||
handleInstallRuntime,
|
||||
handleSaveModelConfig,
|
||||
handleAgentProfileChange,
|
||||
handleUninstallRuntime
|
||||
};
|
||||
}
|
||||
323
web/src/pages/desktop/picoclaw/sidebar-effects.ts
Normal file
323
web/src/pages/desktop/picoclaw/sidebar-effects.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { closeGateway, connectGateway, picoclawGateway } from '@/api/picoclaw.ts';
|
||||
import {
|
||||
clearPicoclawRuntimeInstallSnapshot,
|
||||
setPicoclawRuntimeInstallSnapshot,
|
||||
type PicoclawRuntimeInstallSnapshot
|
||||
} from '@/lib/picoclaw-storage.ts';
|
||||
import type {
|
||||
PicoclawChatMessage,
|
||||
PicoclawOverlayState,
|
||||
PicoclawRunState,
|
||||
PicoclawRuntimeStatus,
|
||||
PicoclawTakeoverState,
|
||||
PicoclawTransportState
|
||||
} from '@/jotai/picoclaw.ts';
|
||||
|
||||
import {
|
||||
createAssistantMessage,
|
||||
createErrorMessage,
|
||||
createObservationMessage,
|
||||
createStatusMessage,
|
||||
createToolActionMessage,
|
||||
HIDDEN_OVERLAY,
|
||||
retainLatestObservationScreenshot
|
||||
} from './message-utils.ts';
|
||||
import { canConnectGateway, isPicoclawRuntimeInstalling } from './runtime-view.ts';
|
||||
|
||||
type MessageSetter = Dispatch<SetStateAction<PicoclawChatMessage[]>>;
|
||||
type TakeoverSetter = Dispatch<SetStateAction<PicoclawTakeoverState>>;
|
||||
|
||||
type GatewayEventOptions = {
|
||||
t: TFunction;
|
||||
setActiveSessionId: Dispatch<SetStateAction<string>>;
|
||||
setTakeover: TakeoverSetter;
|
||||
setMessages: MessageSetter;
|
||||
setTransportState: Dispatch<SetStateAction<PicoclawTransportState>>;
|
||||
setOverlay: Dispatch<SetStateAction<PicoclawOverlayState>>;
|
||||
setRunState: Dispatch<SetStateAction<PicoclawRunState>>;
|
||||
};
|
||||
|
||||
type LifecycleOptions = {
|
||||
t: TFunction;
|
||||
refreshStateRef: MutableRefObject<() => Promise<PicoclawRuntimeStatus | null>>;
|
||||
setActiveSessionId: Dispatch<SetStateAction<string>>;
|
||||
setIsFreshConversation: Dispatch<SetStateAction<boolean>>;
|
||||
setMessages: MessageSetter;
|
||||
setIsInitializing: Dispatch<SetStateAction<boolean>>;
|
||||
setTakeover: TakeoverSetter;
|
||||
setOverlay: Dispatch<SetStateAction<PicoclawOverlayState>>;
|
||||
setTransportState: Dispatch<SetStateAction<PicoclawTransportState>>;
|
||||
setRunState: Dispatch<SetStateAction<PicoclawRunState>>;
|
||||
};
|
||||
|
||||
type InstallSnapshotOptions = {
|
||||
t: TFunction;
|
||||
runtimeStatus: PicoclawRuntimeStatus | null;
|
||||
setInstallSnapshot: Dispatch<SetStateAction<PicoclawRuntimeInstallSnapshot | null>>;
|
||||
previousInstallStateRef: MutableRefObject<{ installing: boolean; status: string }>;
|
||||
setMessages: MessageSetter;
|
||||
};
|
||||
|
||||
export function usePicoclawGatewayEvents({
|
||||
t,
|
||||
setActiveSessionId,
|
||||
setTakeover,
|
||||
setMessages,
|
||||
setTransportState,
|
||||
setOverlay,
|
||||
setRunState
|
||||
}: GatewayEventOptions) {
|
||||
useEffect(() => {
|
||||
const unsubs = [
|
||||
picoclawGateway.on('connected', ({ sessionId }) => {
|
||||
setActiveSessionId(sessionId);
|
||||
setTakeover({
|
||||
active: true,
|
||||
sessionId,
|
||||
reason: 'connected'
|
||||
});
|
||||
setMessages((current) => [...current, createStatusMessage(t('picoclaw.status.connected'))]);
|
||||
}),
|
||||
picoclawGateway.on('transport_state', (state) => {
|
||||
setTransportState(state);
|
||||
|
||||
if (state === 'connected') {
|
||||
setActiveSessionId(picoclawGateway.getSessionId());
|
||||
setTakeover((current) => ({
|
||||
active: true,
|
||||
sessionId: current.sessionId || picoclawGateway.getSessionId(),
|
||||
reason: state
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setTakeover((current) => ({
|
||||
...current,
|
||||
active: false,
|
||||
reason: state
|
||||
}));
|
||||
setOverlay(HIDDEN_OVERLAY);
|
||||
setRunState('idle');
|
||||
}),
|
||||
picoclawGateway.on('run_state', (state) => {
|
||||
setRunState(state);
|
||||
}),
|
||||
picoclawGateway.on('assistant_message', (message) => {
|
||||
setMessages((current) => {
|
||||
const existingIndex = current.findIndex((item) => item.id === message.id);
|
||||
const nextMessage = createAssistantMessage(message);
|
||||
|
||||
if (existingIndex < 0) {
|
||||
return [...current, nextMessage];
|
||||
}
|
||||
|
||||
const updated = [...current];
|
||||
updated[existingIndex] = nextMessage;
|
||||
return updated;
|
||||
});
|
||||
}),
|
||||
picoclawGateway.on('tool_action', (action) => {
|
||||
setMessages((current) => [...current, createToolActionMessage(action)]);
|
||||
setOverlay({
|
||||
visible: true,
|
||||
message: `${t('picoclaw.overlay.locked')} · ${action.action}`,
|
||||
x: action.x,
|
||||
y: action.y,
|
||||
action: action.action
|
||||
});
|
||||
}),
|
||||
picoclawGateway.on('observation', (observation) => {
|
||||
setMessages((current) =>
|
||||
retainLatestObservationScreenshot([...current, createObservationMessage(observation)])
|
||||
);
|
||||
}),
|
||||
picoclawGateway.on('error', (error) => {
|
||||
setMessages((current) => [...current, createErrorMessage(error)]);
|
||||
}),
|
||||
picoclawGateway.on('close', (closeEvent) => {
|
||||
if (closeEvent.code === 1000) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createStatusMessage(t('picoclaw.status.disconnected'))
|
||||
]);
|
||||
}
|
||||
})
|
||||
];
|
||||
|
||||
return () => {
|
||||
unsubs.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
}, [setActiveSessionId, setMessages, setOverlay, setRunState, setTakeover, setTransportState, t]);
|
||||
}
|
||||
|
||||
export function usePicoclawSidebarLifecycle({
|
||||
t,
|
||||
refreshStateRef,
|
||||
setActiveSessionId,
|
||||
setIsFreshConversation,
|
||||
setMessages,
|
||||
setIsInitializing,
|
||||
setTakeover,
|
||||
setOverlay,
|
||||
setTransportState,
|
||||
setRunState
|
||||
}: LifecycleOptions) {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function openSidebar() {
|
||||
const nextSessionId = picoclawGateway.rotateSession();
|
||||
setActiveSessionId(nextSessionId);
|
||||
setIsFreshConversation(true);
|
||||
setMessages([]);
|
||||
try {
|
||||
const nextRuntimeStatus = await refreshStateRef.current();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextRuntimeStatus?.installing) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createStatusMessage(t('picoclaw.install.installing'))
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextRuntimeStatus?.installed === false) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createStatusMessage(t('picoclaw.install.requiredDescription'))
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextRuntimeStatus?.model_configured === false) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createStatusMessage(t('picoclaw.model.requiredDescription'))
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canConnectGateway(nextRuntimeStatus ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createStatusMessage(t('picoclaw.status.connecting'))
|
||||
]);
|
||||
try {
|
||||
await connectGateway();
|
||||
} catch {
|
||||
// handled by gateway events
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void openSidebar();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
closeGateway();
|
||||
setTakeover((current) => ({
|
||||
...current,
|
||||
active: false,
|
||||
reason: 'panel_closed'
|
||||
}));
|
||||
setOverlay(HIDDEN_OVERLAY);
|
||||
setTransportState('disconnected');
|
||||
setRunState('idle');
|
||||
};
|
||||
}, [
|
||||
refreshStateRef,
|
||||
setActiveSessionId,
|
||||
setIsFreshConversation,
|
||||
setIsInitializing,
|
||||
setMessages,
|
||||
setOverlay,
|
||||
setRunState,
|
||||
setTakeover,
|
||||
setTransportState,
|
||||
t
|
||||
]);
|
||||
}
|
||||
|
||||
export function usePicoclawInstallRefresh(
|
||||
isRuntimeInstallActive: boolean,
|
||||
refreshStateRef: MutableRefObject<() => Promise<PicoclawRuntimeStatus | null>>
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!isRuntimeInstallActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshStateRef.current();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [isRuntimeInstallActive, refreshStateRef]);
|
||||
}
|
||||
|
||||
export function usePicoclawInstallSnapshotSync({
|
||||
t,
|
||||
runtimeStatus,
|
||||
setInstallSnapshot,
|
||||
previousInstallStateRef,
|
||||
setMessages
|
||||
}: InstallSnapshotOptions) {
|
||||
useEffect(() => {
|
||||
if (!runtimeStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isInstalling = isPicoclawRuntimeInstalling(runtimeStatus);
|
||||
|
||||
if (isInstalling) {
|
||||
const nextSnapshot = {
|
||||
installing: true,
|
||||
installProgress: runtimeStatus.install_progress,
|
||||
installStage: runtimeStatus.install_stage,
|
||||
status: runtimeStatus.status
|
||||
};
|
||||
setInstallSnapshot(nextSnapshot);
|
||||
setPicoclawRuntimeInstallSnapshot(nextSnapshot);
|
||||
} else {
|
||||
setInstallSnapshot(null);
|
||||
clearPicoclawRuntimeInstallSnapshot();
|
||||
}
|
||||
|
||||
const previous = previousInstallStateRef.current;
|
||||
if (previous.installing && !isInstalling) {
|
||||
if (runtimeStatus.installed === true) {
|
||||
setMessages((current) => [...current, createStatusMessage(t('picoclaw.install.success'))]);
|
||||
} else {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: (runtimeStatus.status || 'runtime_install_failed').toUpperCase(),
|
||||
message: runtimeStatus.last_error || t('picoclaw.install.failed'),
|
||||
raw: runtimeStatus
|
||||
})
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
previousInstallStateRef.current = {
|
||||
installing: isInstalling,
|
||||
status: runtimeStatus.status
|
||||
};
|
||||
}, [runtimeStatus, setInstallSnapshot, setMessages, t, previousInstallStateRef]);
|
||||
}
|
||||
222
web/src/pages/desktop/picoclaw/sidebar-header.tsx
Normal file
222
web/src/pages/desktop/picoclaw/sidebar-header.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Dropdown, MenuProps, Modal, Select } from 'antd';
|
||||
import {
|
||||
EllipsisIcon,
|
||||
HistoryIcon,
|
||||
PlayIcon,
|
||||
PowerIcon,
|
||||
SlidersHorizontalIcon,
|
||||
TrashIcon
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { LayoutSidebarRightCollapse } from '@/components/icons/layout-sidebar-right-collapse.tsx';
|
||||
|
||||
type SidebarHeaderProps = {
|
||||
isCheckingRuntime: boolean;
|
||||
installed?: boolean;
|
||||
modelConfigured?: boolean;
|
||||
runtimeReady?: boolean;
|
||||
isTogglingRuntime: boolean;
|
||||
agentProfile?: string;
|
||||
isSwitchingAgent?: boolean;
|
||||
isHistoryOpen?: boolean;
|
||||
onToggleRuntime: () => void | Promise<void>;
|
||||
onClose?: () => void;
|
||||
onOpenHistory?: () => void;
|
||||
onAgentProfileChange?: (profile: string) => void | Promise<void>;
|
||||
onOpenModelConfig?: () => void;
|
||||
isUninstallingRuntime?: boolean;
|
||||
onUninstallRuntime?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export const SidebarHeader = ({
|
||||
isCheckingRuntime,
|
||||
installed,
|
||||
modelConfigured,
|
||||
runtimeReady,
|
||||
isTogglingRuntime,
|
||||
agentProfile,
|
||||
isSwitchingAgent,
|
||||
isHistoryOpen,
|
||||
onToggleRuntime,
|
||||
onClose,
|
||||
onOpenHistory,
|
||||
onAgentProfileChange,
|
||||
onOpenModelConfig,
|
||||
isUninstallingRuntime,
|
||||
onUninstallRuntime
|
||||
}: SidebarHeaderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isUninstallModalVisible, setIsUninstallModalVisible] = useState(false);
|
||||
const isRuntimeReady = runtimeReady === true;
|
||||
const isInstalled = installed !== false;
|
||||
const isModelConfigured = modelConfigured !== false;
|
||||
|
||||
const agentOptions = [
|
||||
{
|
||||
value: 'kvm',
|
||||
title: t('picoclaw.agent.kvmTitle'),
|
||||
description: t('picoclaw.agent.kvmDescription')
|
||||
},
|
||||
{
|
||||
value: 'default',
|
||||
title: t('picoclaw.agent.defaultTitle'),
|
||||
description: t('picoclaw.agent.defaultDescription')
|
||||
}
|
||||
];
|
||||
|
||||
const handleUninstallClick = () => setIsUninstallModalVisible(true);
|
||||
|
||||
const handleConfirmUninstall = () => {
|
||||
setIsUninstallModalVisible(false);
|
||||
onUninstallRuntime?.();
|
||||
};
|
||||
|
||||
const moreMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'model-config',
|
||||
label: <span className="text-xs">{t('picoclaw.model.menuLabel')}</span>,
|
||||
icon: <SlidersHorizontalIcon size={14} className="text-neutral-400" />,
|
||||
onClick: () => onOpenModelConfig?.(),
|
||||
disabled: isUninstallingRuntime || isTogglingRuntime || !isInstalled
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'uninstall',
|
||||
danger: true,
|
||||
label: <span className="text-xs">{t('picoclaw.uninstall.menuLabel')}</span>,
|
||||
icon: <TrashIcon size={14} />,
|
||||
onClick: handleUninstallClick,
|
||||
disabled: isUninstallingRuntime || isTogglingRuntime
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-12 flex-shrink-0 items-center justify-between gap-3 pl-2 pr-4">
|
||||
{/* Left: Title */}
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={onClose}
|
||||
title={t('menu.collapse')}
|
||||
className="!flex !items-center !justify-center !text-neutral-400 hover:!bg-white/[0.08] hover:!text-neutral-200"
|
||||
icon={<LayoutSidebarRightCollapse size={18} />}
|
||||
/>
|
||||
{/* <span className="truncate text-[13px] font-medium text-neutral-200">{t('picoclaw.title')}</span> */}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{isCheckingRuntime ? null : isInstalled ? (
|
||||
<>
|
||||
{/* Agent Profile Selector */}
|
||||
<Select
|
||||
variant="borderless"
|
||||
size="small"
|
||||
value={agentProfile || 'kvm'}
|
||||
onChange={(value) => void onAgentProfileChange?.(value)}
|
||||
disabled={isUninstallingRuntime || isTogglingRuntime || isSwitchingAgent}
|
||||
loading={isSwitchingAgent}
|
||||
popupMatchSelectWidth={false}
|
||||
className="min-w-[120px] text-xs [&_.ant-select-selection-item]:text-xs [&_.ant-select-selection-item]:text-neutral-300"
|
||||
options={agentOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.title
|
||||
}))}
|
||||
optionRender={(option) => {
|
||||
const current = agentOptions.find((item) => item.value === option.value);
|
||||
if (!current) return option.label;
|
||||
return (
|
||||
<div className="py-1">
|
||||
<div className="text-xs font-medium text-neutral-200">{current.title}</div>
|
||||
<div className="mt-0.5 text-[10px] leading-snug text-neutral-500">
|
||||
{current.description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Start/ Stop & History Button */}
|
||||
{isRuntimeReady ? (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={onOpenHistory}
|
||||
title={t('picoclaw.history.title')}
|
||||
className={[
|
||||
'!flex !items-center !justify-center',
|
||||
isHistoryOpen
|
||||
? '!bg-white/[0.08] !text-neutral-200'
|
||||
: '!text-neutral-400 hover:!bg-white/[0.08] hover:!text-neutral-200'
|
||||
].join(' ')}
|
||||
icon={<HistoryIcon size={14} />}
|
||||
/>
|
||||
<Button
|
||||
disabled={isTogglingRuntime}
|
||||
loading={isTogglingRuntime}
|
||||
onClick={() => void onToggleRuntime()}
|
||||
title={t('picoclaw.config.stopRuntime')}
|
||||
icon={!isTogglingRuntime ? <PowerIcon size={14} /> : undefined}
|
||||
type="text"
|
||||
size="small"
|
||||
className="!flex !items-center !justify-center !text-red-400 hover:!bg-white/[0.08] hover:!text-red-500"
|
||||
/>
|
||||
</>
|
||||
) : isModelConfigured ? (
|
||||
<Button
|
||||
disabled={isTogglingRuntime}
|
||||
loading={isTogglingRuntime}
|
||||
onClick={() => void onToggleRuntime()}
|
||||
title={t('picoclaw.config.startRuntime')}
|
||||
icon={!isTogglingRuntime ? <PlayIcon size={14} /> : undefined}
|
||||
type="text"
|
||||
size="small"
|
||||
className="!flex !items-center !justify-center !text-neutral-400 hover:!bg-white/[0.08] hover:!text-sky-400"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* More Menu */}
|
||||
<Dropdown
|
||||
menu={{ items: moreMenuItems }}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
disabled={isUninstallingRuntime || isTogglingRuntime}
|
||||
arrow={{ pointAtCenter: true }}
|
||||
>
|
||||
<Button
|
||||
disabled={isUninstallingRuntime}
|
||||
loading={isUninstallingRuntime}
|
||||
icon={!isUninstallingRuntime ? <EllipsisIcon size={16} /> : undefined}
|
||||
type="text"
|
||||
size="small"
|
||||
className="!flex !items-center !justify-center !text-neutral-400 hover:!bg-white/[0.08] hover:!text-neutral-200"
|
||||
/>
|
||||
</Dropdown>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={t('picoclaw.uninstall.confirmTitle')}
|
||||
open={isUninstallModalVisible}
|
||||
onOk={handleConfirmUninstall}
|
||||
onCancel={() => setIsUninstallModalVisible(false)}
|
||||
okText={t('picoclaw.uninstall.confirmOk')}
|
||||
cancelText={t('picoclaw.uninstall.confirmCancel')}
|
||||
okButtonProps={{ danger: true }}
|
||||
width={400}
|
||||
centered
|
||||
>
|
||||
<p className="text-sm text-neutral-400">{t('picoclaw.uninstall.confirmContent')}</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
165
web/src/pages/desktop/picoclaw/sidebar-history.tsx
Normal file
165
web/src/pages/desktop/picoclaw/sidebar-history.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Clock3Icon, MessageSquareTextIcon, Trash2Icon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { PicoclawSessionListItem } from '@/api/picoclaw.ts';
|
||||
|
||||
type SidebarHistoryProps = {
|
||||
sessions: PicoclawSessionListItem[];
|
||||
activeSessionId?: string;
|
||||
isLoading: boolean;
|
||||
isDeleting: boolean;
|
||||
isSwitching?: boolean;
|
||||
onSelect: (sessionId: string) => void | Promise<void>;
|
||||
onDelete: (sessionId: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function formatSessionTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export const SidebarHistory = ({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
isLoading,
|
||||
isDeleting,
|
||||
isSwitching,
|
||||
onSelect,
|
||||
onDelete
|
||||
}: SidebarHistoryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [pendingDelete, setPendingDelete] = useState<PicoclawSessionListItem | null>(null);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center px-6 text-sm text-neutral-500">
|
||||
{t('picoclaw.history.loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
|
||||
<Clock3Icon size={18} className="text-neutral-600" />
|
||||
<span className="text-sm text-neutral-400">{t('picoclaw.history.emptyTitle')}</span>
|
||||
<span className="text-xs text-neutral-600">{t('picoclaw.history.emptyDescription')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="picoclaw-sidebar-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-3">
|
||||
<div className="space-y-2">
|
||||
{sessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={isSwitching ? -1 : 0}
|
||||
onClick={() => {
|
||||
if (isSwitching) {
|
||||
return;
|
||||
}
|
||||
void onSelect(session.id);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (isSwitching) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
void onSelect(session.id);
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
'group flex w-full items-start gap-3 rounded-2xl border px-3 py-3 text-left transition-colors',
|
||||
isSwitching ? 'pointer-events-none opacity-60' : '',
|
||||
isActive
|
||||
? 'border-sky-400/30 bg-sky-400/10'
|
||||
: 'border-white/[0.06] bg-white/[0.03] hover:border-white/[0.1] hover:bg-white/[0.05]'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white/[0.05] text-neutral-400">
|
||||
<MessageSquareTextIcon size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-neutral-200">
|
||||
{session.title}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-neutral-500">
|
||||
{session.preview}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
disabled={isDeleting || isSwitching}
|
||||
icon={<Trash2Icon size={14} />}
|
||||
className={[
|
||||
'!mt-0.5 !hidden !items-center !justify-center !text-neutral-500 hover:!bg-red-500/10 hover:!text-red-300',
|
||||
isActive ? '' : 'group-hover:!inline-flex'
|
||||
].join(' ')}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setPendingDelete(session);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3 text-[11px] text-neutral-600">
|
||||
<span>{formatSessionTime(session.updated)}</span>
|
||||
<span>
|
||||
{t('picoclaw.history.messageCount', { count: session.message_count })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [activeSessionId, isDeleting, isLoading, isSwitching, onSelect, sessions, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col">{content}</div>
|
||||
<Modal
|
||||
centered
|
||||
open={pendingDelete !== null}
|
||||
title={t('picoclaw.history.deleteConfirmTitle')}
|
||||
okText={t('picoclaw.history.deleteConfirmOk')}
|
||||
cancelText={t('picoclaw.history.deleteConfirmCancel')}
|
||||
okButtonProps={{ danger: true, loading: isDeleting }}
|
||||
onOk={() => {
|
||||
if (!pendingDelete) {
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(onDelete(pendingDelete.id)).finally(() => setPendingDelete(null));
|
||||
}}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
>
|
||||
<p className="text-sm text-neutral-400">
|
||||
{t('picoclaw.history.deleteConfirmContent', { title: pendingDelete?.title || '' })}
|
||||
</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
67
web/src/pages/desktop/picoclaw/sidebar-install.tsx
Normal file
67
web/src/pages/desktop/picoclaw/sidebar-install.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Button, Progress } from 'antd';
|
||||
import { DownloadIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type SidebarInstallProps = {
|
||||
installProgress?: number;
|
||||
installStage?: string;
|
||||
isInstalling: boolean;
|
||||
onInstall: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export const SidebarInstall = ({
|
||||
installProgress,
|
||||
installStage,
|
||||
isInstalling,
|
||||
onInstall
|
||||
}: SidebarInstallProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center overflow-y-auto px-6 pb-10 pt-10">
|
||||
{/* Icon */}
|
||||
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl border border-white/[0.08] bg-white/[0.04]">
|
||||
<DownloadIcon className="text-sky-400" size={22} />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-xs text-center">
|
||||
<div className="text-sm font-semibold text-neutral-100">
|
||||
{t('picoclaw.install.requiredTitle')}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-neutral-500">
|
||||
{isInstalling
|
||||
? t('picoclaw.install.progressDescription')
|
||||
: t('picoclaw.install.requiredDescription')}
|
||||
</div>
|
||||
|
||||
{isInstalling && (
|
||||
<div className="mt-6 text-left">
|
||||
<div className="mb-2 flex items-center justify-between text-[11px] text-neutral-500">
|
||||
<span>{t(`picoclaw.install.stages.${installStage || 'downloading'}`)}</span>
|
||||
<span>{installProgress ?? 0}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={installProgress || 0}
|
||||
showInfo={false}
|
||||
strokeColor="#38bdf8"
|
||||
trailColor="rgba(255,255,255,0.07)"
|
||||
status="active"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-center">
|
||||
{!isInstalling && (
|
||||
<Button
|
||||
icon={<DownloadIcon size={13} />}
|
||||
onClick={() => void onInstall()}
|
||||
type="primary"
|
||||
>
|
||||
{t('picoclaw.install.install')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
131
web/src/pages/desktop/picoclaw/sidebar-model-config.tsx
Normal file
131
web/src/pages/desktop/picoclaw/sidebar-model-config.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button, Input } from 'antd';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { BookOpenIcon, CpuIcon, ExternalLinkIcon, KeyRoundIcon, LinkIcon, SaveIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
|
||||
type SidebarModelConfigProps = {
|
||||
apiBase: string;
|
||||
apiKey: string;
|
||||
isSaving: boolean;
|
||||
modelIdentifier: string;
|
||||
modelName?: string;
|
||||
onApiBaseChange: (value: string) => void;
|
||||
onApiKeyChange: (value: string) => void;
|
||||
onModelIdentifierChange: (value: string) => void;
|
||||
onSave: () => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
showCancel?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarModelConfig = ({
|
||||
apiBase,
|
||||
apiKey,
|
||||
isSaving,
|
||||
modelIdentifier,
|
||||
modelName,
|
||||
onApiBaseChange,
|
||||
onApiKeyChange,
|
||||
onModelIdentifierChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
showCancel = false
|
||||
}: SidebarModelConfigProps) => {
|
||||
const { t } = useTranslation();
|
||||
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
|
||||
|
||||
useEffect(() => {
|
||||
setIsKeyboardEnable(false);
|
||||
return () => {
|
||||
setIsKeyboardEnable(true);
|
||||
};
|
||||
}, [setIsKeyboardEnable]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-y-auto px-5 pb-8 pt-10">
|
||||
{/* Title */}
|
||||
<div className="mb-1 text-sm font-semibold text-neutral-100">
|
||||
{t('picoclaw.model.requiredTitle')}
|
||||
</div>
|
||||
<div className="mb-5 text-xs leading-5 text-neutral-500">
|
||||
{t('picoclaw.model.requiredDescription')}
|
||||
{modelName && <span className="ml-1 text-neutral-400">{modelName}</span>}
|
||||
</div>
|
||||
|
||||
{/* Documentation Link */}
|
||||
<a
|
||||
href="https://docs.picoclaw.io/docs/configuration/model-list/#supported-vendors-and-protocols"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group mb-5 flex items-center justify-between rounded-lg border border-white/[0.06] bg-white/[0.02] p-3 transition-colors hover:border-white/[0.1] hover:bg-white/[0.04]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md border border-white/[0.08] bg-white/[0.04]">
|
||||
<BookOpenIcon className="text-sky-400" size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-neutral-300">
|
||||
{t('picoclaw.model.docsTitle', 'Configuration Guide')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-neutral-500">
|
||||
{t('picoclaw.model.docsDesc', 'Supported models and protocols')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLinkIcon size={14} className="text-neutral-500 transition-colors group-hover:text-neutral-300" />
|
||||
</a>
|
||||
|
||||
{/* Fields */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-neutral-500">
|
||||
{t('picoclaw.model.modelIdentifier')}
|
||||
</label>
|
||||
<Input
|
||||
prefix={<CpuIcon size={13} className="text-neutral-500" />}
|
||||
placeholder={t('picoclaw.model.modelIdentifierPlaceholder')}
|
||||
value={modelIdentifier}
|
||||
onChange={(e) => onModelIdentifierChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-neutral-500">
|
||||
{t('picoclaw.model.apiKey')}
|
||||
</label>
|
||||
<Input.Password
|
||||
prefix={<KeyRoundIcon size={13} className="text-neutral-500" />}
|
||||
placeholder={t('picoclaw.model.apiKeyPlaceholder')}
|
||||
value={apiKey}
|
||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-neutral-500">
|
||||
{t('picoclaw.model.apiBase')}
|
||||
</label>
|
||||
<Input
|
||||
prefix={<LinkIcon size={13} className="text-neutral-500" />}
|
||||
placeholder={t('picoclaw.model.apiBasePlaceholder')}
|
||||
value={apiBase}
|
||||
onChange={(e) => onApiBaseChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
{showCancel && <Button onClick={onCancel}>{t('picoclaw.cancel')}</Button>}
|
||||
<Button
|
||||
icon={<SaveIcon size={13} />}
|
||||
loading={isSaving}
|
||||
onClick={() => void onSave()}
|
||||
type="primary"
|
||||
>
|
||||
{t(isSaving ? 'picoclaw.model.saving' : 'picoclaw.model.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
319
web/src/pages/desktop/picoclaw/sidebar-session-actions.ts
Normal file
319
web/src/pages/desktop/picoclaw/sidebar-session-actions.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import {
|
||||
closeGateway,
|
||||
connectGateway,
|
||||
deletePicoclawSession,
|
||||
getPicoclawSession,
|
||||
listPicoclawSessions,
|
||||
picoclawGateway,
|
||||
sendChatMessage,
|
||||
sendStopMessage,
|
||||
type PicoclawSessionListItem
|
||||
} from '@/api/picoclaw.ts';
|
||||
import { generateUUIDv4 } from '@/lib/picoclaw-gateway.ts';
|
||||
import type {
|
||||
PicoclawChatMessage,
|
||||
PicoclawConfigState,
|
||||
PicoclawOverlayState,
|
||||
PicoclawRunState,
|
||||
PicoclawRuntimeStatus,
|
||||
PicoclawTakeoverState,
|
||||
PicoclawTransportState
|
||||
} from '@/jotai/picoclaw.ts';
|
||||
|
||||
import { createErrorMessage, createStatusMessage, HIDDEN_OVERLAY } from './message-utils.ts';
|
||||
import { canConnectGateway } from './runtime-view.ts';
|
||||
|
||||
type RuntimeStatusSetter = Dispatch<SetStateAction<PicoclawRuntimeStatus | null>>;
|
||||
type MessageSetter = Dispatch<SetStateAction<PicoclawChatMessage[]>>;
|
||||
type TakeoverSetter = Dispatch<SetStateAction<PicoclawTakeoverState>>;
|
||||
|
||||
type PicoclawSidebarSessionActionOptions = {
|
||||
t: TFunction;
|
||||
runtimeStatus: PicoclawRuntimeStatus | null;
|
||||
transportState: PicoclawTransportState;
|
||||
runState: PicoclawRunState;
|
||||
config: PicoclawConfigState;
|
||||
activeSessionId: string;
|
||||
isFreshConversation: boolean;
|
||||
isSwitchingSession: boolean;
|
||||
refreshState: () => Promise<PicoclawRuntimeStatus | null>;
|
||||
setMessages: MessageSetter;
|
||||
setTakeover: TakeoverSetter;
|
||||
setOverlay: Dispatch<SetStateAction<PicoclawOverlayState>>;
|
||||
setTransportState: Dispatch<SetStateAction<PicoclawTransportState>>;
|
||||
setRunState: Dispatch<SetStateAction<PicoclawRunState>>;
|
||||
setHistorySessions: Dispatch<SetStateAction<PicoclawSessionListItem[]>>;
|
||||
setActiveSessionId: Dispatch<SetStateAction<string>>;
|
||||
setIsFreshConversation: Dispatch<SetStateAction<boolean>>;
|
||||
setIsHistoryOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsModelConfigOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setIsLoadingHistory: Dispatch<SetStateAction<boolean>>;
|
||||
setIsDeletingSession: Dispatch<SetStateAction<boolean>>;
|
||||
setIsSwitchingSession: Dispatch<SetStateAction<boolean>>;
|
||||
setRuntimeStatus: RuntimeStatusSetter;
|
||||
};
|
||||
|
||||
export function createPicoclawSidebarSessionActions(options: PicoclawSidebarSessionActionOptions) {
|
||||
const {
|
||||
t,
|
||||
runtimeStatus,
|
||||
transportState,
|
||||
runState,
|
||||
config,
|
||||
activeSessionId,
|
||||
isFreshConversation,
|
||||
isSwitchingSession,
|
||||
refreshState,
|
||||
setMessages,
|
||||
setTakeover,
|
||||
setOverlay,
|
||||
setTransportState,
|
||||
setRunState,
|
||||
setHistorySessions,
|
||||
setActiveSessionId,
|
||||
setIsFreshConversation,
|
||||
setIsHistoryOpen,
|
||||
setIsModelConfigOpen,
|
||||
setIsLoadingHistory,
|
||||
setIsDeletingSession,
|
||||
setIsSwitchingSession,
|
||||
setRuntimeStatus
|
||||
} = options;
|
||||
|
||||
async function loadHistorySessions() {
|
||||
setIsLoadingHistory(true);
|
||||
try {
|
||||
const response = await listPicoclawSessions({ limit: 100 });
|
||||
if (response.code === 0) {
|
||||
setHistorySessions(response.data || []);
|
||||
return response.data || [];
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend(content: string) {
|
||||
let ready = transportState === 'connected' && runState === 'idle';
|
||||
|
||||
if (!ready) {
|
||||
const nextRuntimeStatus = await refreshState();
|
||||
if (!canConnectGateway(nextRuntimeStatus ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await connectGateway();
|
||||
ready = true;
|
||||
} catch {
|
||||
ready = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = generateUUIDv4();
|
||||
setIsFreshConversation(false);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{
|
||||
id,
|
||||
kind: 'user',
|
||||
text: content,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
]);
|
||||
|
||||
sendChatMessage(content, {
|
||||
id,
|
||||
maxSteps: config.maxSteps,
|
||||
maxRuntimeMs: config.maxRuntimeMs
|
||||
});
|
||||
}
|
||||
|
||||
async function handleNewConversation() {
|
||||
if (isFreshConversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeGateway();
|
||||
setIsHistoryOpen(false);
|
||||
setIsModelConfigOpen(false);
|
||||
setMessages([]);
|
||||
setTakeover({
|
||||
active: false,
|
||||
sessionId: undefined,
|
||||
reason: 'new_conversation'
|
||||
});
|
||||
setOverlay(HIDDEN_OVERLAY);
|
||||
setTransportState('disconnected');
|
||||
setRunState('idle');
|
||||
|
||||
const nextSessionId = picoclawGateway.rotateSession();
|
||||
setActiveSessionId(nextSessionId);
|
||||
setIsFreshConversation(true);
|
||||
|
||||
const nextRuntimeStatus = await refreshState();
|
||||
if (!canConnectGateway(nextRuntimeStatus ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((current) => [...current, createStatusMessage(t('picoclaw.status.connecting'))]);
|
||||
try {
|
||||
await connectGateway(nextSessionId);
|
||||
} catch {
|
||||
// handled by gateway events
|
||||
}
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
sendStopMessage();
|
||||
setMessages((current) => [...current, createStatusMessage(t('picoclaw.status.stopped'))]);
|
||||
}
|
||||
|
||||
async function handleOpenHistory() {
|
||||
setIsModelConfigOpen(false);
|
||||
setIsHistoryOpen(true);
|
||||
await loadHistorySessions();
|
||||
}
|
||||
|
||||
function handleCloseHistory() {
|
||||
setIsHistoryOpen(false);
|
||||
}
|
||||
|
||||
async function handleSelectHistorySession(sessionId: string) {
|
||||
if (isSwitchingSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionId === activeSessionId) {
|
||||
setIsHistoryOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSwitchingSession(true);
|
||||
try {
|
||||
const response = await getPicoclawSession(sessionId);
|
||||
if (response.code !== 0) {
|
||||
const errorMessage =
|
||||
(response as { message?: string; msg?: string }).message ||
|
||||
(response as { message?: string; msg?: string }).msg ||
|
||||
t('picoclaw.history.loadFailed');
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: 'SESSION_LOAD_FAILED',
|
||||
message: errorMessage,
|
||||
raw: response
|
||||
})
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedMessages = (response.data.messages || []).map(
|
||||
(message: { role: 'user' | 'assistant'; content: string }, index: number) => ({
|
||||
id: `${response.data.id}-${index}`,
|
||||
kind: message.role,
|
||||
text: message.content,
|
||||
createdAt: Date.now() + index
|
||||
})
|
||||
);
|
||||
|
||||
closeGateway();
|
||||
setMessages(loadedMessages);
|
||||
setActiveSessionId(sessionId);
|
||||
setIsFreshConversation(false);
|
||||
setIsHistoryOpen(false);
|
||||
setTakeover({
|
||||
active: false,
|
||||
sessionId,
|
||||
reason: 'history_selected'
|
||||
});
|
||||
setOverlay(HIDDEN_OVERLAY);
|
||||
setTransportState('disconnected');
|
||||
setRunState('idle');
|
||||
setRuntimeStatus((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
current_session: sessionId
|
||||
}
|
||||
: current
|
||||
);
|
||||
|
||||
if (canConnectGateway(runtimeStatus ?? null)) {
|
||||
try {
|
||||
await connectGateway(sessionId);
|
||||
} catch {
|
||||
// handled by gateway events
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t('picoclaw.history.loadFailed');
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: 'SESSION_LOAD_FAILED',
|
||||
message: errorMessage,
|
||||
raw: error
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
setIsSwitchingSession(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteHistorySession(sessionId: string) {
|
||||
setIsDeletingSession(true);
|
||||
try {
|
||||
const response = await deletePicoclawSession(sessionId);
|
||||
if (response.code === 0) {
|
||||
setHistorySessions((current) => current.filter((session) => session.id !== sessionId));
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
(response as { message?: string; msg?: string }).message ||
|
||||
(response as { message?: string; msg?: string }).msg ||
|
||||
t('picoclaw.history.deleteFailed');
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: 'SESSION_DELETE_FAILED',
|
||||
message: errorMessage,
|
||||
raw: response
|
||||
})
|
||||
]);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t('picoclaw.history.deleteFailed');
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
createErrorMessage({
|
||||
code: 'SESSION_DELETE_FAILED',
|
||||
message: errorMessage,
|
||||
raw: error
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
setIsDeletingSession(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleSend,
|
||||
handleNewConversation,
|
||||
handleStop,
|
||||
handleOpenHistory,
|
||||
handleCloseHistory,
|
||||
handleSelectHistorySession,
|
||||
handleDeleteHistorySession
|
||||
};
|
||||
}
|
||||
165
web/src/pages/desktop/picoclaw/sidebar.tsx
Normal file
165
web/src/pages/desktop/picoclaw/sidebar.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Button } from 'antd';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Loader2Icon, PlayIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { picoclawChatOpenAtom } from '@/jotai/picoclaw.ts';
|
||||
|
||||
import { MessageInput } from './message-input.tsx';
|
||||
import { MessageList } from './message-list.tsx';
|
||||
import { SidebarHeader } from './sidebar-header.tsx';
|
||||
import { SidebarHistory } from './sidebar-history.tsx';
|
||||
import { SidebarInstall } from './sidebar-install.tsx';
|
||||
import { SidebarModelConfig } from './sidebar-model-config.tsx';
|
||||
import { useSidebar } from './use-sidebar.ts';
|
||||
|
||||
export const Sidebar = () => {
|
||||
const { t } = useTranslation();
|
||||
const setIsChatOpen = useSetAtom(picoclawChatOpenAtom);
|
||||
const {
|
||||
connectionLabel,
|
||||
isInitializing,
|
||||
sidebarMode,
|
||||
handleCancelModelConfig,
|
||||
handleAgentProfileChange,
|
||||
handleCloseHistory,
|
||||
handleDeleteHistorySession,
|
||||
handleModelApiBaseChange,
|
||||
handleModelApiKeyChange,
|
||||
handleModelIdentifierChange,
|
||||
handleNewConversation,
|
||||
handleOpenHistory,
|
||||
handleOpenModelConfig,
|
||||
handleSaveModelConfig,
|
||||
handleSelectHistorySession,
|
||||
historySessions,
|
||||
activeSessionId,
|
||||
isDeletingSession,
|
||||
isFreshConversation,
|
||||
isHistoryOpen,
|
||||
isInstallingRuntime,
|
||||
isLoadingHistory,
|
||||
isSwitchingSession,
|
||||
installProgress,
|
||||
installStage,
|
||||
isSavingModelConfig,
|
||||
isSwitchingAgent,
|
||||
modelApiBase,
|
||||
modelApiKey,
|
||||
modelIdentifier,
|
||||
isModelConfigOpen,
|
||||
runState,
|
||||
transportState,
|
||||
isTogglingRuntime,
|
||||
messages,
|
||||
runtimeStatus,
|
||||
handleInstallRuntime,
|
||||
isUninstallingRuntime,
|
||||
handleUninstallRuntime,
|
||||
handleSend,
|
||||
handleStartRuntime
|
||||
} = useSidebar();
|
||||
|
||||
return (
|
||||
<aside className="picoclaw-sidebar-scrollbar flex h-full min-h-0 w-full flex-col overflow-x-hidden bg-[#0d0d0f]">
|
||||
{/* Header */}
|
||||
<SidebarHeader
|
||||
isCheckingRuntime={isInitializing}
|
||||
installed={runtimeStatus?.installed}
|
||||
modelConfigured={runtimeStatus?.model_configured}
|
||||
isTogglingRuntime={isTogglingRuntime}
|
||||
agentProfile={runtimeStatus?.agent_profile}
|
||||
isSwitchingAgent={isSwitchingAgent}
|
||||
runtimeReady={runtimeStatus?.ready}
|
||||
onToggleRuntime={handleStartRuntime}
|
||||
onClose={() => setIsChatOpen(false)}
|
||||
isHistoryOpen={isHistoryOpen}
|
||||
onOpenHistory={isHistoryOpen ? handleCloseHistory : handleOpenHistory}
|
||||
onAgentProfileChange={handleAgentProfileChange}
|
||||
onOpenModelConfig={handleOpenModelConfig}
|
||||
isUninstallingRuntime={isUninstallingRuntime}
|
||||
onUninstallRuntime={handleUninstallRuntime}
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-white/[0.06]" />
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{sidebarMode === 'loading' ? (
|
||||
<div className="flex flex-1 flex-col items-center pt-10">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2Icon className="animate-spin text-neutral-500" size={22} />
|
||||
<span className="text-xs text-neutral-500">{connectionLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : sidebarMode === 'install' ? (
|
||||
<SidebarInstall
|
||||
installProgress={installProgress}
|
||||
installStage={installStage}
|
||||
isInstalling={isInstallingRuntime}
|
||||
onInstall={handleInstallRuntime}
|
||||
/>
|
||||
) : sidebarMode === 'model' ? (
|
||||
<SidebarModelConfig
|
||||
apiBase={modelApiBase}
|
||||
apiKey={modelApiKey}
|
||||
isSaving={isSavingModelConfig}
|
||||
modelIdentifier={modelIdentifier}
|
||||
modelName={runtimeStatus?.model_name}
|
||||
onApiBaseChange={handleModelApiBaseChange}
|
||||
onApiKeyChange={handleModelApiKeyChange}
|
||||
onModelIdentifierChange={handleModelIdentifierChange}
|
||||
onSave={handleSaveModelConfig}
|
||||
onCancel={handleCancelModelConfig}
|
||||
showCancel={isModelConfigOpen}
|
||||
/>
|
||||
) : sidebarMode === 'history' ? (
|
||||
<SidebarHistory
|
||||
sessions={historySessions}
|
||||
activeSessionId={activeSessionId}
|
||||
isLoading={isLoadingHistory}
|
||||
isDeleting={isDeletingSession}
|
||||
isSwitching={isSwitchingSession}
|
||||
onSelect={handleSelectHistorySession}
|
||||
onDelete={handleDeleteHistorySession}
|
||||
/>
|
||||
) : !runtimeStatus?.ready ? (
|
||||
<div className="flex flex-1 flex-col items-center px-6 pb-6 pt-10 text-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl border border-white/[0.08] bg-white/[0.04]">
|
||||
<PlayIcon className="text-sky-400" size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-1 text-sm font-medium text-neutral-200">
|
||||
{t('picoclaw.start.title')}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-500">{t('picoclaw.start.description')}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayIcon size={14} />}
|
||||
loading={isTogglingRuntime}
|
||||
onClick={() => void handleStartRuntime()}
|
||||
>
|
||||
{t('picoclaw.config.startRuntime')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<MessageList messages={messages} runState={runState} />
|
||||
<div className="border-t border-white/[0.06] px-3 pb-3 pt-3">
|
||||
<MessageInput
|
||||
transportState={transportState}
|
||||
onSend={handleSend}
|
||||
onNewConversation={handleNewConversation}
|
||||
disableNewConversation={isFreshConversation}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
231
web/src/pages/desktop/picoclaw/use-sidebar.ts
Normal file
231
web/src/pages/desktop/picoclaw/use-sidebar.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useAtom, useSetAtom } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { PicoclawSessionListItem } from '@/api/picoclaw.ts';
|
||||
import {
|
||||
getPicoclawRuntimeInstallSnapshot,
|
||||
type PicoclawRuntimeInstallSnapshot
|
||||
} from '@/lib/picoclaw-storage.ts';
|
||||
import {
|
||||
picoclawConfigAtom,
|
||||
picoclawMessagesAtom,
|
||||
picoclawOverlayAtom,
|
||||
picoclawRunStateAtom,
|
||||
picoclawRuntimeStatusAtom,
|
||||
picoclawTakeoverStateAtom,
|
||||
picoclawTransportStateAtom
|
||||
} from '@/jotai/picoclaw.ts';
|
||||
|
||||
import {
|
||||
getPicoclawSidebarConnectionLabel,
|
||||
getPicoclawSidebarMode,
|
||||
getPicoclawSidebarStatusColor,
|
||||
isPicoclawRuntimeInstalling
|
||||
} from './runtime-view.ts';
|
||||
import { createPicoclawSidebarActions } from './sidebar-actions.ts';
|
||||
import {
|
||||
usePicoclawGatewayEvents,
|
||||
usePicoclawInstallRefresh,
|
||||
usePicoclawInstallSnapshotSync,
|
||||
usePicoclawSidebarLifecycle
|
||||
} from './sidebar-effects.ts';
|
||||
import { createPicoclawSidebarSessionActions } from './sidebar-session-actions.ts';
|
||||
|
||||
export const useSidebar = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [messages, setMessages] = useAtom(picoclawMessagesAtom);
|
||||
const [transportState, setTransportState] = useAtom(picoclawTransportStateAtom);
|
||||
const [runState, setRunState] = useAtom(picoclawRunStateAtom);
|
||||
const [runtimeStatus, setRuntimeStatus] = useAtom(picoclawRuntimeStatusAtom);
|
||||
const [config] = useAtom(picoclawConfigAtom);
|
||||
const [, setTakeover] = useAtom(picoclawTakeoverStateAtom);
|
||||
const setOverlay = useSetAtom(picoclawOverlayAtom);
|
||||
const previousInstallStateRef = useRef<{ installing: boolean; status: string }>({
|
||||
installing: false,
|
||||
status: ''
|
||||
});
|
||||
|
||||
const [modelApiBase, setModelApiBase] = useState('');
|
||||
const [modelApiKey, setModelApiKey] = useState('');
|
||||
const [modelIdentifier, setModelIdentifier] = useState('');
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [isModelConfigOpen, setIsModelConfigOpen] = useState(false);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||
const [isInstallRequestPending, setIsInstallRequestPending] = useState(false);
|
||||
const [isUninstallRequestPending, setIsUninstallRequestPending] = useState(false);
|
||||
const [isSavingModelConfig, setIsSavingModelConfig] = useState(false);
|
||||
const [isSwitchingAgent, setIsSwitchingAgent] = useState(false);
|
||||
const [isSwitchingSession, setIsSwitchingSession] = useState(false);
|
||||
const [isTogglingRuntime, setIsTogglingRuntime] = useState(false);
|
||||
const [isFreshConversation, setIsFreshConversation] = useState(true);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
const [isDeletingSession, setIsDeletingSession] = useState(false);
|
||||
const [historySessions, setHistorySessions] = useState<PicoclawSessionListItem[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState('');
|
||||
const [installSnapshot, setInstallSnapshot] = useState<PicoclawRuntimeInstallSnapshot | null>(
|
||||
() => getPicoclawRuntimeInstallSnapshot()
|
||||
);
|
||||
const isRuntimeStatusInstalling = isPicoclawRuntimeInstalling(runtimeStatus);
|
||||
const isSnapshotInstalling = !runtimeStatus && installSnapshot?.installing === true;
|
||||
const isRuntimeInstallActive = isRuntimeStatusInstalling || isSnapshotInstalling;
|
||||
const isInstallingRuntime = isInstallRequestPending || isRuntimeInstallActive;
|
||||
const installProgress = isRuntimeStatusInstalling
|
||||
? runtimeStatus?.install_progress ?? installSnapshot?.installProgress
|
||||
: isSnapshotInstalling
|
||||
? installSnapshot?.installProgress
|
||||
: undefined;
|
||||
const installStage = isRuntimeStatusInstalling
|
||||
? runtimeStatus?.install_stage ?? installSnapshot?.installStage
|
||||
: isSnapshotInstalling
|
||||
? installSnapshot?.installStage
|
||||
: undefined;
|
||||
const actions = createPicoclawSidebarActions({
|
||||
t,
|
||||
runtimeStatus,
|
||||
transportState,
|
||||
installSnapshot,
|
||||
modelApiBase,
|
||||
modelApiKey,
|
||||
modelIdentifier,
|
||||
setRuntimeStatus,
|
||||
setMessages,
|
||||
setTakeover,
|
||||
setOverlay,
|
||||
setTransportState,
|
||||
setRunState,
|
||||
setIsModelConfigOpen,
|
||||
setIsTogglingRuntime,
|
||||
setIsInstallRequestPending,
|
||||
setInstallSnapshot,
|
||||
setIsSavingModelConfig,
|
||||
setIsSwitchingAgent,
|
||||
setIsUninstallRequestPending
|
||||
});
|
||||
const refreshStateRef = useRef(actions.refreshState);
|
||||
refreshStateRef.current = actions.refreshState;
|
||||
const sessionActions = createPicoclawSidebarSessionActions({
|
||||
t,
|
||||
runtimeStatus,
|
||||
transportState,
|
||||
runState,
|
||||
config,
|
||||
activeSessionId,
|
||||
isFreshConversation,
|
||||
isSwitchingSession,
|
||||
refreshState: refreshStateRef.current,
|
||||
setMessages,
|
||||
setTakeover,
|
||||
setOverlay,
|
||||
setTransportState,
|
||||
setRunState,
|
||||
setHistorySessions,
|
||||
setActiveSessionId,
|
||||
setIsFreshConversation,
|
||||
setIsHistoryOpen,
|
||||
setIsModelConfigOpen,
|
||||
setIsLoadingHistory,
|
||||
setIsDeletingSession,
|
||||
setIsSwitchingSession,
|
||||
setRuntimeStatus
|
||||
});
|
||||
|
||||
usePicoclawGatewayEvents({
|
||||
t,
|
||||
setActiveSessionId,
|
||||
setTakeover,
|
||||
setMessages,
|
||||
setTransportState,
|
||||
setOverlay,
|
||||
setRunState
|
||||
});
|
||||
usePicoclawSidebarLifecycle({
|
||||
t,
|
||||
refreshStateRef,
|
||||
setActiveSessionId,
|
||||
setIsFreshConversation,
|
||||
setMessages,
|
||||
setIsInitializing,
|
||||
setTakeover,
|
||||
setOverlay,
|
||||
setTransportState,
|
||||
setRunState
|
||||
});
|
||||
usePicoclawInstallRefresh(isRuntimeInstallActive, refreshStateRef);
|
||||
usePicoclawInstallSnapshotSync({
|
||||
t,
|
||||
runtimeStatus,
|
||||
setInstallSnapshot,
|
||||
previousInstallStateRef,
|
||||
setMessages
|
||||
});
|
||||
|
||||
const connectionLabel = useMemo(
|
||||
() =>
|
||||
getPicoclawSidebarConnectionLabel(runtimeStatus, transportState, runState, isInitializing, t),
|
||||
[isInitializing, runState, runtimeStatus, t, transportState]
|
||||
);
|
||||
const statusColor = useMemo(
|
||||
() => getPicoclawSidebarStatusColor(runtimeStatus, transportState, runState, isInitializing),
|
||||
[isInitializing, runState, runtimeStatus, transportState]
|
||||
);
|
||||
const sidebarMode = useMemo(() => {
|
||||
if (isModelConfigOpen) {
|
||||
return 'model';
|
||||
}
|
||||
if (isHistoryOpen) {
|
||||
return 'history';
|
||||
}
|
||||
return getPicoclawSidebarMode(runtimeStatus, isInitializing);
|
||||
}, [isHistoryOpen, isInitializing, isModelConfigOpen, runtimeStatus]);
|
||||
|
||||
return {
|
||||
activeSessionId,
|
||||
connectionLabel,
|
||||
handleCancelModelConfig: () => setIsModelConfigOpen(false),
|
||||
handleAgentProfileChange: actions.handleAgentProfileChange,
|
||||
handleCloseHistory: sessionActions.handleCloseHistory,
|
||||
handleDeleteHistorySession: sessionActions.handleDeleteHistorySession,
|
||||
handleModelApiBaseChange: setModelApiBase,
|
||||
handleModelApiKeyChange: setModelApiKey,
|
||||
handleModelIdentifierChange: setModelIdentifier,
|
||||
handleNewConversation: sessionActions.handleNewConversation,
|
||||
handleOpenHistory: sessionActions.handleOpenHistory,
|
||||
handleOpenModelConfig: () => {
|
||||
setIsHistoryOpen(false);
|
||||
setIsModelConfigOpen(true);
|
||||
},
|
||||
handleSaveModelConfig: actions.handleSaveModelConfig,
|
||||
handleSelectHistorySession: sessionActions.handleSelectHistorySession,
|
||||
historySessions,
|
||||
isDeletingSession,
|
||||
isFreshConversation,
|
||||
isHistoryOpen,
|
||||
isInitializing,
|
||||
isInstallingRuntime,
|
||||
isLoadingHistory,
|
||||
isSwitchingSession,
|
||||
isUninstallingRuntime: isUninstallRequestPending,
|
||||
installProgress,
|
||||
installStage,
|
||||
isSavingModelConfig,
|
||||
isSwitchingAgent,
|
||||
isTogglingRuntime,
|
||||
messages,
|
||||
modelApiBase,
|
||||
modelApiKey,
|
||||
modelIdentifier,
|
||||
isModelConfigOpen,
|
||||
runState,
|
||||
runtimeStatus,
|
||||
sidebarMode,
|
||||
statusColor,
|
||||
handleInstallRuntime: actions.handleInstallRuntime,
|
||||
handleUninstallRuntime: actions.handleUninstallRuntime,
|
||||
transportState,
|
||||
handleSend: sessionActions.handleSend,
|
||||
handleStartRuntime: actions.handleStartRuntime,
|
||||
handleStop: sessionActions.handleStop
|
||||
};
|
||||
};
|
||||
@@ -19,11 +19,11 @@ export const H264Direct = () => {
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const scale = storage.getVideoScale()
|
||||
const scale = storage.getVideoScale();
|
||||
if (scale) {
|
||||
setVideoScale(scale)
|
||||
setVideoScale(scale);
|
||||
}
|
||||
}, [setVideoScale])
|
||||
}, [setVideoScale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.VideoDecoder) {
|
||||
@@ -69,17 +69,17 @@ export const H264Direct = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-start justify-center xl:items-center">
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 items-center justify-center overflow-hidden">
|
||||
<canvas
|
||||
id="screen"
|
||||
ref={canvasRef}
|
||||
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
|
||||
className={clsx('block select-none', mouseStyle)}
|
||||
style={{
|
||||
transform: `scale(${videoScale})`,
|
||||
transformOrigin: 'center',
|
||||
...(resolution?.width
|
||||
? { width: resolution.width, height: resolution.height, objectFit: 'cover' }
|
||||
: { maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }),
|
||||
: { maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' })
|
||||
}}
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { w3cwebsocket as W3cWebSocket } from 'websocket';
|
||||
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse.ts';
|
||||
import { resolutionAtom, videoScaleAtom } from '@/jotai/screen.ts';
|
||||
import * as storage from '@/lib/localstorage.ts'
|
||||
|
||||
export const H264Webrtc = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const mouseStyle = useAtomValue(mouseStyleAtom);
|
||||
const [videoScale, setVideoScale] = useAtom(videoScaleAtom);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
@@ -173,19 +171,19 @@ export const H264Webrtc = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const scale = storage.getVideoScale()
|
||||
const scale = storage.getVideoScale();
|
||||
if (scale) {
|
||||
setVideoScale(scale)
|
||||
setVideoScale(scale);
|
||||
}
|
||||
}, [setVideoScale])
|
||||
}, [setVideoScale]);
|
||||
|
||||
return (
|
||||
<Spin size="large" tip="Loading" spinning={isLoading}>
|
||||
<div className="flex h-screen w-screen items-start justify-center xl:items-center">
|
||||
<div className="relative h-full min-h-0 w-full min-w-0 overflow-hidden">
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 items-center justify-center overflow-hidden">
|
||||
<video
|
||||
id="screen"
|
||||
ref={videoRef}
|
||||
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
|
||||
className={clsx('block select-none', mouseStyle)}
|
||||
style={{
|
||||
transform: `scale(${videoScale})`,
|
||||
transformOrigin: 'center',
|
||||
@@ -206,6 +204,14 @@ export const H264Webrtc = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
{isLoading && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<div className="rounded-full bg-neutral-900/85 px-4 py-2 text-sm text-neutral-100 shadow-lg">
|
||||
Loading
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Image } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useAtomValue, useAtom } from 'jotai';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
|
||||
import MonitorXIcon from '@/assets/images/monitor-x.svg';
|
||||
import { stopFrameDetect } from '@/api/stream.ts';
|
||||
import { getFrameDetect } from '@/lib/localstorage.ts';
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { getBaseUrl } from '@/lib/service.ts';
|
||||
import { mouseStyleAtom } from '@/jotai/mouse.ts';
|
||||
import { resolutionAtom, videoScaleAtom } from '@/jotai/screen.ts';
|
||||
import * as storage from '@/lib/localstorage.ts'
|
||||
|
||||
|
||||
export const Mjpeg = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const mouseStyle = useAtomValue(mouseStyleAtom);
|
||||
const [videoScale, setVideoScale] = useAtom(videoScaleAtom);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [streamNonce, setStreamNonce] = useState(0);
|
||||
const streamURL = `${getBaseUrl('http')}/api/stream/mjpeg`;
|
||||
const streamSrc = hasError ? undefined : `${streamURL}?v=${streamNonce}`;
|
||||
|
||||
useEffect(() => {
|
||||
// stop frame detect for a while
|
||||
@@ -23,32 +24,33 @@ export const Mjpeg = () => {
|
||||
if (enabled) {
|
||||
stopFrameDetect(10);
|
||||
}
|
||||
setHasError(false);
|
||||
setStreamNonce((current) => current + 1);
|
||||
}, [resolution]);
|
||||
|
||||
useEffect(() => {
|
||||
const scale = storage.getVideoScale()
|
||||
const scale = storage.getVideoScale();
|
||||
if (scale) {
|
||||
setVideoScale(scale)
|
||||
setVideoScale(scale);
|
||||
}
|
||||
}, [setVideoScale])
|
||||
}, [setVideoScale]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-start justify-center xl:items-center"
|
||||
style={{
|
||||
transform: `scale(${videoScale})`,
|
||||
transformOrigin: 'center'
|
||||
}}>
|
||||
<Image
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 items-center justify-center overflow-hidden bg-black">
|
||||
<img
|
||||
id="screen"
|
||||
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
|
||||
style={
|
||||
resolution?.width
|
||||
className={clsx('block select-none', mouseStyle)}
|
||||
style={{
|
||||
transform: `scale(${videoScale})`,
|
||||
transformOrigin: 'center',
|
||||
...(resolution?.width
|
||||
? { width: resolution.width, height: resolution.height, objectFit: 'cover' }
|
||||
: { maxHeight: '100vh', objectFit: 'scale-down' }
|
||||
}
|
||||
src={`${getBaseUrl('http')}/api/stream/mjpeg`}
|
||||
fallback={MonitorXIcon}
|
||||
preview={false}
|
||||
: { maxHeight: '100%', objectFit: 'scale-down' }),
|
||||
visibility: hasError ? 'hidden' : 'visible'
|
||||
}}
|
||||
src={streamSrc}
|
||||
onError={() => setHasError(true)}
|
||||
alt="screen"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user