feat: KOOK自动获取bot身份 + action/narration剥出气泡 + 渠道配置表单回填
agent-116 KOOK token即用+自动获取:
- kook/identity.go: Connect 调 GET /user/me 拿 bot 身份(bot_id/bot_username/bot_nickname), 失败容错
- 渠道列表 enrichAccountsWithBot 附 bot 身份; base_url空用默认/receive_mode默认ws(只填token可跑)
agent-117 action/narration 剥出气泡(主人需求: 动作/旁白不进气泡,按位置上/下):
- parseContent.splitBubble(content)->{body,head,tail}: body=纯文本(去标签), head=开头/中间action/narr(气泡上), tail=结尾(气泡下)
- renderContent 只渲染text(去内联灰斜体); chat.tsx BubbleNotes 泡外 *动作/旁白* 灰斜体小字(气泡外依附), SplitChatBubble 先剥splitBubble再splitParagraphs(body), <p>/\n\n/毛玻璃/SourceLabel不破坏
- 注: 非chat mode renderContent 现在丢弃action/narr(可接受)
Channels 配置表单回填(修save后输入框空):
- openConfig 从 data.accounts[0].config 回填 token(掩码提示)/base_url/平台字段/黑白名单/限流, 不再硬清空
全仓 build/vet/test绿+前端build绿
This commit is contained in:
1 parent
bdd9d752e1
commit
65091f6497
15 files changed
+571
-83
No files matched your search
@@ -34,12 +34,13 @@ interface PlatformField {
|
||||
* are rendered for every platform separately; this map holds only what a given
|
||||
* adapter additionally needs. Keys are the adapter Config json tags verbatim. */
|
||||
const PLATFORM_FIELDS: Record<string, PlatformField[]> = {
|
||||
kook: [
|
||||
{ key: "receive_mode", label: "receive_mode(收包模式 ws/webhook)", placeholder: "ws" },
|
||||
{ key: "webhook_path", label: "webhook_path(webhook 挂载路由)", placeholder: "/webhook/kook" },
|
||||
{ key: "webhook_verify_token", label: "webhook_verify_token(校验 token)", secret: true },
|
||||
{ key: "webhook_encrypt_key", label: "webhook_encrypt_key(AES 加密密钥)", secret: true },
|
||||
],
|
||||
// KOOK is "token 即用、其余自动获取": only the token is required. base_url
|
||||
// defaults to the official root, receive_mode defaults to "ws" and webhook_path
|
||||
// to "/webhook/kook", so none of them need a form field. Advanced values
|
||||
// (receive_mode="webhook", webhook_path, webhook_verify_token, webhook_encrypt_key)
|
||||
// are set through the "额外 config" JSON instead. We keep the (empty) entry so
|
||||
// the panel still renders the KOOK-specific hint below.
|
||||
kook: [],
|
||||
"qq-obv11": [
|
||||
{ key: "mode", label: "mode(client/server)", placeholder: "server" },
|
||||
{ key: "listen_addr", label: "listen_addr(server 监听)", placeholder: "0.0.0.0:6700" },
|
||||
@@ -132,18 +133,29 @@ export function ChannelsModule(): React.ReactNode {
|
||||
const openConfig = (name: string) => {
|
||||
setErr(null);
|
||||
setNote(null);
|
||||
setCfgPlatform(platformOf(name));
|
||||
setCfgId("");
|
||||
setToken("");
|
||||
setBaseURL("");
|
||||
setPlatVals({});
|
||||
setAllowGroups("");
|
||||
setDenyGroups("");
|
||||
setAllowUsers("");
|
||||
setDenyUsers("");
|
||||
setGroupMsgPerMin("");
|
||||
setMsgPerMin("");
|
||||
setMaxMessages("");
|
||||
const platform = platformOf(name);
|
||||
setCfgPlatform(platform);
|
||||
// Prefill from the backend-provided account (if already configured) so a
|
||||
// saved config is visible again instead of the form opening blank. token is
|
||||
// masked by the backend (never raw), so we only echo it as a hint.
|
||||
const acct = (data ?? []).find((c) => platformOf(c.name) === platform)?.accounts?.[0];
|
||||
setCfgId(acct?.id ?? "");
|
||||
const cfg = acct?.config ?? {};
|
||||
setToken(typeof cfg.bot_token === "string" && cfg.bot_token ? "(已配置,仅掩码显示)" : "");
|
||||
setBaseURL(typeof cfg.base_url === "string" ? cfg.base_url : "");
|
||||
const plat: Record<string, string> = {};
|
||||
for (const f of PLATFORM_FIELDS[platform] ?? []) {
|
||||
const v = cfg[f.key];
|
||||
plat[f.key] = v == null ? "" : String(v);
|
||||
}
|
||||
setPlatVals(plat);
|
||||
setAllowGroups((acct?.allowGroups ?? []).join(","));
|
||||
setDenyGroups((acct?.denyGroups ?? []).join(","));
|
||||
setAllowUsers((acct?.allowUsers ?? []).join(","));
|
||||
setDenyUsers(acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : "");
|
||||
setGroupMsgPerMin(cfg.groupMsgPerMin == null ? "" : String(cfg.groupMsgPerMin));
|
||||
setMsgPerMin(cfg.msgPerMin == null ? "" : String(cfg.msgPerMin));
|
||||
setMaxMessages(cfg.maxMessages == null ? "" : String(cfg.maxMessages));
|
||||
setExtraConfig("");
|
||||
};
|
||||
|
||||
@@ -253,16 +265,21 @@ export function ChannelsModule(): React.ReactNode {
|
||||
render: (c) =>
|
||||
c.accounts && c.accounts.length ? (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{c.accounts.map((a) => (
|
||||
<span key={a.id || "default"} className="flex items-center gap-1">
|
||||
<Tag tone="neutral">{a.id || "default"}</Tag>
|
||||
{a.id ? (
|
||||
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
))}
|
||||
{c.accounts.map((a) => {
|
||||
// Show the connected bot's resolved identity (kook user/me) when the
|
||||
// adapter fetched it, falling back to the account id.
|
||||
const label = a.botNickname || (a.botUsername ? `@${a.botUsername}` : a.id || "default");
|
||||
return (
|
||||
<span key={a.id || "default"} className="flex items-center gap-1">
|
||||
<Tag tone="neutral">{label}</Tag>
|
||||
{a.id ? (
|
||||
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">—</span>
|
||||
@@ -316,6 +333,14 @@ export function ChannelsModule(): React.ReactNode {
|
||||
{cfgPlatform ? (
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">配置账号 → {cfgPlatform}</h3>
|
||||
{cfgPlatform === "kook" ? (
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
KOOK 为「token 即用、其余自动获取」:仅需填 token(必填);base_url 留空即用官方默认;receive_mode 默认 ws、webhook_path 默认 /webhook/kook,均无需填写。若改用 webhook 或配校验/加密,在下方「额外 config」JSON 里设置,如
|
||||
{" "}
|
||||
{"{"}"receive_mode":"webhook","webhook_path":"/webhook/kook","webhook_verify_token":"...","webhook_encrypt_key":"..."{"}"}
|
||||
(verify_token / encrypt_key 仅在 webhook 模式需要)。连接后渠道列表会显示该 KOOK bot 身份。
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
保存后写入 channels.json(本地、已 gitignore)并热更新 {cfgPlatform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。
|
||||
</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Icon, type IconName } from "./Icon";
|
||||
import { WakePath } from "./status";
|
||||
import { modeBody } from "./messages";
|
||||
import { renderContent } from "../lib/renderContent";
|
||||
import { splitBubble, type ContentPart } from "../lib/parseContent";
|
||||
|
||||
/** Collapsible inline chain-of-thought block. A Miuix disclosure row: icon chip
|
||||
* + label, spinner when still thinking, and a single-line muted preview of the
|
||||
@@ -162,22 +163,24 @@ export function MessageBubble({
|
||||
return <AssistantBubble message={message} mode={mode} active={active} />;
|
||||
}
|
||||
|
||||
/** Split a chat reply into its `<p>`-delimited paragraph bodies. Each complete
|
||||
* `<p>…</p>` block becomes one bubble segment; a reply that carries no `<p>`
|
||||
* wrapper yields an empty list so the caller renders one bubble as before.
|
||||
/** Split a chat reply's annotation-stripped body into `<p>`-delimited paragraph
|
||||
* bodies. Each complete `<p>…</p>` block becomes one bubble segment; a body that
|
||||
* carries no `<p>` wrapper falls back to splitting on `\n\n` runs, and a body
|
||||
* with neither yields an empty list so the caller handles the empty reply.
|
||||
* Non-greedy + multi-line so a tag may span newlines; a still-streaming,
|
||||
* unclosed `<p>` simply does not match and stays out until it closes. */
|
||||
function splitParagraphs(content: string): string[] {
|
||||
// Preferred: the agent wraps each paragraph in <p>…</p> (design/07 §2). When it
|
||||
// emits plain \n\n runs instead (the model often does), split on those too so
|
||||
// each logical paragraph becomes its own bubble. But never cut inside an
|
||||
// <action>…</action> / <narration>…</narration> block, and never split on a
|
||||
// single \n (that is an in-line break, kept inside the paragraph).
|
||||
// each logical paragraph becomes its own bubble. `content` here is the
|
||||
// annotation-stripped body from `splitBubble`, so no <action>/<narration> block
|
||||
// can be cut across; never split on a single \n (that is an in-line break, kept
|
||||
// inside the paragraph).
|
||||
const pRe = /<p[^>]*>([\s\S]*?)<\/p>/g;
|
||||
const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim());
|
||||
if (pSegments.length > 0) return pSegments;
|
||||
|
||||
// Keep non-<p> content (action/narration + body) so we don't strip the tags.
|
||||
// Keep non-<p> remainder as its own paragraph bodies.
|
||||
const segments: string[] = [];
|
||||
for (const part of content.split(/\n\s*\n/)) {
|
||||
if (!part.trim()) continue;
|
||||
@@ -186,8 +189,37 @@ function splitParagraphs(content: string): string[] {
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Chat-mode body: a Miuix surface shell around a whitespace-preserving span,
|
||||
* with `<action>`/`<narration>` rendered as muted italic via renderContent. */
|
||||
/** Small muted attachment note for a single `<action>` / `<narration>` block.
|
||||
* Rendered as a grey italic micro-line OUTSIDE the bubble (anchored to the
|
||||
* message's head/tail by `splitBubble`) — never inside the bubble body.
|
||||
* `align` mirrors the bubble column: `left` indents under the assistant avatar
|
||||
* column, `right` lines the note up with a right-aligned user bubble. */
|
||||
function BubbleNotes({
|
||||
items,
|
||||
align = "left",
|
||||
}: {
|
||||
items: ContentPart[];
|
||||
align?: "left" | "right";
|
||||
}): React.ReactNode {
|
||||
if (items.length === 0) return null;
|
||||
const container =
|
||||
align === "right"
|
||||
? "flex flex-col items-end gap-0.5"
|
||||
: "flex flex-col items-start gap-0.5 pl-10";
|
||||
return (
|
||||
<div className={container}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="text-[10px] italic leading-tight text-[var(--mu-text-lo)]">
|
||||
*{item.type === "action" ? "动作" : "旁白"}:{item.text}*
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Chat-mode body: a Miuix surface shell around a whitespace-preserving span.
|
||||
* `text` is already the annotation-stripped body paragraph from `splitBubble`,
|
||||
* so it renders as plain text only — action/narration notes live outside. */
|
||||
function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode {
|
||||
return (
|
||||
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)] shadow-[var(--mu-shadow-1)]">
|
||||
@@ -267,13 +299,14 @@ function UserParaBubble({
|
||||
);
|
||||
}
|
||||
|
||||
/** Chat-only entry: a `chat`-mode reply's `<p>…</p>` paragraphs are split into
|
||||
* stacked bubbles (first = full bubble, remainder = compact). Any other mode —
|
||||
* code / home / work / docs / vision / voice / agents / settings — is passed
|
||||
* straight through to MessageBubble so its body renders untouched: no `<p>`
|
||||
* splitting, and its content (which may itself contain `<p>` / `<action>` /
|
||||
* `<narration>` as literal text or code) is never re-parsed. A `chat` message
|
||||
* with no `<p>` wrapper renders as one bubble exactly as before. */
|
||||
/** Chat-only entry: a `chat`-mode reply's `<action>`/`<narration>` blocks are
|
||||
* peeled off by `splitBubble` into head/tail attachment notes, and the remaining
|
||||
* plain-text body is split into stacked bubbles (first = full bubble, remainder
|
||||
* = compact). Any other mode — code / home / work / docs / vision / voice /
|
||||
* agents / settings — is passed straight through to MessageBubble so its body
|
||||
* renders untouched: no `<p>` splitting, and its content (which may itself
|
||||
* contain `<p>` / `<action>` / `<narration>` as literal text or code) is never
|
||||
* re-parsed. */
|
||||
export function SplitChatBubble({
|
||||
message,
|
||||
active = false,
|
||||
@@ -284,20 +317,23 @@ export function SplitChatBubble({
|
||||
const mode = message.mode ?? "chat";
|
||||
if (mode !== "chat") return <MessageBubble message={message} active={active} />;
|
||||
|
||||
const segments = splitParagraphs(message.content ?? "");
|
||||
if (segments.length === 0) {
|
||||
// No complete `<p>…</p>` pair: render the single bubble as-is (but strip a
|
||||
// lone wrapper tag so a still-streaming reply never flashes raw `<p>`).
|
||||
const raw = message.content ?? "";
|
||||
const clean = raw.replace(/<p[^>]*>/g, "").replace(/<\/p>/g, "");
|
||||
const bubble = clean === raw ? message : { ...message, content: clean };
|
||||
return <MessageBubble message={bubble} active={active} />;
|
||||
const { body, head, tail } = splitBubble(message.content ?? "");
|
||||
const segments = splitParagraphs(body);
|
||||
|
||||
if (segments.length === 0 && head.length === 0 && tail.length === 0) {
|
||||
// No visible content: hand off so the empty / still-streaming reply keeps its
|
||||
// usual chrome (avatar, mood, "生成中…").
|
||||
return <MessageBubble message={message} active={active} />;
|
||||
}
|
||||
|
||||
// Group the paragraphs of one reply into a tighter column so intra-reply
|
||||
// segments read as one bubble while separate replies keep the 16px list gap.
|
||||
// The annotation notes (head/tail) attach to the whole group — above the first
|
||||
// bubble and below the last — never inside a bubble's body.
|
||||
const align = message.role === "user" ? "right" : "left";
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className={`flex flex-col gap-2.5 ${message.role === "user" ? "items-end" : ""}`}>
|
||||
<BubbleNotes items={head} align={align} />
|
||||
{segments.map((seg, i) =>
|
||||
message.role === "user" ? (
|
||||
<UserParaBubble key={i} message={message} text={seg} full={i === 0} />
|
||||
@@ -311,6 +347,7 @@ export function SplitChatBubble({
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<BubbleNotes items={tail} align={align} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { renderContent } from "../../lib/renderContent";
|
||||
|
||||
function shell(children: ReactNode): ReactNode {
|
||||
return (
|
||||
<div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
|
||||
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -116,7 +116,7 @@ export function VisionMessageBody({ message }: { message: ChatMessage }): ReactN
|
||||
const hasImage = Boolean(message.image);
|
||||
if (!message.content && !message.streaming && !hasImage) return null;
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]">
|
||||
<div className="liquid-glass-soft overflow-hidden rounded-[var(--mu-radius-lg)]">
|
||||
{hasImage ? (
|
||||
<img
|
||||
src={message.image as string}
|
||||
@@ -154,7 +154,7 @@ export function VoiceMessageBody({ message }: { message: ChatMessage }): ReactNo
|
||||
}, [message.streaming]);
|
||||
|
||||
return (
|
||||
<div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-3">
|
||||
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--mu-accent)]/15 text-[var(--mu-accent)]">
|
||||
<Icon name="mic" size={20} />
|
||||
@@ -229,6 +229,6 @@ export function modeBody(mode: Mode, message: ChatMessage): ReactNode {
|
||||
case "voice": return <VoiceMessageBody message={message} />;
|
||||
case "agents": return <AgentsMessageBody message={message} />;
|
||||
case "settings": return <SettingsMessageBody message={message} />;
|
||||
default: return <div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">{fallbackText(message)}</div>;
|
||||
default: return <div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">{fallbackText(message)}</div>;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
// Structured parser for persona action/narration XML tags (design/07 §1).
|
||||
// A single message `content` may mix plain text with `<action>…</action>` and
|
||||
// `<narration>…</narration>`; this splits it into an ordered parts array so the
|
||||
// UI can render inline (grey italic) while the structured data stays available
|
||||
// for downstream feeds (mind-map / state) via `summarizeContent`.
|
||||
// UI can position each block (inline, or as an attachment note via `splitBubble`)
|
||||
// while the structured data stays available for downstream feeds (mind-map /
|
||||
// state) via `summarizeContent`.
|
||||
|
||||
export type ContentPartType = "text" | "action" | "narration";
|
||||
|
||||
@@ -64,3 +65,58 @@ export function summarizeContent(content: string): ContentSummary {
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Result of `splitBubble`: a renderable plain-text `body` plus the annotation
|
||||
* blocks that belong above (`head`) or below (`tail`) the bubble. `head`/`tail`
|
||||
* hold the leading / trailing action & narration parts in order; `body` is the
|
||||
* concatenated text with any annotation blocks removed. */
|
||||
export interface BubbleSplit {
|
||||
body: string;
|
||||
head: ContentPart[];
|
||||
tail: ContentPart[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Peel the `<action>` / `<narration>` blocks out of `content` so the bubble
|
||||
* body is pure text while the annotations attach as notes outside it.
|
||||
*
|
||||
* - `head` = the leading run of action/narration before the FIRST text part
|
||||
* (rendered above the bubble).
|
||||
* - `tail` = the trailing run of action/narration after the LAST text part
|
||||
* (rendered below the bubble).
|
||||
* - `body` = every text part concatenated and outer-trimmed.
|
||||
* - Annotation blocks interleaved BETWEEN text paragraphs have no stable text
|
||||
* anchor once the body collapses, so they lift to `head` — the only hard
|
||||
* requirement is that they never render inside the bubble.
|
||||
* - A content with no text part at all yields an empty `body` with everything
|
||||
* in `head`, so the notes alone carry the message.
|
||||
*/
|
||||
export function splitBubble(content: string): BubbleSplit {
|
||||
const parts = parseContent(content);
|
||||
let firstTextIdx = -1;
|
||||
let lastTextIdx = -1;
|
||||
parts.forEach((part, i) => {
|
||||
if (part.type === "text") {
|
||||
if (firstTextIdx < 0) firstTextIdx = i;
|
||||
lastTextIdx = i;
|
||||
}
|
||||
});
|
||||
|
||||
const head: ContentPart[] = [];
|
||||
const tail: ContentPart[] = [];
|
||||
let bodyText = "";
|
||||
|
||||
parts.forEach((part, i) => {
|
||||
if (part.type === "text") {
|
||||
bodyText += part.text;
|
||||
return;
|
||||
}
|
||||
if (firstTextIdx < 0 || i < firstTextIdx || (i > firstTextIdx && i < lastTextIdx)) {
|
||||
head.push(part);
|
||||
} else {
|
||||
tail.push(part);
|
||||
}
|
||||
});
|
||||
|
||||
return { body: bodyText.trim(), head, tail };
|
||||
}
|
||||
@@ -1,19 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { parseContent, type ContentPart } from "../lib/parseContent";
|
||||
import { parseContent } from "../lib/parseContent";
|
||||
|
||||
/** Render a message body into inline parts: plain text as-is, while
|
||||
* `<action>` / `<narration>` become muted italic spans (Miuix `--mu-text-lo`).
|
||||
* Whitespace is preserved by the caller's `whitespace-pre-wrap` container — the
|
||||
* returned nodes are just the segments dropped into it in order. */
|
||||
/** Render a message body into its plain-text segments only. `<action>` /
|
||||
* `<narration>` blocks are NOT rendered inline here — they are peeled out by
|
||||
* `splitBubble` and shown as attachment notes outside the bubble. */
|
||||
export function renderContent(content: string): ReactNode[] {
|
||||
return parseContent(content).map((part, i) => renderPart(part, i));
|
||||
}
|
||||
|
||||
function renderPart(part: ContentPart, key: number): ReactNode {
|
||||
if (part.type === "text") return <span key={key}>{part.text}</span>;
|
||||
return (
|
||||
<span key={key} className="italic text-[var(--mu-text-lo)]">
|
||||
{part.text}
|
||||
</span>
|
||||
);
|
||||
return parseContent(content)
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part, i) => <span key={i}>{part.text}</span>);
|
||||
}
|
||||
@@ -269,6 +269,10 @@ export interface AdminChannelAccount {
|
||||
groupMsgPerMin?: number;
|
||||
msgPerMin?: number;
|
||||
maxMessages?: number;
|
||||
/** Connected bot identity (kook resolves it from /api/v3/user/me). */
|
||||
botId?: string;
|
||||
botUsername?: string;
|
||||
botNickname?: string;
|
||||
}
|
||||
|
||||
/** An IoT device as returned by GET /api/v1/admin/iot. */
|
||||
|
||||
Reference in new issue
Block a user