Merge remote-tracking branch 'origin/main'

This commit is contained in:
Autumn-27 committed 2026-09-16 11:07:02 -04:00
commit a15285a7ae
22 files changed
+1248 -43

No files matched your search

+3
View File
@@ -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`),
+31
View File
@@ -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);
});
+62
View File
@@ -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,
}),
);
}
+14
View File
@@ -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" };