fix: fixed tool-call compatibility and tiny runtime resolver behavior
- Updated tool-call handling to accept string and object arguments. - Stored object tool-call args directly into block.partialArgs and block.arguments. - Added compiled runtime module resolution honoring exports, main, and index fallbacks. - Patched tiny runtime loading to install resolver stubs and load the resolved entry file.
This commit is contained in:
@@ -851,7 +851,10 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
||||
if (toolCall.id) block.id = toolCall.id;
|
||||
if (toolCall.function?.name) block.name = toolCall.function.name;
|
||||
let delta = "";
|
||||
const rawArgs: unknown = toolCall.function?.arguments;
|
||||
// The OpenAI SDK types `function.arguments` as a JSON string, but MiniMax-compatible
|
||||
// hosts stream a fully-formed object instead. Model both shapes so the branches below
|
||||
// narrow honestly rather than widening through `unknown`.
|
||||
const rawArgs = toolCall.function?.arguments as string | Record<string, unknown> | undefined;
|
||||
if (typeof rawArgs === "string") {
|
||||
if (rawArgs.length > 0) {
|
||||
delta = rawArgs;
|
||||
@@ -868,10 +871,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
||||
// single delta instead of the OpenAI JSON-string contract. Hold the object directly
|
||||
// — no `[object Object]` round-trip through the string buffer — and serialize once for
|
||||
// the wire delta that proxy servers forward verbatim as `input_json_delta`.
|
||||
const objectArgs = rawArgs as Record<string, unknown>;
|
||||
block.partialArgs = objectArgs;
|
||||
block.arguments = objectArgs;
|
||||
delta = JSON.stringify(objectArgs);
|
||||
block.partialArgs = rawArgs;
|
||||
block.arguments = rawArgs;
|
||||
delta = JSON.stringify(rawArgs);
|
||||
}
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
### Changed
|
||||
|
||||
- Changed the `task` tool description to tag read-only agents and explicitly forbid assigning them file edits/commands or offloading reasoning to `quick_task`/`explore`.
|
||||
|
||||
- Changed Redis and SQL session storage initialization to load only indexed metadata (`size`, `mtimeMs`) instead of full session content
|
||||
- Changed `SessionStorage` read paths to rely on backend-backed metadata/indexed storage, so session content is fetched on demand rather than cached as full in-memory mirrors
|
||||
- Changed session-list slice reads to go through `SessionStorage.readTextSlices` across all backends, removing the file-only single-open branch and caller-managed buffers. `FileSessionStorage` now reads both windows via `peekFileEnds`, while Redis and SQL backends encode session content once per combined read.
|
||||
@@ -37,6 +36,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed tiny-model startup in compiled binaries by resolving `@huggingface/transformers` and its runtime dependencies from the installed cache using `package.json` `exports`/`main` metadata, preventing module-resolution failures when launching models
|
||||
- Fixed tiny runtime installation flow in compiled binaries by using the build-time resolved `@huggingface/transformers` version and ensuring the runtime lock directory’s parent exists before acquiring the install lock, preventing mismatch and setup failures on fresh installs
|
||||
- Fixed the terminal protocol debug probe reusing one stable Kitty graphics id across repeated panels, which could move/replace an earlier swatch instead of rendering a new one.
|
||||
- Fixed selector dialogs (the `ask` tool, hook prompts) collapsing to a single visible option on shorter terminals when options carried long descriptions: the highlighted option's wrapped description consumed the entire row budget, hiding every other option and making the menu feel unnavigable (down moved the lone visible entry, left/right did nothing). When the fully-expanded list overflows, `HookSelectorComponent` now renders a compact list — every option label stays on screen and only the highlighted option expands its description, truncated to the remaining rows — so the whole menu is always visible and the detail pane follows the cursor.
|
||||
- Fixed `read` failing with "Path not found" on web URLs whose scheme `//` collapsed to a single `/` (e.g. `https:/github.com/...`), which happens when a URL is routed through Node's `path.normalize`/`path.resolve`. The fetch URL recognizer now accepts a single-slash scheme and repairs it back to `//` before fetching, so collapsed URLs resolve instead of falling through to filesystem lookup.
|
||||
@@ -9292,4 +9293,4 @@ Initial public release.
|
||||
- Git branch display in footer
|
||||
- Message queueing during streaming responses
|
||||
- OAuth integration for Gmail and Google Calendar access
|
||||
- HTML export with syntax highlighting and collapsible sections
|
||||
- HTML export with syntax highlighting and collapsible sections
|
||||
@@ -1,10 +1,20 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import * as path from "node:path";
|
||||
|
||||
const packageDir = path.join(import.meta.dir, "..");
|
||||
const outputPath = path.join(packageDir, "dist", "omp");
|
||||
|
||||
// Transformers.js is an optional, native-heavy dependency that is never bundled
|
||||
// into the binary; the tiny-model worker `bun install`s it into a runtime cache
|
||||
// on first use. The `catalog:` spec cannot be resolved from inside the compiled
|
||||
// bunfs (issue #1763), so embed the concrete installed version here for the
|
||||
// worker to pin its runtime install against.
|
||||
const transformersVersion = (
|
||||
createRequire(import.meta.url)("@huggingface/transformers/package.json") as { version: string }
|
||||
).version;
|
||||
|
||||
function shouldAdhocSignDarwinBinary(): boolean {
|
||||
return process.platform === "darwin";
|
||||
}
|
||||
@@ -40,6 +50,8 @@ async function main(): Promise<void> {
|
||||
"--keep-names",
|
||||
"--define",
|
||||
'process.env.PI_COMPILED="true"',
|
||||
"--define",
|
||||
`process.env.PI_TINY_TRANSFORMERS_VERSION=${JSON.stringify(transformersVersion)}`,
|
||||
"--external",
|
||||
"mupdf",
|
||||
"--root",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as Module from "node:module";
|
||||
import * as path from "node:path";
|
||||
|
||||
/**
|
||||
* Bun's compiled-binary module resolver only finds `<pkg>/index.js` for bare
|
||||
* specifiers loaded from the *real* filesystem — it ignores `main`/`exports`
|
||||
* (issue #1763). The tiny-model Transformers.js runtime is `bun install`ed into
|
||||
* a cache directory at runtime, and its graph (`@huggingface/transformers` →
|
||||
* `onnxruntime-node` → `onnxruntime-common`, plus an eager `require("sharp")`)
|
||||
* all point `main`/`exports` at nested files, so the stock resolver cannot load
|
||||
* any of them. We patch `Module._resolveFilename` to resolve those bare
|
||||
* specifiers against the cache ourselves, honoring `main`/`exports`.
|
||||
*
|
||||
* This module is filesystem-pure aside from {@link installRuntimeModuleResolver}
|
||||
* mutating the `node:module` resolver, so the resolution logic is unit-testable
|
||||
* without a compiled binary.
|
||||
*/
|
||||
|
||||
/** Conditions honored when resolving an `exports` map for a CommonJS `require`. */
|
||||
const RUNTIME_CONDITIONS: Record<string, true> = { node: true, require: true, default: true };
|
||||
|
||||
/** Extension probes appended to a `main`/`exports` target that lacks one. */
|
||||
const RUNTIME_EXTENSIONS: readonly string[] = [".js", ".cjs", ".mjs", ".json", ".node"];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a conditional `exports` target (string, array of fallbacks, or a
|
||||
* condition object) and return the first relative path that matches a runtime
|
||||
* condition in declaration order. Returns `null` when nothing applies (e.g.
|
||||
* `import`-only targets).
|
||||
*/
|
||||
export function selectConditionalTarget(target: unknown): string | null {
|
||||
if (typeof target === "string") return target;
|
||||
if (Array.isArray(target)) {
|
||||
for (const entry of target) {
|
||||
const resolved = selectConditionalTarget(entry);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (isRecord(target)) {
|
||||
for (const condition in target) {
|
||||
if (!RUNTIME_CONDITIONS[condition]) continue;
|
||||
const resolved = selectConditionalTarget(target[condition]);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve a relative target inside a package to a concrete file path, probing extensions and `index`. */
|
||||
function resolveFileTarget(pkgDir: string, relative: string): string | null {
|
||||
const base = path.join(pkgDir, relative);
|
||||
const candidates = [base, ...RUNTIME_EXTENSIONS.map(ext => base + ext)];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stat = fs.statSync(candidate);
|
||||
if (stat.isFile()) return candidate;
|
||||
if (stat.isDirectory()) {
|
||||
const indexed = resolveFileTarget(candidate, "index");
|
||||
if (indexed) return indexed;
|
||||
}
|
||||
} catch {
|
||||
// missing candidate — keep probing
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveExportsEntry(
|
||||
pkgDir: string,
|
||||
exports: Record<string, unknown>,
|
||||
subpath: string | undefined,
|
||||
): string | null {
|
||||
let subpathMap = false;
|
||||
for (const key in exports) {
|
||||
subpathMap = key === "." || key.startsWith("./");
|
||||
break;
|
||||
}
|
||||
if (subpathMap) {
|
||||
const key = subpath ? `./${subpath}` : ".";
|
||||
if (!(key in exports)) return null;
|
||||
const target = selectConditionalTarget(exports[key]);
|
||||
return target ? resolveFileTarget(pkgDir, target) : null;
|
||||
}
|
||||
// A bare condition map only describes the package root, so a subpath
|
||||
// request falls through to plain path joining at the call site.
|
||||
if (subpath) return null;
|
||||
const target = selectConditionalTarget(exports);
|
||||
return target ? resolveFileTarget(pkgDir, target) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a bare specifier into its package name and optional subpath, handling
|
||||
* scoped packages (`@scope/name/sub` → `@scope/name` + `sub`).
|
||||
*/
|
||||
export function splitBareSpecifier(specifier: string): { packageName: string; subpath: string | undefined } {
|
||||
const segments = specifier.split("/");
|
||||
const take = specifier.startsWith("@") ? 2 : 1;
|
||||
const packageName = segments.slice(0, take).join("/");
|
||||
const subpath = segments.length > take ? segments.slice(take).join("/") : undefined;
|
||||
return { packageName, subpath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bare specifier against an installed `node_modules` directory,
|
||||
* honoring `exports` (CommonJS conditions), then `main`, then `index.js`.
|
||||
* Returns an absolute file path, or `null` when the package/entry is absent.
|
||||
*/
|
||||
export function resolveRuntimeModule(runtimeNodeModules: string, specifier: string): string | null {
|
||||
const { packageName, subpath } = splitBareSpecifier(specifier);
|
||||
const pkgDir = path.join(runtimeNodeModules, ...packageName.split("/"));
|
||||
const manifest = readManifest(pkgDir);
|
||||
if (!manifest) return subpath ? resolveFileTarget(pkgDir, subpath) : null;
|
||||
|
||||
const { exports } = manifest;
|
||||
if (typeof exports === "string" || isRecord(exports)) {
|
||||
const map = typeof exports === "string" ? { ".": exports } : exports;
|
||||
const resolved = resolveExportsEntry(pkgDir, map, subpath);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
if (subpath) return resolveFileTarget(pkgDir, subpath);
|
||||
if (typeof manifest.main === "string") {
|
||||
const resolved = resolveFileTarget(pkgDir, manifest.main);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
return resolveFileTarget(pkgDir, "index.js");
|
||||
}
|
||||
|
||||
function readManifest(pkgDir: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ModuleResolver {
|
||||
_resolveFilename(request: string, parent: unknown, isMain: boolean, options?: unknown): string;
|
||||
}
|
||||
|
||||
const PATCHED = Symbol.for("omp.tiny.compiledRuntimeResolver");
|
||||
|
||||
interface ResolverOptions {
|
||||
/** Absolute path to the runtime cache's `node_modules`. */
|
||||
runtimeNodeModules: string;
|
||||
/** Bare specifier → absolute file path overrides (e.g. `sharp` → no-op stub). */
|
||||
stubs?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch `node:module`'s resolver (idempotently) so bare specifiers that the
|
||||
* stock compiled-binary resolver cannot find fall back to the runtime cache.
|
||||
* Stock resolution is tried first, so this never changes behavior for modules
|
||||
* that already resolve (the worker's own bundled imports, node builtins).
|
||||
*/
|
||||
export function installRuntimeModuleResolver({ runtimeNodeModules, stubs = {} }: ResolverOptions): void {
|
||||
const resolver = (Module as unknown as { default?: ModuleResolver } & ModuleResolver).default ?? Module;
|
||||
const target = resolver as unknown as ModuleResolver & { [PATCHED]?: boolean };
|
||||
if (target[PATCHED]) return;
|
||||
const original = target._resolveFilename.bind(target);
|
||||
target._resolveFilename = (request: string, parent: unknown, isMain: boolean, options?: unknown): string => {
|
||||
try {
|
||||
return original(request, parent, isMain, options);
|
||||
} catch (error) {
|
||||
const stub = stubs[request];
|
||||
if (stub) return stub;
|
||||
const resolved = resolveRuntimeModule(runtimeNodeModules, request);
|
||||
if (resolved) return resolved;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
target[PATCHED] = true;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import { getTinyModelsCacheDir, isCompiledBinary, prompt } from "@oh-my-pi/pi-utils";
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
import tinyTitleSystemPrompt from "../prompts/system/tiny-title-system.md" with { type: "text" };
|
||||
import { installRuntimeModuleResolver, resolveRuntimeModule } from "./compiled-runtime";
|
||||
import { resolveTinyModelDevicePreference, type TinyModelDevice, tinyModelDeviceLoadOrder } from "./device";
|
||||
import { resolveTinyModelDtypeOverride, type TinyModelDtype } from "./dtype";
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ const STOP_DECODE_WINDOW_TOKENS = 32;
|
||||
const MEMORY_COMPLETION_MAX_NEW_TOKENS = 256;
|
||||
const TINY_TITLE_SYSTEM_PROMPT = prompt.render(tinyTitleSystemPrompt);
|
||||
const TRANSFORMERS_PACKAGE = "@huggingface/transformers";
|
||||
const COMPILED_TRANSFORMERS_VERSION = process.env.PI_TINY_TRANSFORMERS_VERSION;
|
||||
const sourceRequire = createRequire(import.meta.url);
|
||||
const INSTALL_LOCK_ATTEMPTS = 240;
|
||||
const INSTALL_LOCK_SLEEP_MS = 250;
|
||||
@@ -67,6 +69,7 @@ function resolveTransformersVersionSpec(): string {
|
||||
manifest.optionalDependencies?.[TRANSFORMERS_PACKAGE] ?? manifest.dependencies?.[TRANSFORMERS_PACKAGE];
|
||||
if (!versionSpec) throw new Error(`${TRANSFORMERS_PACKAGE} is missing from package.json optionalDependencies`);
|
||||
if (!versionSpec.startsWith("catalog:")) return versionSpec;
|
||||
if (COMPILED_TRANSFORMERS_VERSION) return COMPILED_TRANSFORMERS_VERSION;
|
||||
const installed = sourceRequire(`${TRANSFORMERS_PACKAGE}/package.json`) as { version: string };
|
||||
return installed.version;
|
||||
}
|
||||
@@ -117,6 +120,7 @@ function getTinyTitleRuntimeDir(): string {
|
||||
|
||||
async function acquireInstallLock(runtimeDir: string): Promise<() => Promise<void>> {
|
||||
const lockDir = `${runtimeDir}.lock`;
|
||||
await fs.mkdir(path.dirname(lockDir), { recursive: true });
|
||||
for (let attempt = 0; attempt < INSTALL_LOCK_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await fs.mkdir(lockDir);
|
||||
@@ -216,6 +220,23 @@ async function ensureCompiledTransformersRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the freshly-installed compiled runtime for loading: stub `sharp`
|
||||
* (the tiny models are text-generation only, so the native image pipeline is
|
||||
* dead weight) and patch the module resolver so Transformers.js's bare requires
|
||||
* (`onnxruntime-node`, `onnxruntime-common`) resolve against the cache. Returns
|
||||
* the absolute Transformers.js entrypoint to `require`.
|
||||
*/
|
||||
async function prepareCompiledRuntime(runtimeDir: string): Promise<string> {
|
||||
const nodeModules = path.join(runtimeDir, "node_modules");
|
||||
const sharpStub = path.join(runtimeDir, "omp-sharp-stub.cjs");
|
||||
await Bun.write(sharpStub, "module.exports = {};\n");
|
||||
installRuntimeModuleResolver({ runtimeNodeModules: nodeModules, stubs: { sharp: sharpStub } });
|
||||
const entry = resolveRuntimeModule(nodeModules, TRANSFORMERS_PACKAGE);
|
||||
if (!entry) throw new Error(`Unable to resolve ${TRANSFORMERS_PACKAGE} in compiled runtime at ${nodeModules}`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function configureTransformers(transformers: TransformersRuntime): TransformersRuntime {
|
||||
transformers.env.cacheDir = getTinyModelsCacheDir();
|
||||
transformers.env.allowLocalModels = false;
|
||||
@@ -232,8 +253,9 @@ async function loadTransformers(
|
||||
transformersRuntime = (async () => {
|
||||
if (!isCompiledBinary()) return configureTransformers(sourceRequire(TRANSFORMERS_PACKAGE) as TransformersRuntime);
|
||||
const runtimeDir = await ensureCompiledTransformersRuntime(transport, requestId, modelKey);
|
||||
const require_ = createRequire(path.join(runtimeDir, "package.json"));
|
||||
return configureTransformers(require_(TRANSFORMERS_PACKAGE) as TransformersRuntime);
|
||||
const entry = await prepareCompiledRuntime(runtimeDir);
|
||||
const require_ = createRequire(entry);
|
||||
return configureTransformers(require_(entry) as TransformersRuntime);
|
||||
})().catch(error => {
|
||||
transformersRuntime = null;
|
||||
throw error;
|
||||
|
||||
@@ -1525,7 +1525,10 @@ export class TUI extends Container {
|
||||
cursorPos === null
|
||||
? null
|
||||
: { row: paddedViewportTop + (cursorPos.row - newViewportTop), col: cursorPos.col };
|
||||
return { kind: "deferredMutation", cursor: { position: mappedCursor, totalLines: this.#previousLines.length } };
|
||||
return {
|
||||
kind: "deferredMutation",
|
||||
cursor: { position: mappedCursor, totalLines: this.#previousLines.length },
|
||||
};
|
||||
}
|
||||
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
|
||||
}
|
||||
|
||||
@@ -2067,13 +2067,27 @@ describe("TUI terminal-state regressions", () => {
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
expect(visible(term).map(l => l.trim())).toEqual(["line-24", "line-25", "line-26", "line-27", "line-28", "line-29"]);
|
||||
expect(visible(term).map(l => l.trim())).toEqual([
|
||||
"line-24",
|
||||
"line-25",
|
||||
"line-26",
|
||||
"line-27",
|
||||
"line-28",
|
||||
"line-29",
|
||||
]);
|
||||
// Delete one offscreen row (index 5): the last six rows are unchanged,
|
||||
// only their absolute indices shift.
|
||||
component.setLines([...initial.slice(0, 5), ...initial.slice(6)]);
|
||||
tui.requestRender(true, { allowUnknownViewportMutation: true });
|
||||
await settle(term);
|
||||
expect(visible(term).map(l => l.trim())).toEqual(["line-24", "line-25", "line-26", "line-27", "line-28", "line-29"]);
|
||||
expect(visible(term).map(l => l.trim())).toEqual([
|
||||
"line-24",
|
||||
"line-25",
|
||||
"line-26",
|
||||
"line-27",
|
||||
"line-28",
|
||||
"line-29",
|
||||
]);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
|
||||
tui.stop();
|
||||
@@ -2096,7 +2110,14 @@ describe("TUI terminal-state regressions", () => {
|
||||
component.setLines([...initial.slice(0, 5), ...initial.slice(6, -1), "prompt-updated"]);
|
||||
tui.requestRender(true, { allowUnknownViewportMutation: true });
|
||||
await settle(term);
|
||||
expect(visible(term).map(l => l.trim())).toEqual(["line-24", "line-25", "line-26", "line-27", "line-28", "prompt-updated"]);
|
||||
expect(visible(term).map(l => l.trim())).toEqual([
|
||||
"line-24",
|
||||
"line-25",
|
||||
"line-26",
|
||||
"line-27",
|
||||
"line-28",
|
||||
"prompt-updated",
|
||||
]);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
|
||||
tui.stop();
|
||||
@@ -2121,7 +2142,14 @@ describe("TUI terminal-state regressions", () => {
|
||||
component.setLines([...initial.slice(0, 5), ...initial.slice(6, -1), `prompt>ab${CURSOR_MARKER}`]);
|
||||
tui.requestRender(true, { allowUnknownViewportMutation: true });
|
||||
await settle(term);
|
||||
expect(visible(term).map(l => l.trim())).toEqual(["line-24", "line-25", "line-26", "line-27", "line-28", "prompt>ab"]);
|
||||
expect(visible(term).map(l => l.trim())).toEqual([
|
||||
"line-24",
|
||||
"line-25",
|
||||
"line-26",
|
||||
"line-27",
|
||||
"line-28",
|
||||
"prompt>ab",
|
||||
]);
|
||||
expect(term.getCursor()).toEqual({ row: 5, col: "prompt>ab".length });
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
|
||||
@@ -2146,7 +2174,14 @@ describe("TUI terminal-state regressions", () => {
|
||||
component.setLines([...initial.slice(0, 5), ...initial.slice(6)]);
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
expect(visible(term).map(l => l.trim())).toEqual(["line-24", "line-25", "line-26", "line-27", "line-28", "line-29"]);
|
||||
expect(visible(term).map(l => l.trim())).toEqual([
|
||||
"line-24",
|
||||
"line-25",
|
||||
"line-26",
|
||||
"line-27",
|
||||
"line-28",
|
||||
"line-29",
|
||||
]);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user