multi model support

This commit is contained in:
Davit
2026-04-09 18:58:40 +04:00
parent da3583f584
commit 3ee77b2210
23 changed files with 7466 additions and 93 deletions
+1
View File
@@ -6,4 +6,5 @@ api/data/
api/build/
client/dist/
api/src/public/uploads/
.vscode/
openclaw.log
+11
View File
@@ -0,0 +1,11 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf",
"jsxSingleQuote": false
}
+13 -1
View File
@@ -1,6 +1,11 @@
module.exports = {
extends: ['airbnb-base', 'airbnb-typescript/base'],
extends: ['airbnb-base', 'airbnb-typescript/base', 'prettier'],
parserOptions: { project: ['tsconfig.json'], tsconfigRootDir: __dirname },
settings: {
'import/resolver': {
node: { paths: [__dirname] },
},
},
rules: {
'max-len': [1, 160, 4],
'object-curly-newline': 'off',
@@ -8,5 +13,12 @@ module.exports = {
'no-underscore-dangle': 'off',
'class-methods-use-this': 'off',
'no-prototype-builtins': 'off',
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: ['**/*.test.ts', '**/*.spec.ts'],
packageDir: __dirname,
},
],
},
};
+34
View File
@@ -23,6 +23,7 @@
"jsonwebtoken": "^9.0.0",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"node-pty": "^1.1.0",
"reflect-metadata": "^0.2.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^4.6.0",
@@ -47,6 +48,7 @@
"eslint": "^8.33.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-prettier": "^10.1.8",
"nodemon": "^3.1.11",
"openapi-types": "^12.1.0",
"ts-node": "^10.9.1",
@@ -3724,6 +3726,22 @@
"eslint-plugin-import": "^2.25.3"
}
},
"node_modules/eslint-config-prettier": {
"version": "10.1.8",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"funding": {
"url": "https://opencollective.com/eslint-config-prettier"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-import-resolver-node": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
@@ -5702,6 +5720,22 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/node-pty/node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
+2
View File
@@ -26,6 +26,7 @@
"jsonwebtoken": "^9.0.0",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"node-pty": "^1.1.0",
"reflect-metadata": "^0.2.2",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^4.6.0",
@@ -50,6 +51,7 @@
"eslint": "^8.33.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-prettier": "^10.1.8",
"nodemon": "^3.1.11",
"openapi-types": "^12.1.0",
"ts-node": "^10.9.1",
+111
View File
@@ -0,0 +1,111 @@
"""
PTY bridge: spawns a command inside a pseudo-terminal and bridges
stdin/stdout so Node.js can communicate with it via pipes.
Handles terminal resize via in-band JSON messages on stdin.
Usage: python3 pty-bridge.py <cols> <rows> <command> [args...]
"""
import sys
import os
import select
import fcntl
import termios
import struct
import json
import errno
def set_nonblocking(fd):
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
def set_winsize(fd, rows, cols):
winsize = struct.pack('HHHH', rows, cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
def main():
if len(sys.argv) < 4:
sys.exit(1)
cols = int(sys.argv[1])
rows = int(sys.argv[2])
cmd = sys.argv[3:]
pid, fd = os.forkpty()
if pid == 0:
os.environ['TERM'] = 'xterm-256color'
os.execvp(cmd[0], cmd)
sys.exit(127)
set_winsize(fd, rows, cols)
set_nonblocking(fd)
set_nonblocking(sys.stdin.fileno())
stdin_fd = sys.stdin.fileno()
stdout_fd = sys.stdout.fileno()
stdin_buf = b''
try:
while True:
fds = [fd, stdin_fd]
try:
readable, _, _ = select.select(fds, [], [], 0.1)
except (select.error, ValueError):
break
if fd in readable:
try:
data = os.read(fd, 65536)
if not data:
break
os.write(stdout_fd, data)
except OSError as e:
if e.errno == errno.EIO:
break
raise
if stdin_fd in readable:
try:
data = os.read(stdin_fd, 65536)
if not data:
break
except OSError as e:
if e.errno in (errno.EIO, errno.EAGAIN):
continue
break
stdin_buf += data
while b'\n' in stdin_buf or len(stdin_buf) > 0:
if stdin_buf.startswith(b'{"type":'):
nl = stdin_buf.find(b'\n')
if nl == -1:
break
line = stdin_buf[:nl]
stdin_buf = stdin_buf[nl + 1:]
try:
msg = json.loads(line)
if msg.get('type') == 'resize':
set_winsize(fd, msg['rows'], msg['cols'])
except (json.JSONDecodeError, KeyError):
os.write(fd, line)
else:
os.write(fd, stdin_buf)
stdin_buf = b''
break
except KeyboardInterrupt:
pass
finally:
try:
os.close(fd)
except OSError:
pass
_, status = os.waitpid(pid, 0)
code = os.WEXITSTATUS(status) if os.WIFEXITED(status) else 1
sys.exit(code)
if __name__ == '__main__':
main()
+6
View File
@@ -9,15 +9,21 @@ export type IAgent = {
createdAt: Date | string;
updatedAt: Date | string | null;
deletedAt: Date | string | null;
/** Present on API list/get responses; not a DB column */
model?: string | null;
};
export type CAgent = Omit<IAgent, 'deletedAt'> | null;
/** Single-agent JSON body (GET/PATCH responses); omits DB-only fields like deletedAt */
export type AgentJson = NonNullable<CAgent>;
export type AgentFilters = QueryFilters<'name' | 'createdAt' | 'updatedAt'>;
export type AgentRequestBody = {
name?: string;
openclawAgentId?: string;
interactive?: boolean;
};
export type List = RequestHandler<never, APIResponse<CAgent>, never, AgentFilters>;
+3 -1
View File
@@ -13,6 +13,7 @@ import routes from './routes';
import seedAdminUser from './seed';
import AppDataSource from './data-source';
import { ensureDevicePaired, gateway } from './services/openclawGateway';
import attachPtyWebSocket from './services/ptyService';
dotenv.config();
@@ -39,7 +40,8 @@ app.use(expressErrorHandler);
const gwOk = await gateway.ensureConnected();
if (gwOk) console.log(colors.green('[gateway] persistent connection ready')); /* eslint-disable-line */
else console.warn(colors.yellow('[gateway] initial connection failed, will use CLI fallback')); /* eslint-disable-line */
app.listen(18802, () => console.log(colors.green('running on port 18802'))); /* eslint-disable-line */
const server = app.listen(18802, () => console.log(colors.green('running on port 18802'))); /* eslint-disable-line */
attachPtyWebSocket(server);
}
} catch (error) {
console.log(colors.red('%s'), error); /* eslint-disable-line */
+79 -6
View File
@@ -3,7 +3,7 @@ import { RequestHandler } from 'express';
import AppDataSource from '../../data-source';
import { Agent, Conversation, Message } from '../../entities';
import { MessageRole } from '../../@types/message';
import { List, Get, Create, Update, Destroy } from '../../@types/agent';
import { List, Get, Create, Update, Destroy, AgentJson } from '../../@types/agent';
import * as ocService from '../../services/openclawService';
function toSlug(name: string): string {
@@ -33,7 +33,19 @@ const list: List = async (req, res, next) => {
.orderBy(`agent.${sortField as string}`, (sortType as string).toUpperCase() === 'ASC' ? 'ASC' : 'DESC')
.getMany();
return res.json({ total, items });
const ocIds = items.map((a) => a.openclawAgentId);
const models = ocService.getAgentModelsForOpenclawIds(ocIds);
const itemsWithModel: AgentJson[] = items.map((a) => ({
_id: a._id,
name: a.name,
openclawAgentId: a.openclawAgentId,
createdBy: a.createdBy,
createdAt: a.createdAt,
updatedAt: a.updatedAt,
model: models[a.openclawAgentId] ?? null,
}));
return res.json({ total, items: itemsWithModel });
} catch (error) {
return next(error);
}
@@ -43,7 +55,18 @@ const get: Get = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
return res.json(agent);
if (!agent) return res.json(null);
const model = ocService.getAgentModel(agent.openclawAgentId);
const body: AgentJson = {
_id: agent._id,
name: agent.name,
openclawAgentId: agent.openclawAgentId,
createdBy: agent.createdBy,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
model,
};
return res.json(body);
} catch (error) {
return next(error);
}
@@ -52,16 +75,19 @@ const get: Get = async (req, res, next) => {
const create: Create = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
const openclawAgentId = req.body.openclawAgentId?.trim() || toSlug(req.body.name || '');
const { interactive, ...body } = req.body;
const openclawAgentId = body.openclawAgentId?.trim() || toSlug(body.name || '');
const agent = agentRepo.create({
...req.body,
...body,
openclawAgentId,
createdBy: req.user!._id,
createdAt: new Date(),
});
const saved = await agentRepo.save(agent);
ocService.registerAgent(openclawAgentId);
if (!interactive) {
ocService.registerAgent(openclawAgentId);
}
return res.json(saved);
} catch (error) {
@@ -343,6 +369,50 @@ const serveWorkspaceUpload: RequestHandler = async (req, res, next) => {
}
};
const listModels: RequestHandler = async (_req, res, next) => {
try {
const models = ocService.listModels(true);
return res.json({ ok: true, models });
} catch (error) {
return next(error);
}
};
const getAgentModel: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
if (!agent?.openclawAgentId) {
return res.status(404).json({ error: 'Agent not found' });
}
const model = ocService.getAgentModel(agent.openclawAgentId);
return res.json({ ok: true, model });
} catch (error) {
return next(error);
}
};
const patchAgentModel: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
if (!agent?.openclawAgentId) {
return res.status(404).json({ error: 'Agent not found' });
}
const { model } = req.body as { model: string };
if (!model) {
return res.status(400).json({ error: 'model is required' });
}
const ok = ocService.setAgentModel(agent.openclawAgentId, model);
if (!ok) {
return res.status(500).json({ error: 'Failed to set model' });
}
return res.json({ ok: true, model });
} catch (error) {
return next(error);
}
};
export {
list,
get,
@@ -356,4 +426,7 @@ export {
getSessionSettings,
patchSessionSettings,
serveWorkspaceUpload,
listModels,
getAgentModel,
patchAgentModel,
};
+16
View File
@@ -47,6 +47,22 @@ router.route('/agent/:id(\\d+)/workspace/uploads/:filename')
controller.serveWorkspaceUpload,
);
router.route('/agent/models')
.get(
auth,
controller.listModels,
);
router.route('/agent/:id(\\d+)/model')
.get(
auth,
controller.getAgentModel,
)
.patch(
auth,
controller.patchAgentModel,
);
router.route('/agent/:id(\\d+)/conversation/:conversationId(\\d+)/session-settings')
.get(
auth,
+293 -67
View File
@@ -16,10 +16,7 @@ const GW_RE_PARTIAL_TAG = new RegExp(`^<\\/?\\s*(?:${GW_TAG})\\s*$`, 'i');
function stripGatewayTags(text: string): string {
if (!text) return text;
return text
.replace(GW_RE_OPEN, '')
.replace(GW_RE_CLOSE, '')
.replace(GW_RE_PARTIAL_CLOSE, '');
return text.replace(GW_RE_OPEN, '').replace(GW_RE_CLOSE, '').replace(GW_RE_PARTIAL_CLOSE, '');
}
export function agentWorkspacePath(agentId: string): string {
@@ -58,7 +55,9 @@ export function createSseEmitter(res: Response): SseEmitter {
// ── Filesystem helpers ──
function extractUserText(raw: string): string {
const match = raw.split('\n').reverse()
const match = raw
.split('\n')
.reverse()
.map((l) => l.trim().match(/^\[.+?\]\s+(.+)/))
.find((m) => m !== null);
return match ? match[1].trim() : raw.trim();
@@ -71,18 +70,27 @@ function extractAssistantText(raw: string): string {
function readFirstUserMessage(jsonlPath: string): string | null {
if (!jsonlPath || !fs.existsSync(jsonlPath)) return null;
try {
const lines = fs.readFileSync(jsonlPath, 'utf-8').split('\n').filter((l) => l.trim());
const lines = fs
.readFileSync(jsonlPath, 'utf-8')
.split('\n')
.filter((l) => l.trim());
const userMsg = lines
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
.map((l) => {
try {
return JSON.parse(l);
} catch {
return null;
}
})
.find((entry) => entry?.type === 'message' && entry.message?.role === 'user');
if (!userMsg) return null;
const content = userMsg.message.content || [];
const textPart = Array.isArray(content)
? content.find((c: any) => c.type === 'text')
: null;
const textPart = Array.isArray(content) ? content.find((c: any) => c.type === 'text') : null;
const rawText = textPart?.text || (typeof content === 'string' ? content : null);
return rawText ? extractUserText(rawText).slice(0, 200) : null;
} catch { /* ignore */ }
} catch {
/* ignore */
}
return null;
}
@@ -97,9 +105,18 @@ export interface OpenClawMessage {
function parseMessagesFromJsonl(jsonlPath: string): OpenClawMessage[] {
if (!jsonlPath || !fs.existsSync(jsonlPath)) return [];
try {
const lines = fs.readFileSync(jsonlPath, 'utf-8').split('\n').filter((l) => l.trim());
const lines = fs
.readFileSync(jsonlPath, 'utf-8')
.split('\n')
.filter((l) => l.trim());
const raw = lines
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
.map((l) => {
try {
return JSON.parse(l);
} catch {
return null;
}
})
.filter((entry) => {
if (!entry || entry.type !== 'message') return false;
const { role } = entry.message || {};
@@ -114,12 +131,21 @@ function parseMessagesFromJsonl(jsonlPath: string): OpenClawMessage[] {
.join('\n')
.trim();
const text = role === 'user' ? extractUserText(rawText) : extractAssistantText(rawText);
const thinking = content
.filter((c: any) => c.type === 'thinking' && c.thinking)
.map((c: any) => c.thinking)
.join('\n')
.trim() || null;
return text ? { externalId: entry.id, role, text, thinking, timestamp: entry.timestamp || null } : null;
const thinking =
content
.filter((c: any) => c.type === 'thinking' && c.thinking)
.map((c: any) => c.thinking)
.join('\n')
.trim() || null;
return text
? {
externalId: entry.id,
role,
text,
thinking,
timestamp: entry.timestamp || null,
}
: null;
})
.filter((m): m is OpenClawMessage => m !== null);
@@ -135,7 +161,9 @@ function parseMessagesFromJsonl(jsonlPath: string): OpenClawMessage[] {
}
return messages;
}, []);
} catch { /* ignore read errors */ }
} catch {
/* ignore read errors */
}
return [];
}
@@ -152,19 +180,28 @@ function extractThinkingFromJsonl(agentId: string, sessionKey: string): string |
try {
const parsed = JSON.parse(l);
return parsed.type === 'message' && parsed.message?.role === 'assistant';
} catch { return false; }
} catch {
return false;
}
});
if (assistantLine) {
const parsed = JSON.parse(assistantLine);
const thinkingPart = (parsed.message.content || []).find((p: any) => p.type === 'thinking');
return thinkingPart?.thinking || null;
}
} catch { /* non-critical */ }
} catch {
/* non-critical */
}
return null;
}
function getSessionSettingsInternal(agentId: string, sessionKey: string | null) {
const defaults = { thinkingLevel: 'medium', fastMode: null as boolean | null, verboseLevel: 'inherit', reasoningLevel: 'inherit' };
const defaults = {
thinkingLevel: 'medium',
fastMode: null as boolean | null,
verboseLevel: 'inherit',
reasoningLevel: 'inherit',
};
if (!sessionKey) return defaults;
try {
const sessFile = path.join(OPENCLAW_HOME, 'agents', agentId, 'sessions', 'sessions.json');
@@ -178,12 +215,19 @@ function getSessionSettingsInternal(agentId: string, sessionKey: string | null)
verboseLevel: entry.verboseLevel || defaults.verboseLevel,
reasoningLevel: entry.reasoningLevel || defaults.reasoningLevel,
};
} catch { return defaults; }
} catch {
return defaults;
}
}
// ── Agent execution ──
function runAgentViaGateway(agentId: string, message: string, sessionKey: string | null, emitter: SseEmitter) {
function runAgentViaGateway(
agentId: string,
message: string,
sessionKey: string | null,
emitter: SseEmitter
) {
const runId = crypto.randomUUID();
const listenerKey = `agent-${runId}`;
let assistantSent = '';
@@ -213,7 +257,7 @@ function runAgentViaGateway(agentId: string, message: string, sessionKey: string
else reasoningSent = clean;
emitter.send(
stream === 'assistant' ? 'response.output_text.delta' : 'response.thinking.delta',
newContent,
newContent
);
}
} else {
@@ -221,7 +265,7 @@ function runAgentViaGateway(agentId: string, message: string, sessionKey: string
else reasoningSent = clean;
emitter.send(
stream === 'assistant' ? 'response.output_text.delta' : 'response.thinking.delta',
clean,
clean
);
}
}
@@ -240,7 +284,8 @@ function runAgentViaGateway(agentId: string, message: string, sessionKey: string
params.sessionKey = fullKey;
}
gateway.request('agent', params, { expectFinal: true, timeoutMs: 120000 })
gateway
.request('agent', params, { expectFinal: true, timeoutMs: 120000 })
.then(() => {
gateway.offEvent(listenerKey);
if (sessionKey) {
@@ -258,9 +303,15 @@ function runAgentViaGateway(agentId: string, message: string, sessionKey: string
return { kill: () => gateway.offEvent(listenerKey) };
}
function runAgentWithEmitter(agentId: string, message: string, sessionKey: string | null, emitter: SseEmitter) {
function runAgentWithEmitter(
agentId: string,
message: string,
sessionKey: string | null,
emitter: SseEmitter
) {
const sessionSettings = getSessionSettingsInternal(agentId, sessionKey);
const thinkingArg = sessionSettings.thinkingLevel === 'inherit' ? 'medium' : sessionSettings.thinkingLevel;
const thinkingArg =
sessionSettings.thinkingLevel === 'inherit' ? 'medium' : sessionSettings.thinkingLevel;
const args = ['agent', '--agent', agentId, '-m', message, '--thinking', thinkingArg];
if (sessionSettings.reasoningLevel && sessionSettings.reasoningLevel !== 'inherit') {
args.push('--reasoning', sessionSettings.reasoningLevel);
@@ -302,7 +353,8 @@ function runAgentWithEmitter(agentId: string, message: string, sessionKey: strin
}
break;
}
const firstTag = (thinkIdx !== -1 && (outputIdx === -1 || thinkIdx < outputIdx)) ? thinkIdx : -1;
const firstTag =
thinkIdx !== -1 && (outputIdx === -1 || thinkIdx < outputIdx) ? thinkIdx : -1;
const tagIdx = firstTag !== -1 ? thinkIdx : outputIdx;
if (tagIdx > 0) emit(buf.slice(0, tagIdx), false);
if (firstTag !== -1) {
@@ -365,7 +417,12 @@ function runAgentWithEmitter(agentId: string, message: string, sessionKey: strin
.filter((l) => {
const trimmed = l.trim();
if (!trimmed) return false;
return trimmed.includes('Error') || trimmed.includes('error') || trimmed.includes('failed') || trimmed.includes('No API key');
return (
trimmed.includes('Error') ||
trimmed.includes('error') ||
trimmed.includes('failed') ||
trimmed.includes('No API key')
);
})
.join(' | ');
if (errorLines) {
@@ -386,15 +443,25 @@ function runAgentWithEmitter(agentId: string, message: string, sessionKey: strin
// ── Public API ──
const WORKSPACE_MARKDOWN_FILES = [
'AGENTS.md', 'SOUL.md', 'TOOLS.md', 'IDENTITY.md',
'USER.md', 'HEARTBEAT.md', 'BOOTSTRAP.md', 'MEMORY.md',
'AGENTS.md',
'SOUL.md',
'TOOLS.md',
'IDENTITY.md',
'USER.md',
'HEARTBEAT.md',
'BOOTSTRAP.md',
'MEMORY.md',
];
export function isAllowedWorkspaceFilename(name: string): boolean {
return typeof name === 'string' && WORKSPACE_MARKDOWN_FILES.includes(name);
}
export function listAgents(): { agentId: string; name: string; createdAt: Date }[] {
export function listAgents(): {
agentId: string;
name: string;
createdAt: Date;
}[] {
const agentsDir = path.join(OPENCLAW_HOME, 'agents');
if (!fs.existsSync(agentsDir)) return [];
@@ -410,7 +477,9 @@ export function listAgents(): { agentId: string; name: string; createdAt: Date }
try {
const identity = JSON.parse(fs.readFileSync(identityPath, 'utf-8'));
name = identity.name || aid;
} catch { /* keep agentId as name */ }
} catch {
/* keep agentId as name */
}
}
const stat = fs.statSync(path.join(agentsDir, aid));
return { agentId: aid, name, createdAt: stat.birthtime };
@@ -457,32 +526,50 @@ export function getSessionMessages(agentId: string, sessionKey: string): OpenCla
if (!fs.existsSync(sessionsFile)) return [];
try {
const raw = JSON.parse(fs.readFileSync(sessionsFile, 'utf-8'));
const sessionEntry = raw[`agent:${agentId}:${sessionKey}`]
|| Object.values(raw).find((v: any) => v.sessionId === sessionKey);
const sessionEntry =
raw[`agent:${agentId}:${sessionKey}`] ||
Object.values(raw).find((v: any) => v.sessionId === sessionKey);
if (!sessionEntry) return [];
const jsonlPath = (sessionEntry as any).sessionFile
|| path.join(OPENCLAW_HOME, 'agents', agentId, 'sessions', `${(sessionEntry as any).sessionId}.jsonl`);
const jsonlPath =
(sessionEntry as any).sessionFile ||
path.join(
OPENCLAW_HOME,
'agents',
agentId,
'sessions',
`${(sessionEntry as any).sessionId}.jsonl`
);
return parseMessagesFromJsonl(jsonlPath);
} catch {
return [];
}
}
export function deleteSessionMessage(agentId: string, sessionKey: string, externalId: string): boolean {
export function deleteSessionMessage(
agentId: string,
sessionKey: string,
externalId: string
): boolean {
const sessionsFile = path.join(OPENCLAW_HOME, 'agents', agentId, 'sessions', 'sessions.json');
try {
if (!fs.existsSync(sessionsFile)) return true;
const raw = JSON.parse(fs.readFileSync(sessionsFile, 'utf-8'));
const entry = raw[`agent:${agentId}:${sessionKey}`]
|| Object.values(raw).find((v: any) => v.sessionId === sessionKey);
const entry =
raw[`agent:${agentId}:${sessionKey}`] ||
Object.values(raw).find((v: any) => v.sessionId === sessionKey);
if (!(entry as any)?.sessionFile || !fs.existsSync((entry as any).sessionFile)) return true;
const lines = fs.readFileSync((entry as any).sessionFile, 'utf-8').trimEnd().split('\n');
const lines = fs
.readFileSync((entry as any).sessionFile, 'utf-8')
.trimEnd()
.split('\n');
const filtered = lines.filter((line) => {
try {
const parsed = JSON.parse(line);
return parsed.id !== externalId;
} catch { return true; }
} catch {
return true;
}
});
if (filtered.length < lines.length) {
@@ -515,15 +602,18 @@ export function getSessionSettings(agentId: string, sessionKey: string) {
export async function patchSessionSettings(
agentId: string,
sessionKey: string,
body: Record<string, any>,
body: Record<string, any>
): Promise<{ ok: boolean; error?: string }> {
const fullKey = `agent:${agentId}:${sessionKey}`;
const patch: Record<string, any> = { key: fullKey };
if (body.thinkingLevel !== undefined) patch.thinkingLevel = body.thinkingLevel === 'inherit' ? null : body.thinkingLevel;
if (body.thinkingLevel !== undefined)
patch.thinkingLevel = body.thinkingLevel === 'inherit' ? null : body.thinkingLevel;
if (body.fastMode !== undefined) patch.fastMode = body.fastMode === null ? null : !!body.fastMode;
if (body.verboseLevel !== undefined) patch.verboseLevel = body.verboseLevel === 'inherit' ? null : body.verboseLevel;
if (body.reasoningLevel !== undefined) patch.reasoningLevel = body.reasoningLevel === 'inherit' ? null : body.reasoningLevel;
if (body.verboseLevel !== undefined)
patch.verboseLevel = body.verboseLevel === 'inherit' ? null : body.verboseLevel;
if (body.reasoningLevel !== undefined)
patch.reasoningLevel = body.reasoningLevel === 'inherit' ? null : body.reasoningLevel;
if (body.label !== undefined) patch.label = body.label || null;
const gwReady = await gateway.ensureConnected();
@@ -539,8 +629,13 @@ export async function patchSessionSettings(
try {
const sessionsFile = path.join(OPENCLAW_HOME, 'agents', agentId, 'sessions', 'sessions.json');
const raw = fs.existsSync(sessionsFile) ? JSON.parse(fs.readFileSync(sessionsFile, 'utf-8')) : {};
const entry = raw[fullKey] || { sessionId: crypto.randomUUID(), updatedAt: Date.now() };
const raw = fs.existsSync(sessionsFile)
? JSON.parse(fs.readFileSync(sessionsFile, 'utf-8'))
: {};
const entry = raw[fullKey] || {
sessionId: crypto.randomUUID(),
updatedAt: Date.now(),
};
if (patch.thinkingLevel !== undefined) entry.thinkingLevel = patch.thinkingLevel;
if (patch.fastMode !== undefined) entry.fastMode = patch.fastMode;
if (patch.verboseLevel !== undefined) entry.verboseLevel = patch.verboseLevel;
@@ -614,13 +709,22 @@ export function getWorkspaceUploadPath(agentId: string, filename: string): strin
return fs.existsSync(fp) ? fp : null;
}
export function registerAgent(agentId: string): { ok: boolean; existed?: boolean; output?: string; error?: string } {
export function registerAgent(agentId: string): {
ok: boolean;
existed?: boolean;
output?: string;
error?: string;
} {
const workspace = agentWorkspacePath(agentId);
console.log(`[register] adding agent: ${agentId}, workspace: ${workspace}`);
try {
const output = execSync(
`openclaw agents add ${agentId} --non-interactive --workspace ${workspace} --json 2>&1`,
{ cwd: os.homedir(), env: { ...process.env, NO_COLOR: '1' }, timeout: 15000 },
{
cwd: os.homedir(),
env: { ...process.env, NO_COLOR: '1' },
timeout: 15000,
}
).toString();
console.log(`[register] success: ${output.trim()}`);
return { ok: true, output: output.trim() };
@@ -637,10 +741,11 @@ export function setAgentIdentity(agentId: string, name: string): { ok: boolean;
try {
const args = ['agents', 'set-identity', '--agent', agentId];
if (name) args.push('--name', name);
const output = execSync(
`openclaw ${args.map((a) => `"${a}"`).join(' ')} 2>&1`,
{ cwd: os.homedir(), env: { ...process.env, NO_COLOR: '1' }, timeout: 15000 },
).toString();
const output = execSync(`openclaw ${args.map((a) => `"${a}"`).join(' ')} 2>&1`, {
cwd: os.homedir(),
env: { ...process.env, NO_COLOR: '1' },
timeout: 15000,
}).toString();
console.log(`[set-identity] success: ${output.trim()}`);
return { ok: true };
} catch (err: any) {
@@ -650,13 +755,18 @@ export function setAgentIdentity(agentId: string, name: string): { ok: boolean;
}
}
export function removeAgent(agentId: string): { ok: boolean; notFound?: boolean; error?: string } {
export function removeAgent(agentId: string): {
ok: boolean;
notFound?: boolean;
error?: string;
} {
console.log(`[remove] removing agent: ${agentId}`);
try {
const output = execSync(
`openclaw agents delete ${agentId} --force --json 2>&1`,
{ cwd: os.homedir(), env: { ...process.env, NO_COLOR: '1' }, timeout: 15000 },
).toString();
const output = execSync(`openclaw agents delete ${agentId} --force --json 2>&1`, {
cwd: os.homedir(),
env: { ...process.env, NO_COLOR: '1' },
timeout: 15000,
}).toString();
console.log(`[remove] success: ${output.trim()}`);
const dir = agentDir(agentId);
if (fs.existsSync(dir)) {
@@ -667,7 +777,8 @@ export function removeAgent(agentId: string): { ok: boolean; notFound?: boolean;
} catch (err: any) {
const stderr = err.stderr?.toString() || err.stdout?.toString() || err.message;
console.error(`[remove] failed for "${agentId}":`, stderr);
if (stderr.includes('not found') || stderr.includes('Unknown agent')) return { ok: true, notFound: true };
if (stderr.includes('not found') || stderr.includes('Unknown agent'))
return { ok: true, notFound: true };
return { ok: false, error: stderr };
}
}
@@ -677,7 +788,7 @@ export async function runChat(
message: string,
sessionKey: string | null,
filePaths: string[],
res: Response,
res: Response
): Promise<void> {
let fullMessage = message || '';
if (filePaths.length) {
@@ -711,8 +822,8 @@ export async function runChat(
} else {
if (gwReady && !hasWriteScope) {
console.log(
'[chat] gateway connected but device-auth lacks operator.write — using CLI fallback. '
+ 'Fix: openclaw devices list → openclaw devices approve <id>',
'[chat] gateway connected but device-auth lacks operator.write — using CLI fallback. ' +
'Fix: openclaw devices list → openclaw devices approve <id>'
);
} else {
console.log('[chat] gateway unavailable, using CLI fallback');
@@ -721,7 +832,122 @@ export async function runChat(
}
}
export function copyFileToWorkspace(agentId: string, srcPath: string, originalName: string): string {
// ─── Model management ───
export interface ModelInfo {
id: string;
name: string;
provider: string;
input: string;
context: number;
local: boolean;
available: boolean;
tags: string[];
}
function loadModelsFromCli(): ModelInfo[] {
const raw = execSync('openclaw models list --all --json', {
encoding: 'utf-8',
timeout: 60000,
});
const data = JSON.parse(raw) as { models: Record<string, unknown>[] };
return data.models.map((m) => {
const key = String(m.key || '');
const slashIdx = key.indexOf('/');
return {
id: key,
name: String(m.name || key),
provider: slashIdx > 0 ? key.slice(0, slashIdx) : 'other',
input: String(m.input || 'text'),
context: Number(m.contextWindow) || 0,
local: Boolean(m.local),
available: Boolean(m.available),
tags: Array.isArray(m.tags) ? m.tags.map(String) : [],
};
});
}
export function listModels(availableOnly = true): ModelInfo[] {
try {
const models = loadModelsFromCli();
return availableOnly ? models.filter((m) => m.available) : models;
} catch (err) {
console.error('[models] failed to list models:', err);
return [];
}
}
function readOpenclawConfig(): Record<string, unknown> | null {
try {
const configPath = path.join(OPENCLAW_HOME, 'openclaw.json');
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
} catch {
return null;
}
}
/** One config read; maps OpenClaw agent ids to configured model id (for list endpoints). */
export function getAgentModelsForOpenclawIds(openclawIds: string[]): Record<string, string | null> {
const result: Record<string, string | null> = {};
const config = readOpenclawConfig();
if (!config) {
openclawIds.forEach((id) => {
result[id] = null;
});
return result;
}
const agents = config.agents as Record<string, unknown> | undefined;
if (!agents) {
openclawIds.forEach((id) => {
result[id] = null;
});
return result;
}
const list = (agents.list || []) as Record<string, unknown>[];
const defaults = agents.defaults as Record<string, unknown> | undefined;
const defModel = defaults?.model as Record<string, string> | undefined;
const fallback = defModel?.primary || null;
openclawIds.forEach((agentId) => {
const agentEntry = list.find((a) => a.id === agentId);
if (agentEntry?.model) {
const { model } = agentEntry;
result[agentId] =
typeof model === 'string' ? model : (model as Record<string, string>).primary || null;
} else {
result[agentId] = fallback;
}
});
return result;
}
export function getAgentModel(agentId: string): string | null {
return getAgentModelsForOpenclawIds([agentId])[agentId] ?? null;
}
export function setAgentModel(agentId: string, modelId: string): boolean {
const config = readOpenclawConfig();
if (!config) return false;
const agents = config.agents as Record<string, unknown> | undefined;
const list = (agents?.list || []) as { id: string }[];
const idx = list.findIndex((a) => a.id === agentId);
if (idx === -1) return false;
try {
execSync(`openclaw config set agents.list[${idx}].model "${modelId}"`, {
timeout: 15000,
});
return true;
} catch (err) {
console.error('[models] failed to set agent model:', err);
return false;
}
}
export function copyFileToWorkspace(
agentId: string,
srcPath: string,
originalName: string
): string {
const workDir = agentWorkspacePath(agentId);
if (!fs.existsSync(workDir)) fs.mkdirSync(workDir, { recursive: true });
const dest = path.join(workDir, originalName);
+311
View File
@@ -0,0 +1,311 @@
import { Server as HttpServer } from 'http';
import { spawn, execSync, ChildProcess } from 'child_process';
import fs from 'fs';
import path from 'path';
import * as pty from 'node-pty';
import { WebSocketServer, WebSocket } from 'ws';
import { URL } from 'url';
import jwt from 'jsonwebtoken';
function resolveBridge(): string {
const candidates = [
path.join(__dirname, '..', '..', 'pty-bridge.py'),
path.join(__dirname, '..', 'pty-bridge.py'),
path.resolve('pty-bridge.py'),
path.resolve('api', 'pty-bridge.py'),
];
const found = candidates.find((p) => fs.existsSync(p));
if (found) return found;
console.error(
`[pty] bridge script not found, tried: ${candidates.join(', ')}`
); /* eslint-disable-line */
return candidates[0];
}
const BRIDGE_SCRIPT = resolveBridge();
function verifyToken(token: string): boolean {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
id?: string;
valid?: string;
};
return !!(payload.id && payload.valid);
} catch {
return false;
}
}
function findBinary(name: string): string {
try {
return execSync(`which ${name}`, { encoding: 'utf8' }).trim();
} catch {
return name;
}
}
/** Strip undefined so node-pty / spawn do not get invalid env values. */
function cleanEnv(env: NodeJS.ProcessEnv): { [key: string]: string } {
const out: { [key: string]: string } = {};
Object.keys(env).forEach((k) => {
const v = env[k];
if (v !== undefined) out[k] = v;
});
return out;
}
function isPosixSpawnFailure(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return msg.includes('posix_spawn');
}
function trySpawnNodePty(openclawBin: string, agentName: string): pty.IPty {
const opts = {
name: 'xterm-256color' as const,
cols: 80,
rows: 24,
cwd: process.env.HOME || '/tmp',
env: cleanEnv({ ...process.env, TERM: 'xterm-256color' }),
};
const attempts: [string, string[]][] = [[openclawBin, ['agents', 'add', agentName]]];
try {
const resolved = fs.realpathSync(openclawBin);
if (resolved !== openclawBin) {
attempts.push([resolved, ['agents', 'add', agentName]]);
}
} catch {
/* keep single attempt */
}
let lastErr: unknown;
for (let i = 0; i < attempts.length; i += 1) {
const [file, args] = attempts[i];
try {
return pty.spawn(file, args, opts);
} catch (e) {
lastErr = e;
if (!isPosixSpawnFailure(e)) {
if (e instanceof Error) throw e;
throw new Error(String(e));
}
}
}
if (lastErr instanceof Error) throw lastErr;
if (lastErr) throw new Error(String(lastErr));
throw new Error('node-pty: exhausted spawn attempts');
}
function spawnPythonBridge(
python3Bin: string,
openclawBin: string,
agentName: string
): ChildProcess {
return spawn(
python3Bin,
['-u', BRIDGE_SCRIPT, '80', '24', openclawBin, 'agents', 'add', agentName],
{
cwd: process.env.HOME || '/tmp',
env: process.env as NodeJS.ProcessEnv,
stdio: ['pipe', 'pipe', 'pipe'],
}
);
}
function attachPythonBridge(ws: WebSocket, child: ChildProcess): void {
child.stdout?.on('data', (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data.toString());
}
});
child.stderr?.on('data', (data: Buffer) => {
const text = data.toString();
console.error(`[pty] stderr: ${text}`); /* eslint-disable-line */
if (ws.readyState === WebSocket.OPEN) {
ws.send(text);
}
});
child.on('error', (err) => {
console.error(`[pty] child error: ${err.message}`); /* eslint-disable-line */
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
ws.close();
}
});
child.on('exit', (code) => {
console.log(`[pty] process exited with code ${code}`); /* eslint-disable-line */
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'exit', code: code ?? 1 }));
ws.close();
}
});
ws.on('message', (raw: Buffer | string) => {
const msg = raw.toString();
if (msg.startsWith('{"type":')) {
try {
const ctrl = JSON.parse(msg) as { type?: string; cols?: number; rows?: number };
if (ctrl.type === 'resize' && child.stdin?.writable) {
child.stdin.write(`${JSON.stringify(ctrl)}\n`);
}
} catch {
/* ignore */
}
} else if (child.stdin?.writable) {
child.stdin.write(msg);
}
});
ws.on('close', () => {
console.log(`[pty] WebSocket closed, killing pid=${child.pid}`); /* eslint-disable-line */
try {
child.kill();
} catch {
/* already dead */
}
});
}
function attachNodePty(ws: WebSocket, term: pty.IPty): void {
term.onData((data: string) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
term.onExit(({ exitCode, signal }) => {
const code = signal !== undefined ? 128 + signal : (exitCode ?? 1);
console.log(`[pty] process exited code=${code}`); /* eslint-disable-line */
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'exit', code }));
ws.close();
}
});
ws.on('message', (raw: Buffer | string) => {
const msg = raw.toString();
if (msg.startsWith('{"type":')) {
try {
const ctrl = JSON.parse(msg) as { type?: string; cols?: number; rows?: number };
if (
ctrl.type === 'resize' &&
typeof ctrl.cols === 'number' &&
typeof ctrl.rows === 'number'
) {
term.resize(ctrl.cols, ctrl.rows);
}
} catch {
/* ignore malformed control messages */
}
} else {
term.write(msg);
}
});
ws.on('close', () => {
console.log(`[pty] WebSocket closed, killing pid=${term.pid}`); /* eslint-disable-line */
try {
term.kill();
} catch {
/* already dead */
}
});
}
export default function attachPtyWebSocket(server: HttpServer): void {
const wss = new WebSocketServer({ noServer: true });
const openclawBin = findBinary('openclaw');
const python3Bin = findBinary('python3');
console.log(
`[pty] openclaw: ${openclawBin}, python3: ${python3Bin}, bridge: ${BRIDGE_SCRIPT}`
); /* eslint-disable-line */
server.on('upgrade', (req, socket, head) => {
const parsed = new URL(req.url || '', `http://${req.headers.host}`);
if (parsed.pathname !== '/ws/pty') {
return;
}
const token = parsed.searchParams.get('token');
if (!token || !verifyToken(token)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
});
wss.on('connection', (ws: WebSocket, req) => {
const parsed = new URL(req.url || '', `http://${req.headers.host}`);
const agentName = parsed.searchParams.get('agent');
if (!agentName) {
ws.send(JSON.stringify({ type: 'error', message: 'Missing agent parameter' }));
ws.close();
return;
}
const forcePython = process.env.PTY_BACKEND === 'python';
if (!forcePython) {
try {
const term = trySpawnNodePty(openclawBin, agentName);
console.log(
`[pty] node-pty pid=${term.pid} agent="${agentName}"`
); /* eslint-disable-line */
attachNodePty(ws, term);
return;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (!isPosixSpawnFailure(err)) {
console.error(`[pty] spawn failed: ${msg}`); /* eslint-disable-line */
ws.send(JSON.stringify({ type: 'error', message: `Failed to start PTY: ${msg}` }));
ws.close();
return;
}
console.warn(
`[pty] node-pty failed (${msg}), falling back to Python bridge`
); /* eslint-disable-line */
}
} else {
console.log('[pty] PTY_BACKEND=python, skipping node-pty'); /* eslint-disable-line */
}
if (!fs.existsSync(BRIDGE_SCRIPT)) {
ws.send(
JSON.stringify({
type: 'error',
message:
'PTY failed: node-pty could not start and pty-bridge.py was not found. Set PTY_BACKEND=python only if Python 3 is installed.',
})
);
ws.close();
return;
}
let child: ChildProcess;
try {
child = spawnPythonBridge(python3Bin, openclawBin, agentName);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[pty] Python bridge spawn failed: ${msg}`); /* eslint-disable-line */
ws.send(JSON.stringify({ type: 'error', message: `Failed to start PTY: ${msg}` }));
ws.close();
return;
}
console.log(
`[pty] Python bridge pid=${child.pid} agent="${agentName}"`
); /* eslint-disable-line */
attachPythonBridge(ws, child);
});
console.log('[pty] WebSocket server attached at /ws/pty'); /* eslint-disable-line */
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

+6094 -10
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -12,10 +12,13 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@lobehub/icons": "^5.4.0",
"@mui/icons-material": "^7.3.7",
"@mui/material": "^7.3.7",
"@reduxjs/toolkit": "^2.11.2",
"@types/redux-logger": "^3.0.13",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"file-saver": "^2.0.5",
"formik": "^2.4.9",
"highlight.js": "^11.11.1",
+42 -1
View File
@@ -6,6 +6,8 @@ export interface Agent {
openclawAgentId: string;
createdAt: string;
updatedAt: string;
/** OpenClaw model id from config; included on GET /agent list and GET /agent/:id */
model?: string | null;
}
export interface AgentsResponse {
@@ -44,6 +46,27 @@ export interface SessionSettingsResponse {
settings: Partial<SessionSettings>;
}
export interface ModelInfo {
id: string;
name: string;
provider: string;
input: string;
context: number;
local: boolean;
available: boolean;
tags: string[];
}
export interface ModelsResponse {
ok: boolean;
models: ModelInfo[];
}
export interface AgentModelResponse {
ok: boolean;
model: string | null;
}
export const WORKSPACE_TAB_FILES = [
{ label: 'AGENTS', file: 'AGENTS.md' },
{ label: 'SOUL', file: 'SOUL.md' },
@@ -65,7 +88,7 @@ export const agentsApi = baseApi.injectEndpoints({
query: (id) => `/agent/${id}`,
providesTags: ['Agent'],
}),
createAgent: build.mutation<Agent, { name: string; openclawAgentId?: string }>({
createAgent: build.mutation<Agent, { name: string; openclawAgentId?: string; interactive?: boolean }>({
query: (body) => ({
url: '/agent',
method: 'POST',
@@ -143,6 +166,21 @@ export const agentsApi = baseApi.injectEndpoints({
{ type: 'SessionSettings', id: conversationId },
],
}),
listModels: build.query<ModelsResponse, void>({
query: () => '/agent/models',
}),
getAgentModel: build.query<AgentModelResponse, string>({
query: (agentId) => `/agent/${agentId}/model`,
providesTags: (_res, _err, agentId) => [{ type: 'AgentModel', id: agentId }],
}),
setAgentModel: build.mutation<{ ok: boolean; model: string }, { agentId: string; model: string }>({
query: ({ agentId, model }) => ({
url: `/agent/${agentId}/model`,
method: 'PATCH',
body: { model },
}),
invalidatesTags: (_res, _err, { agentId }) => [{ type: 'AgentModel', id: agentId }, 'Agent'],
}),
}),
});
@@ -158,4 +196,7 @@ export const {
useSaveWorkspaceFileMutation,
useGetSessionSettingsQuery,
usePatchSessionSettingsMutation,
useListModelsQuery,
useGetAgentModelQuery,
useSetAgentModelMutation,
} = agentsApi;
+1
View File
@@ -58,6 +58,7 @@ export const baseApi = createApi({
'Workspace',
'WorkspaceFile',
'SessionSettings',
'AgentModel',
],
endpoints: () => ({}),
});
+41
View File
@@ -0,0 +1,41 @@
import { Box } from '@mui/material';
import { SmartToy } from '@mui/icons-material';
import { ModelIcon, modelMappings } from '@lobehub/icons';
interface ProviderLogoProps {
modelId: string | null | undefined;
size?: number;
fallback?: React.ReactNode;
}
function hasKnownModelIcon(modelId: string): boolean {
const model = modelId.toLowerCase();
return modelMappings.some((item) =>
item.keywords.some((keyword) => new RegExp(keyword, 'i').test(model)),
);
}
export default function ProviderLogo({ modelId, size = 14, fallback }: ProviderLogoProps) {
const robot = fallback ?? <SmartToy sx={{ fontSize: size }} />;
if (!modelId?.trim() || !hasKnownModelIcon(modelId)) {
return <>{robot}</>;
}
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: size,
height: size,
lineHeight: 0,
flexShrink: 0,
'& svg': { display: 'block' },
}}
>
<ModelIcon model={modelId} size={size} type="color" />
</Box>
);
}
+16 -3
View File
@@ -15,14 +15,16 @@ import { useLocation, useNavigate } from 'react-router';
import { useDeleteAgentMutation } from '../../entities/agent/api';
import { useCreateConversationMutation } from '../../entities/conversation/api';
import DeleteButton from '../../shared/ui/DeleteButton';
import ProviderLogo from '../../shared/ui/ProviderLogo';
import ConversationItem from './ConversationItem';
interface AgentSectionProps {
agent: { _id: string; name: string };
agent: { _id: string; name: string; model?: string | null };
conversations: { _id: string; title: string | null; createdAt: string }[];
searchQuery?: string;
collapseKey?: number;
onNavigate?: () => void;
disabled?: boolean;
}
export default function AgentSection({
@@ -31,6 +33,7 @@ export default function AgentSection({
searchQuery,
collapseKey,
onNavigate,
disabled,
}: AgentSectionProps) {
const location = useLocation();
const navigate = useNavigate();
@@ -52,6 +55,7 @@ export default function AgentSection({
const [createConversation] = useCreateConversationMutation();
const [deleteAgent] = useDeleteAgentMutation();
const modelId = agent.model ?? null;
const isSearchActive = Boolean(searchQuery);
const agentNameMatches = searchQuery
@@ -83,7 +87,12 @@ export default function AgentSection({
<>
<ListItem
disablePadding
sx={{ mb: 0.2 }}
sx={{
mb: 0.2,
opacity: disabled ? 0.4 : 1,
pointerEvents: disabled ? 'none' : 'auto',
transition: 'opacity 0.2s',
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
@@ -99,7 +108,11 @@ export default function AgentSection({
<ListItemIcon
sx={{ minWidth: 24, color: isAgentActive ? sidebar.selectedBorder : sidebar.text }}
>
<SmartToy sx={{ fontSize: 16 }} />
<ProviderLogo
modelId={modelId}
size={16}
fallback={<SmartToy sx={{ fontSize: 16 }} />}
/>
</ListItemIcon>
<ListItemText
primary={agent.name}
@@ -0,0 +1,307 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import {
Box,
Drawer,
SwipeableDrawer,
Typography,
IconButton,
useTheme,
useMediaQuery,
} from '@mui/material';
import { Close, CheckCircle } from '@mui/icons-material';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import { API_BASE_URL } from '../../shared/api/baseApi';
import { useSyncAgentsMutation, useDeleteAgentMutation } from '../../entities/agent/api';
interface TerminalPanelProps {
agentName: string;
agentDbId: string;
open: boolean;
onClose: () => void;
onDeleting?: (id: string | null) => void;
}
function buildWsUrl(agentName: string): string {
const base = API_BASE_URL.replace(/\/api\/?$/, '');
const wsBase = base.replace(/^http/, 'ws');
const token = localStorage.getItem('token') || '';
return `${wsBase}/ws/pty?agent=${encodeURIComponent(agentName)}&token=${encodeURIComponent(token)}`;
}
const AUTO_CLOSE_MS = 2500;
export default function TerminalPanel({ agentName, agentDbId, open, onClose, onDeleting }: TerminalPanelProps) {
const theme = useTheme();
const isCompact = useMediaQuery(theme.breakpoints.down('md'));
const { sidebar } = theme.palette;
const bg = sidebar.background;
const headerBg = sidebar.hover;
const border = sidebar.border;
const textColor = sidebar.text;
const accentColor = sidebar.selectedBorder;
const containerRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const observerRef = useRef<ResizeObserver | null>(null);
const autoCloseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [syncAgents] = useSyncAgentsMutation();
const [deleteAgent] = useDeleteAgentMutation();
const [done, setDone] = useState(false);
const doneRef = useRef(false);
const [ready, setReady] = useState(false);
const handleClose = useCallback(async () => {
onClose();
if (!doneRef.current) {
onDeleting?.(agentDbId);
await deleteAgent(agentDbId);
onDeleting?.(null);
}
syncAgents();
}, [agentDbId, deleteAgent, syncAgents, onClose, onDeleting]);
const cleanup = useCallback(() => {
if (autoCloseTimer.current) clearTimeout(autoCloseTimer.current);
autoCloseTimer.current = null;
observerRef.current?.disconnect();
observerRef.current = null;
wsRef.current?.close();
wsRef.current = null;
termRef.current?.dispose();
termRef.current = null;
fitRef.current = null;
}, []);
const containerCallbackRef = useCallback((node: HTMLDivElement | null) => {
containerRef.current = node;
if (node) setReady(true);
}, []);
useEffect(() => {
if (!ready || !open || !containerRef.current) return;
if (termRef.current) return;
const container = containerRef.current;
const narrow =
typeof window !== 'undefined' && window.matchMedia('(max-width:899px)').matches;
const term = new Terminal({
cursorBlink: true,
fontSize: narrow ? 12 : 13,
fontFamily: '"Fira Code", "Cascadia Code", Menlo, Monaco, monospace',
theme: {
background: bg,
foreground: theme.palette.text.primary,
cursor: accentColor,
selectionBackground: sidebar.hover,
},
convertEol: true,
scrollback: 1000,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(container);
termRef.current = term;
fitRef.current = fit;
requestAnimationFrame(() => {
fit.fit();
initWebSocket(term);
});
const observer = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (fitRef.current) {
fitRef.current.fit();
if (wsRef.current?.readyState === WebSocket.OPEN && termRef.current) {
const { cols, rows } = termRef.current;
wsRef.current.send(JSON.stringify({ type: 'resize', cols, rows }));
}
}
});
});
observer.observe(container);
observerRef.current = observer;
function initWebSocket(t: Terminal) {
const url = buildWsUrl(agentName);
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
const { cols, rows } = t;
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
};
ws.onmessage = (event) => {
const data = event.data as string;
if (data.startsWith('{"type":')) {
try {
const msg = JSON.parse(data);
if (msg.type === 'exit') {
setDone(true);
doneRef.current = true;
t.write('\r\n\x1b[32m--- Setup complete ---\x1b[0m\r\n');
syncAgents();
autoCloseTimer.current = setTimeout(handleClose, AUTO_CLOSE_MS);
} else if (msg.type === 'error') {
t.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`);
}
} catch {
t.write(data);
}
} else {
t.write(data);
}
};
ws.onerror = () => {
t.write('\r\n\x1b[31mConnection error\x1b[0m\r\n');
};
ws.onclose = () => {
if (!done) {
t.write('\r\n\x1b[33mDisconnected\x1b[0m\r\n');
}
};
t.onData((d) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(d);
}
});
}
return () => {
cleanup();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, open, agentName]);
useEffect(() => {
if (!open) {
cleanup();
setReady(false);
setDone(false);
doneRef.current = false;
}
}, [open, cleanup]);
const paperSx = {
width: { xs: '100%', md: 'min(720px, 50vw)' },
minWidth: { xs: 0, md: 480 },
maxWidth: { xs: '100%', md: 720 },
height: { xs: '100%', md: '100%' },
maxHeight: { xs: '100dvh', md: '100vh' },
bgcolor: bg,
borderRight: { xs: 'none', md: `1px solid ${border}` },
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
pt: 'env(safe-area-inset-top)',
pb: 'env(safe-area-inset-bottom)',
pl: 'env(safe-area-inset-left)',
} as const;
const header = (
<Box
sx={{
display: 'flex',
alignItems: 'center',
px: { xs: 1.5, sm: 2 },
py: { xs: 1.25, sm: 1 },
minHeight: { xs: 48, sm: 'auto' },
bgcolor: headerBg,
borderBottom: `1px solid ${border}`,
flexShrink: 0,
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: done ? 'success.main' : accentColor,
mr: 1.5,
flexShrink: 0,
animation: done ? 'none' : 'pulse 1.5s ease-in-out infinite',
'@keyframes pulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.4 },
},
}}
/>
<Typography
sx={{
color: sidebar.selectedText,
fontSize: { xs: '0.8rem', sm: '0.85rem' },
fontWeight: 600,
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
Setting up: {agentName}
</Typography>
{done && <CheckCircle sx={{ color: 'success.main', fontSize: { xs: 18, sm: 16 }, mr: 1, flexShrink: 0 }} />}
<IconButton
size={isCompact ? 'medium' : 'small'}
onClick={handleClose}
sx={{ color: textColor, flexShrink: 0 }}
aria-label="Close"
>
<Close sx={{ fontSize: { xs: 22, sm: 16 } }} />
</IconButton>
</Box>
);
const terminalBox = (
<Box
ref={containerCallbackRef}
sx={{
flex: 1,
minHeight: 0,
p: { xs: 0.75, sm: 1 },
overflow: 'hidden',
'& .xterm': { height: '100%' },
'& .xterm-viewport': { overflow: 'hidden !important' },
}}
/>
);
if (isCompact) {
return (
<SwipeableDrawer
anchor="left"
open={open}
onClose={handleClose}
onOpen={() => {}}
disableSwipeToOpen
ModalProps={{
keepMounted: true,
}}
sx={{
'& .MuiDrawer-paper': paperSx,
}}
>
{header}
{terminalBox}
</SwipeableDrawer>
);
}
return (
<Drawer anchor="left" open={open} onClose={handleClose} sx={{ '& .MuiDrawer-paper': paperSx }}>
{header}
{terminalBox}
</Drawer>
);
}
+77 -4
View File
@@ -11,6 +11,10 @@ import {
TextField,
IconButton,
CircularProgress,
Select,
MenuItem,
InputAdornment,
Divider,
} from '@mui/material';
import { People, Add, Search, KeyboardDoubleArrowUp, SwapVert } from '@mui/icons-material';
import { Link, useLocation } from 'react-router';
@@ -22,6 +26,7 @@ import {
import { useGetAllConversationsQuery } from '../../entities/conversation/api';
import ThemePicker from '../../features/theme/ThemePicker';
import AgentSection from './AgentSection';
import TerminalPanel from './TerminalPanel';
export const SIDEBAR_WIDTH = 240;
@@ -34,9 +39,12 @@ export default function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const [showNewAgent, setShowNewAgent] = useState(false);
const [newAgentName, setNewAgentName] = useState('');
const [createMode, setCreateMode] = useState<'quick' | 'configure'>('quick');
const [searchQuery, setSearchQuery] = useState('');
const [collapseKey, setCollapseKey] = useState(0);
const [sortAlpha, setSortAlpha] = useState(false);
const [terminalAgent, setTerminalAgent] = useState<{ slug: string; dbId: string } | null>(null);
const [deletingAgentId, setDeletingAgentId] = useState<string | null>(null);
const { data: agentsData, isLoading: agentsLoading } = useGetAgentsQuery();
const { data: convData } = useGetAllConversationsQuery();
@@ -72,9 +80,14 @@ export default function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
if (!name) return;
setCreateError('');
try {
await createAgent({ name }).unwrap();
const interactive = createMode === 'configure';
const saved = await createAgent({ name, interactive }).unwrap();
const slug = saved.openclawAgentId || name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
setNewAgentName('');
setShowNewAgent(false);
if (interactive) {
setTerminalAgent({ slug, dbId: saved._id });
}
} catch (err: unknown) {
const data = (err as { data?: Record<string, string[]> })?.data;
const msg =
@@ -292,7 +305,12 @@ export default function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
</IconButton>
<IconButton
size="small"
onClick={() => setShowNewAgent(!showNewAgent)}
onClick={() => {
setShowNewAgent((prev) => {
if (!prev) setCreateMode('quick');
return !prev;
});
}}
sx={{ color: sidebar.text, p: 0.3, '&:hover': { color: 'success.main' } }}
>
<Add sx={{ fontSize: 16 }} />
@@ -343,11 +361,51 @@ export default function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
onKeyDown={handleKeyDown}
disabled={isCreating}
error={!!createError}
helperText={createError}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end" sx={{ ml: 0 }}>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5, borderColor: sidebar.border }} />
<Select
size="small"
value={createMode}
onChange={(e) => setCreateMode(e.target.value as 'quick' | 'configure')}
variant="standard"
disableUnderline
sx={{
fontSize: '0.7rem',
color: sidebar.selectedText,
'& .MuiSelect-select': { py: 0, pr: '18px !important', pl: 0.5 },
'& .MuiSvgIcon-root': { color: sidebar.text, fontSize: 14, right: 0 },
}}
MenuProps={{
PaperProps: {
sx: {
bgcolor: sidebar.background,
border: `1px solid ${sidebar.border}`,
'& .MuiMenuItem-root': {
fontSize: '0.7rem',
color: sidebar.text,
py: 0.5,
'&.Mui-selected': { bgcolor: sidebar.hover, color: sidebar.selectedText },
'&:hover': { bgcolor: sidebar.hover },
},
},
},
}}
>
<MenuItem value="quick">Quick add</MenuItem>
<MenuItem value="configure">Configure</MenuItem>
</Select>
</InputAdornment>
),
},
}}
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: sidebar.hover,
borderRadius: 1.5,
pr: 0.5,
'& fieldset': { borderColor: createError ? 'error.main' : 'transparent' },
'&:hover fieldset': { borderColor: createError ? 'error.main' : sidebar.text },
'&.Mui-focused fieldset': {
@@ -355,9 +413,13 @@ export default function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
},
'& input': { color: sidebar.selectedText, fontSize: '0.8rem', py: 0.8, px: 1.5 },
},
'& .MuiFormHelperText-root': { fontSize: '0.65rem', mx: 0.5 },
}}
/>
{createError && (
<Typography sx={{ color: 'error.main', fontSize: '0.65rem', mx: 0.5, mt: 0.25 }}>
{createError}
</Typography>
)}
</Box>
)}
@@ -389,6 +451,7 @@ export default function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
searchQuery={searchQuery || undefined}
collapseKey={collapseKey}
onNavigate={onNavigate}
disabled={deletingAgentId === agent._id}
/>
))}
</List>
@@ -397,6 +460,16 @@ export default function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
</Box>
<ThemePicker />
{terminalAgent && (
<TerminalPanel
agentName={terminalAgent.slug}
agentDbId={terminalAgent.dbId}
open
onClose={() => setTerminalAgent(null)}
onDeleting={setDeletingAgentId}
/>
)}
</Box>
);
}
+5
View File
@@ -40,6 +40,11 @@ mkdirSync(CLIENT_DIST, { recursive: true });
// Copy API build + package.json
cpSync(path.join(API_SRC, 'build'), path.join(API_DIST, 'build'), { recursive: true, force: true });
const ptyBridgeSrc = path.join(API_SRC, 'pty-bridge.py');
const ptyBridgeDst = path.join(API_DIST, 'build', 'pty-bridge.py');
if (existsSync(ptyBridgeSrc)) {
cpSync(ptyBridgeSrc, ptyBridgeDst, { force: true });
}
cpSync(path.join(API_SRC, 'package.json'), path.join(API_DIST, 'package.json'));
cpSync(path.join(API_SRC, 'package-lock.json'), path.join(API_DIST, 'package-lock.json'));