mirror of
https://github.com/lotsoftick/openclaw_client.git
synced 2026-08-14 00:48:07 +00:00
multi model support
This commit is contained in:
@@ -6,4 +6,5 @@ api/data/
|
||||
api/build/
|
||||
client/dist/
|
||||
api/src/public/uploads/
|
||||
.vscode/
|
||||
openclaw.log
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"jsxSingleQuote": false
|
||||
}
|
||||
+13
-1
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Generated
+34
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 |
Generated
+6094
-10
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -58,6 +58,7 @@ export const baseApi = createApi({
|
||||
'Workspace',
|
||||
'WorkspaceFile',
|
||||
'SessionSettings',
|
||||
'AgentModel',
|
||||
],
|
||||
endpoints: () => ({}),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user