feat: update webui extensions, models, and agent config
This commit is contained in:
@@ -45,12 +45,25 @@ export function createWebUiPaths(): WebUiPaths {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePiRpcLaunch(paths: WebUiPaths): { command: string; args: string[]; mode: "dist" } {
|
||||
export type PiRpcLaunchMode = "dist" | "tsx";
|
||||
|
||||
export function resolvePiRpcLaunch(
|
||||
paths: WebUiPaths,
|
||||
): { command: string; args: string[]; mode: PiRpcLaunchMode } {
|
||||
const rpcArgs = ["--mode", "rpc"];
|
||||
if (!existsSync(paths.piCliDist)) {
|
||||
throw new Error(
|
||||
`[webui] 构建版 sproutclaw 未找到: ${paths.piCliDist}。请先运行: sproutclaw build`,
|
||||
);
|
||||
if (existsSync(paths.piCliDist)) {
|
||||
return { command: process.execPath, args: [paths.piCliDist, ...rpcArgs], mode: "dist" };
|
||||
}
|
||||
return { command: process.execPath, args: [paths.piCliDist, ...rpcArgs], mode: "dist" };
|
||||
|
||||
const tsxCli = join(paths.repoRoot, "node_modules", "tsx", "dist", "cli.mjs");
|
||||
const piCliSrc = join(paths.repoRoot, "packages", "coding-agent", "src", "cli.ts");
|
||||
if (existsSync(tsxCli) && existsSync(piCliSrc)) {
|
||||
return { command: process.execPath, args: [tsxCli, piCliSrc, ...rpcArgs], mode: "tsx" };
|
||||
}
|
||||
|
||||
const buildHint =
|
||||
process.platform === "win32"
|
||||
? "请先运行: npm run build 或 pi-built.bat"
|
||||
: "请先运行: npm run build 或 sproutclaw build";
|
||||
throw new Error(`[webui] pi 未找到: ${paths.piCliDist}。${buildHint}`);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
|
||||
import { applyCorsHeaders } from "./cors.ts";
|
||||
import { serveStatic } from "./static.ts";
|
||||
import { handleChatRoute } from "../routes/chat.ts";
|
||||
import { handleExtensionUiRoute } from "../routes/extension-ui.ts";
|
||||
import { handleCommandsRoute } from "../routes/commands.ts";
|
||||
import { handleModelsRoute } from "../routes/models.ts";
|
||||
import { handleSessionsRoute } from "../routes/sessions.ts";
|
||||
@@ -24,6 +25,7 @@ export function createWebUiServer(ctx: WebUiContext) {
|
||||
}
|
||||
|
||||
if (handleChatRoute(req, res, ctx, pathname)) return;
|
||||
if (handleExtensionUiRoute(req, res, ctx, pathname)) return;
|
||||
if (handleSessionsRoute(req, res, ctx, pathname)) return;
|
||||
if (handleModelsRoute(req, res, ctx, pathname)) return;
|
||||
if (handleWebuiConfigRoute(req, res, ctx, pathname, url)) return;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import type { ServerResponse, IncomingMessage } from "node:http";
|
||||
import { isPathInsideRoot } from "../utils/paths.ts";
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
@@ -40,7 +41,9 @@ export function serveStatic(
|
||||
): void {
|
||||
const file = urlPath === "/" ? "/index.html" : urlPath;
|
||||
const full = join(publicDir, file);
|
||||
if (!full.startsWith(publicDir)) {
|
||||
const resolvedPublic = resolve(publicDir);
|
||||
const resolvedFull = resolve(full);
|
||||
if (!isPathInsideRoot(resolvedPublic, resolvedFull) && resolvedFull !== resolvedPublic) {
|
||||
res.writeHead(403);
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { arch, hostname, platform, release, type } from "node:os";
|
||||
import { parsePort } from "./config/cli.ts";
|
||||
import { createWebUiPaths } from "./config/paths.ts";
|
||||
import { closeWebuiDatabase, initWebuiDatabase } from "./db/index.ts";
|
||||
@@ -47,18 +48,27 @@ function shutdown(exitCode = 0): never {
|
||||
|
||||
const webuiDb = initWebuiDatabase(paths.extensionRoot);
|
||||
console.log(`[webui] 配置数据库: ${webuiDb.dbPath}`);
|
||||
console.log(`[webui] 运行环境: Node.js ${process.version} | ${type()} ${release()} (${platform()}/${arch()}) | 主机: ${hostname()}`);
|
||||
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
writePidFile();
|
||||
|
||||
const piClient = createPiClient(paths, () => shutdown(1));
|
||||
const piClient = createPiClient(paths, (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(`[webui] pi RPC 进程异常退出 (code=${code}),WebUI 即将关闭`);
|
||||
setTimeout(() => shutdown(1), 500);
|
||||
return;
|
||||
}
|
||||
shutdown(1);
|
||||
});
|
||||
|
||||
const ctx: WebUiContext = {
|
||||
config: { paths, port },
|
||||
rpc: {
|
||||
sendCmd: piClient.sendCmd,
|
||||
submitPrompt: piClient.submitPrompt,
|
||||
sendExtensionUiResponse: piClient.sendExtensionUiResponse,
|
||||
getRunSnapshot: piClient.getRunSnapshot,
|
||||
connectSseClient: piClient.connectSseClient,
|
||||
removeSseClient: piClient.removeSseClient,
|
||||
@@ -79,5 +89,5 @@ server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`[webui] HTTP 服务已启动: http://localhost:${port}`);
|
||||
console.log(`[webui] 局域网访问: http://smallmengya:${port}`);
|
||||
console.log(`[webui] 局域网访问: http://${hostname()}:${port}`);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,17 @@ import { normalizeChatImages } from "../services/chat-images.ts";
|
||||
import type { WebUiContext } from "../types/context.ts";
|
||||
import { json, readBody } from "../http/request.ts";
|
||||
|
||||
async function trySlashDispatch(
|
||||
msg: string,
|
||||
imgs: ReturnType<typeof normalizeChatImages>,
|
||||
sendCmd: WebUiContext["rpc"]["sendCmd"],
|
||||
): Promise<Awaited<ReturnType<typeof dispatchSlashCommand>> | null> {
|
||||
const trimmed = msg.trim();
|
||||
if (!trimmed.startsWith("/")) return null;
|
||||
const slash = await dispatchSlashCommand(trimmed, sendCmd);
|
||||
return slash?.handled ? slash : null;
|
||||
}
|
||||
|
||||
export function handleChatRoute(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
@@ -14,32 +25,66 @@ export function handleChatRoute(
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/chat") {
|
||||
void readBody(req)
|
||||
.then(async ({ message, images }) => {
|
||||
.then(async ({ message, images, streamingBehavior }) => {
|
||||
const msg = typeof message === "string" ? message : "";
|
||||
const imgs = normalizeChatImages(images);
|
||||
if (!msg.trim() && !imgs?.length) throw new Error("消息不能为空");
|
||||
if (imgs?.length) console.log(`[webui] chat: ${imgs.length} image(s), message=${msg.length} chars`);
|
||||
|
||||
if (!imgs?.length && msg.trim().startsWith("/")) {
|
||||
const slash = await dispatchSlashCommand(msg, sendCmd);
|
||||
if (slash?.handled) {
|
||||
return json(res, { ok: true, slash: true, ...slash });
|
||||
}
|
||||
const slash = await trySlashDispatch(msg, imgs, sendCmd);
|
||||
if (slash) {
|
||||
return json(res, { ok: true, slash: true, ...slash });
|
||||
}
|
||||
|
||||
submitPrompt({ message: msg, images: imgs });
|
||||
const behavior =
|
||||
streamingBehavior === "steer" || streamingBehavior === "followUp"
|
||||
? streamingBehavior
|
||||
: undefined;
|
||||
submitPrompt({ message: msg, images: imgs, streamingBehavior: behavior });
|
||||
return json(res, { ok: true, accepted: true }, 202);
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/steer") {
|
||||
void readBody(req)
|
||||
.then(async ({ message, images }) => {
|
||||
const msg = typeof message === "string" ? message.trim() : "";
|
||||
const imgs = normalizeChatImages(images);
|
||||
if (!msg && !imgs?.length) throw new Error("消息不能为空");
|
||||
const result = await sendCmd({ type: "steer", message: msg, images: imgs });
|
||||
if (!result.success) throw new Error(result.error || "steer 失败");
|
||||
json(res, { ok: true });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/follow-up") {
|
||||
void readBody(req)
|
||||
.then(async ({ message, images }) => {
|
||||
const msg = typeof message === "string" ? message.trim() : "";
|
||||
const imgs = normalizeChatImages(images);
|
||||
if (!msg && !imgs?.length) throw new Error("消息不能为空");
|
||||
const result = await sendCmd({ type: "follow_up", message: msg, images: imgs });
|
||||
if (!result.success) throw new Error(result.error || "follow_up 失败");
|
||||
json(res, { ok: true });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/bash") {
|
||||
void readBody(req)
|
||||
.then(async ({ command }) => {
|
||||
.then(async ({ command, excludeFromContext }) => {
|
||||
const cmd = typeof command === "string" ? command.trim() : "";
|
||||
if (!cmd) throw new Error("命令不能为空");
|
||||
const result = await sendCmd({ type: "bash", command: cmd });
|
||||
const result = await sendCmd({
|
||||
type: "bash",
|
||||
command: cmd,
|
||||
excludeFromContext: excludeFromContext === true,
|
||||
});
|
||||
if (!result.success) throw new Error(result.error || "命令执行失败");
|
||||
json(res, result.data);
|
||||
})
|
||||
@@ -72,5 +117,12 @@ export function handleChatRoute(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/abort-retry") {
|
||||
void sendCmd({ type: "abort_retry" })
|
||||
.then(() => json(res, { ok: true }))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
25
.pi/agent/extensions/webui/backend/routes/extension-ui.ts
Normal file
25
.pi/agent/extensions/webui/backend/routes/extension-ui.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { WebUiContext } from "../types/context.ts";
|
||||
import { json, readBody } from "../http/request.ts";
|
||||
|
||||
export function handleExtensionUiRoute(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
ctx: WebUiContext,
|
||||
pathname: string,
|
||||
): boolean {
|
||||
if (req.method !== "POST" || pathname !== "/api/extension-ui-response") {
|
||||
return false;
|
||||
}
|
||||
|
||||
void readBody(req)
|
||||
.then(({ id, response }) => {
|
||||
if (typeof id !== "string" || !id.trim()) throw new Error("缺少 extension UI 响应 id");
|
||||
if (!response || typeof response !== "object") throw new Error("缺少 extension UI 响应内容");
|
||||
ctx.rpc.sendExtensionUiResponse(id, response as Record<string, unknown>);
|
||||
json(res, { ok: true });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
resolveSessionFile,
|
||||
} from "../services/sessions.ts";
|
||||
import type { WebUiContext } from "../types/context.ts";
|
||||
import { isSameResolvedPath } from "../utils/paths.ts";
|
||||
import { json, readBody } from "../http/request.ts";
|
||||
|
||||
export function handleSessionsRoute(
|
||||
@@ -103,7 +104,11 @@ export function handleSessionsRoute(
|
||||
const savedName = appendSessionName(paths, sp, typeof name === "string" ? name : "");
|
||||
const sessionPath = resolveSessionFile(paths, sp);
|
||||
const state = await sendCmd({ type: "get_state" });
|
||||
if (state.success && state.data?.sessionFile === sessionPath) {
|
||||
if (
|
||||
state.success &&
|
||||
state.data?.sessionFile &&
|
||||
isSameResolvedPath(String(state.data.sessionFile), sessionPath)
|
||||
) {
|
||||
const rename = await sendCmd({ type: "set_session_name", name: savedName });
|
||||
if (!rename.success) throw new Error(rename.error || "同步当前会话名称失败");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { arch, freemem, hostname, platform, release, totalmem, type as osType } from "node:os";
|
||||
import { readWebuiAvatarSettings, writeWebuiAvatarSettings } from "../db/index.ts";
|
||||
import { setExtensionEnabled } from "../settings/extension-settings.ts";
|
||||
import { listMcpSettings, setMcpServerEnabled, setMcpToolEnabled } from "../settings/mcp-settings.ts";
|
||||
@@ -202,5 +203,23 @@ export function handleSettingsRoute(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && pathname === "/api/environment") {
|
||||
json(res, {
|
||||
nodeVersion: process.version,
|
||||
platform: platform(),
|
||||
arch: arch(),
|
||||
osType: osType(),
|
||||
osRelease: release(),
|
||||
hostname: hostname(),
|
||||
pid: process.pid,
|
||||
cwd: process.cwd(),
|
||||
uptime: Math.floor(process.uptime()),
|
||||
totalMemMb: Math.round(totalmem() / 1024 / 1024),
|
||||
freeMemMb: Math.round(freemem() / 1024 / 1024),
|
||||
execPath: process.execPath,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { RunSnapshot, SendCmd, SubmitPromptOptions } from "../types/context
|
||||
export interface PiClient {
|
||||
sendCmd: SendCmd;
|
||||
submitPrompt: (options: SubmitPromptOptions) => void;
|
||||
sendExtensionUiResponse: (id: string, response: Record<string, unknown>) => void;
|
||||
getRunSnapshot: () => RunSnapshot;
|
||||
connectSseClient: (res: ServerResponse) => void;
|
||||
removeSseClient: (res: ServerResponse) => void;
|
||||
@@ -23,14 +24,32 @@ const BUFFERED_EVENT_TYPES = new Set([
|
||||
"tool_execution_end",
|
||||
"compaction_start",
|
||||
"compaction_end",
|
||||
"queue_update",
|
||||
"auto_retry_start",
|
||||
"auto_retry_end",
|
||||
"bash_update",
|
||||
]);
|
||||
|
||||
const DEFAULT_CMD_TIMEOUT_MS = 60_000;
|
||||
const PROMPT_PREFLIGHT_TIMEOUT_MS = 30_000;
|
||||
const PI_STDERR_BUFFER_MAX = 8192;
|
||||
|
||||
function formatSpawnArgs(args: string[]): string {
|
||||
return args.map((arg) => JSON.stringify(arg)).join(" ");
|
||||
}
|
||||
|
||||
function summarizeStderr(buffer: string): string {
|
||||
const trimmed = buffer.trim();
|
||||
if (!trimmed) return "(无 stderr 输出)";
|
||||
const lines = trimmed.split(/\r?\n/).filter(Boolean);
|
||||
return lines.slice(-8).join("\n");
|
||||
}
|
||||
|
||||
export function createPiClient(paths: WebUiPaths, onExit: (code: number | null) => void): PiClient {
|
||||
const piLaunch = resolvePiRpcLaunch(paths);
|
||||
console.log(`[webui] 启动 pi RPC (${piLaunch.mode}): ${piLaunch.command} ${piLaunch.args.join(" ")}`);
|
||||
console.log(
|
||||
`[webui] 启动 pi RPC (${piLaunch.mode}): ${piLaunch.command} ${formatSpawnArgs(piLaunch.args)}`,
|
||||
);
|
||||
|
||||
const pi = spawn(piLaunch.command, piLaunch.args, {
|
||||
cwd: paths.repoRoot,
|
||||
@@ -41,9 +60,20 @@ export function createPiClient(paths: WebUiPaths, onExit: (code: number | null)
|
||||
},
|
||||
});
|
||||
|
||||
pi.stderr.on("data", (data) => process.stderr.write(`[pi] ${data}`));
|
||||
// Keep stdin pipe open; closing it on Windows can trigger RPC shutdown via stdin "end".
|
||||
pi.stdin.write("");
|
||||
|
||||
let stderrBuffer = "";
|
||||
pi.stderr.on("data", (data: Buffer) => {
|
||||
const chunk = data.toString();
|
||||
stderrBuffer = (stderrBuffer + chunk).slice(-PI_STDERR_BUFFER_MAX);
|
||||
process.stderr.write(`[pi] ${data}`);
|
||||
});
|
||||
pi.on("exit", (code) => {
|
||||
console.log(`[webui] pi 退出, code=${code}`);
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(`[webui] pi RPC 启动失败 (code=${code}):\n${summarizeStderr(stderrBuffer)}`);
|
||||
}
|
||||
onExit(code);
|
||||
});
|
||||
|
||||
@@ -157,13 +187,16 @@ export function createPiClient(paths: WebUiPaths, onExit: (code: number | null)
|
||||
|
||||
function submitPrompt(options: SubmitPromptOptions): void {
|
||||
const id = `req_${++reqId}`;
|
||||
const line =
|
||||
JSON.stringify({
|
||||
type: "prompt",
|
||||
message: options.message,
|
||||
images: options.images,
|
||||
id,
|
||||
}) + "\n";
|
||||
const payload: Record<string, unknown> = {
|
||||
type: "prompt",
|
||||
message: options.message,
|
||||
images: options.images,
|
||||
id,
|
||||
};
|
||||
if (options.streamingBehavior) {
|
||||
payload.streamingBehavior = options.streamingBehavior;
|
||||
}
|
||||
const line = JSON.stringify(payload) + "\n";
|
||||
registerPending(
|
||||
id,
|
||||
{
|
||||
@@ -180,6 +213,11 @@ export function createPiClient(paths: WebUiPaths, onExit: (code: number | null)
|
||||
pi.stdin.write(line);
|
||||
}
|
||||
|
||||
function sendExtensionUiResponse(id: string, response: Record<string, unknown>): void {
|
||||
const line = JSON.stringify({ type: "extension_ui_response", id, ...response }) + "\n";
|
||||
pi.stdin.write(line);
|
||||
}
|
||||
|
||||
function connectSseClient(res: ServerResponse): void {
|
||||
const snapshot = getRunSnapshot();
|
||||
res.write(
|
||||
@@ -195,6 +233,7 @@ export function createPiClient(paths: WebUiPaths, onExit: (code: number | null)
|
||||
return {
|
||||
sendCmd,
|
||||
submitPrompt,
|
||||
sendExtensionUiResponse,
|
||||
getRunSnapshot,
|
||||
connectSseClient,
|
||||
removeSseClient: (res) => sseClients.delete(res),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { WebUiPaths } from "../config/paths.ts";
|
||||
import { isPathInsideRoot } from "../utils/paths.ts";
|
||||
import { prunePinnedSessionPaths, removePinnedSessionPath, setSessionPinned } from "../db/index.ts";
|
||||
|
||||
function isMachineSessionLabel(text: string, sessionHeaderId: string): boolean {
|
||||
@@ -140,9 +141,15 @@ export function readSessionMessages(filePath: string): unknown[] {
|
||||
}
|
||||
|
||||
export function resolveSessionFile(paths: WebUiPaths, filePath: string): string {
|
||||
const resolved = resolve(filePath);
|
||||
if (typeof filePath !== "string" || !filePath.trim()) {
|
||||
throw new Error("无效会话路径");
|
||||
}
|
||||
const sessionsRoot = resolve(paths.sessionsDir);
|
||||
if (!resolved.startsWith(`${sessionsRoot}/`) || !resolved.endsWith(".jsonl")) {
|
||||
const resolved = resolve(filePath.trim());
|
||||
if (!resolved.toLowerCase().endsWith(".jsonl")) {
|
||||
throw new Error("无效会话路径");
|
||||
}
|
||||
if (!isPathInsideRoot(sessionsRoot, resolved)) {
|
||||
throw new Error("无效会话路径");
|
||||
}
|
||||
return resolved;
|
||||
|
||||
@@ -7,6 +7,7 @@ export type SendCmd = (command: Record<string, unknown>) => Promise<any>;
|
||||
export interface SubmitPromptOptions {
|
||||
message: string;
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>;
|
||||
streamingBehavior?: "steer" | "followUp";
|
||||
}
|
||||
|
||||
export interface RunSnapshot {
|
||||
@@ -23,6 +24,7 @@ export interface WebUiContext {
|
||||
rpc: {
|
||||
sendCmd: SendCmd;
|
||||
submitPrompt: (options: SubmitPromptOptions) => void;
|
||||
sendExtensionUiResponse: (id: string, response: Record<string, unknown>) => void;
|
||||
getRunSnapshot: () => RunSnapshot;
|
||||
connectSseClient: (res: ServerResponse) => void;
|
||||
removeSseClient: (res: ServerResponse) => void;
|
||||
|
||||
22
.pi/agent/extensions/webui/backend/utils/paths.ts
Normal file
22
.pi/agent/extensions/webui/backend/utils/paths.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { relative, resolve } from "node:path";
|
||||
|
||||
/** True when `candidate` resolves to a path under `rootDir` (not the root dir itself). */
|
||||
export function isPathInsideRoot(rootDir: string, candidate: string): boolean {
|
||||
const root = resolve(rootDir);
|
||||
const target = resolve(candidate);
|
||||
const rel = relative(root, target);
|
||||
if (rel === "" || rel === ".") return false;
|
||||
if (rel.startsWith("..")) return false;
|
||||
// Cross-drive relative paths on Windows are absolute (e.g. D:\other\...)
|
||||
if (/^[A-Za-z]:[\\/]/.test(rel)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isSameResolvedPath(a: string, b: string): boolean {
|
||||
const left = resolve(a);
|
||||
const right = resolve(b);
|
||||
if (process.platform === "win32") {
|
||||
return left.toLowerCase() === right.toLowerCase();
|
||||
}
|
||||
return left === right;
|
||||
}
|
||||
Reference in New Issue
Block a user