Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
a15285a7ae
22 files changed
+1248
-43
No files matched your search
@@ -3,6 +3,7 @@
|
||||
// a few fields the backend serializes differently (e.g. created_at as a unix int)
|
||||
// are passed through and formatted at the call site.
|
||||
|
||||
import type { ChatMention } from "@/lib/chat-mentions";
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import { mockHandle } from "@/lib/mock/handler";
|
||||
import type {
|
||||
@@ -732,6 +733,8 @@ export const api = {
|
||||
if (!r.ok) throw new Error(`report: ${r.status}`);
|
||||
return r.text();
|
||||
},
|
||||
chatMentions: (kind: string, query: string, signal?: AbortSignal, cursor = "") =>
|
||||
http<{ items: ChatMention[]; next_cursor?: string }>(`/chat/mentions?${new URLSearchParams({ kind, q: query, cursor })}`, { signal }),
|
||||
chat: (message: string, task?: string, attachments?: ChatAttachment[], seg?: number) =>
|
||||
post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments, seg }),
|
||||
chatStatus: (taskId: string) => get<{ running: boolean }>(`/tasks/${taskId}/chat/status`),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { activeMention, mentionSearch, mentionToken, selectedMentions } from "./chat-mentions.ts";
|
||||
|
||||
test("mention trigger supports Chinese and cursor placement without hijacking email", () => {
|
||||
assert.equal(activeMention("[email protected]", 16), null);
|
||||
assert.equal(activeMention("已选 @[漏洞#1 X]", 12), null);
|
||||
assert.deepEqual(activeMention("查看@漏洞 后面的文字", 5), { start: 2, end: 5, query: "漏洞" });
|
||||
assert.equal(activeMention("@漏洞\n下一行", 8), null);
|
||||
});
|
||||
|
||||
test("categories, Chinese aliases, IP and keyword search", () => {
|
||||
assert.equal(mentionSearch("").categories.length, 9);
|
||||
assert.equal(mentionSearch("漏").categories[0].kind, "finding");
|
||||
assert.equal(mentionSearch("漏洞").kind, "finding");
|
||||
assert.equal(mentionSearch("漏洞SQL注入").query, "SQL注入");
|
||||
assert.equal(mentionSearch("ip 192.0.2.1").kind, "ip");
|
||||
assert.equal(mentionSearch("接口 GET /api").query, "GET /api");
|
||||
assert.equal(mentionSearch("acme.com").kind, "");
|
||||
});
|
||||
|
||||
test("tokens roundtrip labels and removing one reference preserves its neighbors", () => {
|
||||
const first = mentionToken({ kind: "finding", id: 12, label: "标题[1]\n描述" });
|
||||
const second = mentionToken({ kind: "ip", id: 13, label: "192.0.2.1" });
|
||||
const value = `分析 ${first} 和 ${second}`;
|
||||
const selected = selectedMentions(value);
|
||||
assert.equal(selected.length, 2);
|
||||
assert.equal(selected[0].label, "漏洞 #12 · 标题(1) 描述");
|
||||
const next = value.slice(0, selected[0].start) + value.slice(selected[0].start + selected[0].token.length);
|
||||
assert.equal(selectedMentions(next)[0].token, second);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
export const mentionKinds = [
|
||||
{ kind: "finding", label: "漏洞", alias: "finding" },
|
||||
{ kind: "asset", label: "资产", alias: "asset" },
|
||||
{ kind: "company", label: "企业", alias: "company" },
|
||||
{ kind: "endpoint", label: "接口", alias: "api" },
|
||||
{ kind: "ip", label: "IP", alias: "ip" },
|
||||
{ kind: "app", label: "应用", alias: "app" },
|
||||
{ kind: "root_domain", label: "域名", alias: "domain" },
|
||||
{ kind: "subdomain", label: "子域名", alias: "subdomain" },
|
||||
{ kind: "service", label: "服务", alias: "service" },
|
||||
] as const;
|
||||
|
||||
export type MentionKind = (typeof mentionKinds)[number]["kind"];
|
||||
export interface ChatMention {
|
||||
kind: MentionKind;
|
||||
id: number;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function activeMention(value: string, caret: number) {
|
||||
const before = value.slice(0, caret);
|
||||
const start = before.lastIndexOf("@");
|
||||
if (start < 0 || (start > 0 && /[\w.+/-]/.test(before[start - 1]))) return null;
|
||||
const query = before.slice(start + 1);
|
||||
if (/[[\]\r\n@]/.test(query) || query.length > 220) return null;
|
||||
return { start, end: caret, query };
|
||||
}
|
||||
|
||||
export function mentionSearch(query: string) {
|
||||
const text = query.trimStart().toLowerCase();
|
||||
for (const item of mentionKinds) {
|
||||
for (const alias of [item.label.toLowerCase(), item.alias]) {
|
||||
if (text === alias || text.startsWith(`${alias} `) || (/[^a-z]/.test(alias) && text.startsWith(alias))) {
|
||||
return { kind: item.kind, query: query.trimStart().slice(alias.length).trim(), categories: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
const categories = mentionKinds.filter(
|
||||
(item) => item.label.toLowerCase().startsWith(text) || item.alias.startsWith(text),
|
||||
);
|
||||
return { kind: "" as const, query: query.trim(), categories };
|
||||
}
|
||||
|
||||
export function mentionToken(item: ChatMention) {
|
||||
const kind = mentionKinds.find((entry) => entry.kind === item.kind)?.label ?? "资产";
|
||||
const label = item.label
|
||||
.replace(/[[\]]/g, (char) => (char === "[" ? "(" : ")"))
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 100);
|
||||
return `@[${kind}#${item.id} ${label}]`;
|
||||
}
|
||||
|
||||
export function selectedMentions(value: string) {
|
||||
return [...value.matchAll(/@\[(漏洞|资产|企业|接口|IP|应用|域名|子域名|服务)#([0-9]+)(?: ([^\]\r\n]*))?\]/g)].map(
|
||||
(match) => ({
|
||||
token: match[0],
|
||||
label: `${match[1]} #${match[2]}${match[3] ? ` · ${match[3]}` : ""}`,
|
||||
start: match.index,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1006,6 +1006,20 @@ export async function mockHandle<T>(method: string, rawPath: string, body?: Body
|
||||
function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Record<string, unknown>): unknown {
|
||||
const task = q.get("task") ?? undefined;
|
||||
|
||||
if (path === "/chat/mentions" && m === "GET") {
|
||||
const kind = q.get("kind") ?? "";
|
||||
const query = (q.get("q") ?? "").trim().toLowerCase();
|
||||
const candidates = [
|
||||
...D.findings.map((finding, index) => ({ kind: "finding", id: index + 1, label: finding.name || finding.vulnclass, description: `${finding.severity} · ${finding.summary}` })),
|
||||
...D.companies.map((company) => ({ kind: "company", id: company.id, label: company.name, description: "企业" })),
|
||||
...D.assets.map((asset) => ({ kind: asset.type, id: asset.id, label: asset.type === "endpoint" ? `${asset.method || "GET"} ${asset.url}` : asset.app_name || asset.url || asset.domain || asset.ip || asset.bundle_id || `资产 #${asset.id}`, description: [asset.type, asset.page_title, asset.service_name, asset.bundle_id, asset.ip].filter(Boolean).join(" · ") })),
|
||||
];
|
||||
const filtered = candidates.filter((item) => (!kind || kind === item.kind || (kind === "asset" && item.kind !== "finding" && item.kind !== "company")) && (!query || String(item.id) === query || `${item.label} ${item.description}`.toLowerCase().includes(query)))
|
||||
.sort((a, b) => Number(String(b.id) === query) - Number(String(a.id) === query) || b.id - a.id || a.kind.localeCompare(b.kind));
|
||||
const offset = Math.max(0, Number(q.get("cursor")) || 0);
|
||||
return { items: filtered.slice(offset, offset + 20), next_cursor: offset + 20 < filtered.length ? String(offset + 20) : undefined };
|
||||
}
|
||||
|
||||
// ── auth:让 demo 直接进主界面 ──
|
||||
if (path === "/auth/status") return { initialized: true };
|
||||
if (path === "/auth/login" || path === "/auth/init") return { token: "mock-demo" };
|
||||
|
||||
Reference in new issue
Block a user