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

+7 -7
View File
@@ -22,6 +22,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { MentionTextarea } from "@/components/mention-textarea";
import { SideQuestionButton, SideQuestionWorkspace } from "@/components/side-question-workspace";
import { TodoPopover } from "@/components/todo-popover";
import { ApprovalExecutionFocus, useApprovalFocus, useApprovalHistory } from "@/components/approval-execution-focus";
@@ -51,7 +52,6 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { useSideQuestions } from "@/hooks/use-side-questions";
import { mergeActivities } from "@/lib/activity-merge";
import { api } from "@/lib/api";
@@ -237,13 +237,13 @@ function Composer({
</Button>
</>
)}
<Textarea
<MentionTextarea
className="max-h-40 min-h-10 min-w-0 flex-1 resize-none"
rows={1}
placeholder={placeholder}
value={value}
disabled={disabled && !allowBtw}
onChange={(e) => onChange(e.target.value)}
disabled={disabled && !(running && allowBtw)}
onValueChange={onChange}
onKeyDown={onKeyDown}
/>
{running && allowBtw && isBtwCommand(value) && (
@@ -456,7 +456,7 @@ function DraftChat({
onChange={setInput}
onSend={send}
disabled={sending || uploading || !agentKey}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
placeholder="输入消息,@ 引用记录,Enter 发送"
leftSlot={agentPicker}
onPickFiles={pickFiles}
uploading={uploading}
@@ -811,9 +811,9 @@ function ChatView({
value={input}
onChange={setInput}
onSend={send}
disabled={running}
disabled={running || sending}
allowBtw
placeholder={running ? "Agent 正在回复,可输入 /btw 提问…" : "输入消息,Enter 发送,Shift+Enter 换行"}
placeholder={running ? "Agent 正在回复,可输入 /btw 提问…" : "输入消息,@ 引用记录,Enter 发送"}
running={running}
onStop={stop}
stopDisabled={stopping}
@@ -42,8 +42,9 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { MentionTextarea } from "@/components/mention-textarea";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
import { InputGroup, InputGroupAddon, InputGroupButton } from "@/components/ui/input-group";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
@@ -1903,12 +1904,14 @@ export function SessionsTab({ taskId }: { taskId: string }) {
onChange={(e) => void pickFiles(e.target.files)}
/>
<InputGroup className="min-h-9 has-disabled:opacity-100">
<InputGroupTextarea
<MentionTextarea
inputGroup
rows={1}
aria-label="给主 Agent 发消息"
placeholder={mainBusy ? "主 Agent 正在运行,可输入 /btw 提问…" : "给主 Agent 发消息,引导探索方向…"}
placeholder={mainBusy ? "主 Agent 正在运行,可输入 /btw 提问…" : "给主 Agent 发消息,@ 引用漏洞、资产等…"}
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={sending}
onValueChange={setInput}
onKeyDown={(e) => {
if (!shouldSubmitOnKey(e, sendMode)) return;
e.preventDefault();
@@ -1965,13 +1968,14 @@ export function SessionsTab({ taskId }: { taskId: string }) {
(active.status === "running" || active.status === "paused") ? (
<div className="border-t p-3">
<InputGroup className="min-h-9 has-disabled:opacity-100">
<InputGroupTextarea
<MentionTextarea
inputGroup
rows={1}
aria-label={`给 Worker #${active.intent_id} 发消息`}
placeholder={`给 Worker #${active.intent_id} 发消息,调整执行方向…`}
placeholder={`给 Worker #${active.intent_id} 发消息,@ 引用记录,调整执行方向…`}
value={workerMessage}
onChange={(event) => {
setWorkerMessage(event.target.value);
onValueChange={(value) => {
setWorkerMessage(value);
setWorkerMessageRequestId("");
}}
onKeyDown={(event) => {
+38 -8
View File
@@ -24,7 +24,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import type { InterceptApprovalRow, InterceptAudit, InterceptDetail, InterceptReviewInput } from "@/lib/types";
import type { InterceptApprovalRow, InterceptAudit, InterceptDetail, InterceptPending, InterceptReviewInput } from "@/lib/types";
import { cn } from "@/lib/utils";
function fmtTime(value?: string) {
@@ -640,12 +640,14 @@ function ApprovalTable({
export function ApprovalRecords({ taskId }: { taskId?: string }) {
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
const [pendingRows, setPendingRows] = React.useState<InterceptPending[]>([]);
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(20);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [refreshing, setRefreshing] = React.useState(false);
const [error, setError] = React.useState("");
const [pendingError, setPendingError] = React.useState("");
const [deciding, setDeciding] = React.useState(false);
const [revision, setRevision] = React.useState(0);
const request = React.useRef(0);
@@ -663,13 +665,26 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
const id = ++request.current;
if (manual) setRefreshing(true);
try {
const result = taskId
? await api.interceptTaskPage(taskId, page, pageSize)
: await api.interceptHistoryPage(page, pageSize);
// The approval queue is independent of the current history page. Older
// requests must remain actionable even when newer decisions fill it.
const [history, pending] = await Promise.allSettled([
taskId ? api.interceptTaskPage(taskId, page, pageSize) : api.interceptHistoryPage(page, pageSize),
api.interceptPending(),
]);
if (id !== request.current) return;
setRows(result.items);
setTotal(result.total);
setError("");
if (history.status === "fulfilled") {
setRows(history.value.items);
setTotal(history.value.total);
setError("");
} else {
setError((history.reason as Error).message || "加载失败");
}
if (pending.status === "fulfilled") {
setPendingRows(pending.value);
setPendingError("");
} else {
setPendingError((pending.reason as Error).message || "加载失败");
}
if (manual) setRevision((v) => v + 1);
} catch (e) {
if (id === request.current) setError((e as Error).message || "加载失败");
@@ -708,6 +723,7 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
setRows((prev) =>
prev.map((row) => (row.id === id ? { ...row, status: decision, decided_at: new Date().toISOString() } : row)),
);
setPendingRows((prev) => prev.filter((row) => row.id !== id));
setRevision((v) => v + 1);
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
} catch (e) {
@@ -719,7 +735,16 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
}
};
const pending = rows.filter((row) => row.status === "pending");
const historyById = new Map(rows.map((row) => [row.id, row]));
const pending: InterceptApprovalRow[] = pendingRows
.filter((row) => !taskId || row.task_id === taskId)
.map((row) => ({
conv_title: "",
conv_agent_key: "",
rule_name: row.rule_id ? `规则 #${row.rule_id}` : "",
...historyById.get(row.id),
...row,
}));
const title = taskId ? "拦截审批" : "审批记录";
return (
<div className="flex min-w-0 flex-col gap-5">
@@ -740,6 +765,11 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
<AlertDescription>记录加载失败:{error}。请点击刷新重试。</AlertDescription>
</Alert>
) : null}
{pendingError ? (
<Alert variant="destructive">
<AlertDescription>待审批加载失败:{pendingError}。请点击刷新重试。</AlertDescription>
</Alert>
) : null}
{pending.length ? (
<section className="overflow-hidden rounded-xl border">
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-3 font-medium text-sm">
+353
View File
@@ -0,0 +1,353 @@
"use client";
import * as React from "react";
import { AtSignIcon, ChevronRightIcon, Loader2Icon, XIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { InputGroupTextarea } from "@/components/ui/input-group";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import {
activeMention,
type ChatMention,
mentionKinds,
mentionSearch,
mentionToken,
selectedMentions,
} from "@/lib/chat-mentions";
import { cn } from "@/lib/utils";
type Props = Omit<React.ComponentProps<"textarea">, "value" | "onChange" | "ref"> & {
value: string;
onValueChange: (value: string) => void;
inputGroup?: boolean;
};
export function MentionTextarea({ value, onValueChange, onKeyDown, className, inputGroup, disabled, ...props }: Props) {
const textarea = React.useRef<HTMLTextAreaElement>(null);
const composing = React.useRef(false);
const pendingKeyboardIndex = React.useRef<number | null>(null);
const [cursor, setCursor] = React.useState<number | null>(null);
const [dismissed, setDismissed] = React.useState(false);
const [activeIndex, setActiveIndex] = React.useState(0);
const [result, setResult] = React.useState<{
key: string;
items: ChatMention[];
next_cursor?: string;
error?: string;
loadingMore?: boolean;
pageError?: string;
} | null>(null);
const request = React.useRef<{ key: string; controller: AbortController; loadingMore: boolean } | null>(null);
const listId = React.useId();
const active = cursor === null ? null : activeMention(value, cursor);
const search = mentionSearch(active?.query ?? "");
const open = !!active && !dismissed && !disabled;
const categories = search.categories;
const requestKey = open && !categories.length ? `${search.kind}:${search.query}` : "";
const loading = !!requestKey && result?.key !== requestKey;
const items = result?.key === requestKey ? result.items : [];
const error = result?.key === requestKey ? result.error : undefined;
const nextCursor = result?.key === requestKey ? result.next_cursor : undefined;
const loadingMore = result?.key === requestKey && result.loadingMore;
const pageError = result?.key === requestKey ? result.pageError : undefined;
const count = categories.length || items.length;
const selected = selectedMentions(value);
React.useEffect(() => {
if (!requestKey) return;
setResult(null);
const controller = new AbortController();
request.current = { key: requestKey, controller, loadingMore: false };
const timer = setTimeout(() => {
api.chatMentions(search.kind, search.query, controller.signal).then(
(page) => {
if (!controller.signal.aborted) setResult({ key: requestKey, ...page });
},
(error: Error) => {
if (!controller.signal.aborted) setResult({ key: requestKey, items: [], error: error.message });
},
);
}, 180);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [requestKey, search.kind, search.query]);
function loadMore() {
const session = request.current;
if (
!nextCursor ||
!session ||
session.key !== requestKey ||
session.controller.signal.aborted ||
session.loadingMore
)
return;
session.loadingMore = true;
setResult((current) =>
current?.key === requestKey ? { ...current, loadingMore: true, pageError: undefined } : current,
);
api
.chatMentions(search.kind, search.query, session.controller.signal, nextCursor)
.then(
(page) => {
if (session.controller.signal.aborted) return;
setResult((current) => {
if (current?.key !== session.key) return current;
const seen = new Set(current.items.map((item) => `${item.kind}:${item.id}`));
return {
...current,
items: [...current.items, ...page.items.filter((item) => !seen.has(`${item.kind}:${item.id}`))],
next_cursor: page.next_cursor,
loadingMore: false,
};
});
},
(error: Error) => {
if (!session.controller.signal.aborted)
setResult((current) =>
current?.key === session.key ? { ...current, loadingMore: false, pageError: error.message } : current,
);
},
)
.finally(() => {
session.loadingMore = false;
});
}
React.useEffect(() => {
if (open) document.getElementById(`${listId}-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
}, [activeIndex, listId, open]);
React.useEffect(() => {
if (open && pendingKeyboardIndex.current !== null && pendingKeyboardIndex.current < count) {
document.getElementById(`${listId}-${pendingKeyboardIndex.current}`)?.scrollIntoView({ block: "nearest" });
pendingKeyboardIndex.current = null;
}
}, [count, listId, open]);
function replaceActive(text: string, close: boolean) {
if (!active) return;
const next = value.slice(0, active.start) + text + value.slice(active.end);
const caret = active.start + text.length;
onValueChange(next);
setCursor(caret);
setDismissed(close);
setActiveIndex(0);
requestAnimationFrame(() => {
textarea.current?.focus();
textarea.current?.setSelectionRange(caret, caret);
});
}
function choose(index: number) {
if (categories[index]) replaceActive(`@${categories[index].label} `, false);
else if (items[index]) replaceActive(`${mentionToken(items[index])} `, true);
}
function keyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229) return;
if (open) {
if (event.key === "Escape") {
event.preventDefault();
setDismissed(true);
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (event.key === "ArrowDown" && count && activeIndex >= count - 1 && nextCursor) {
pendingKeyboardIndex.current = count;
setActiveIndex(count);
loadMore();
return;
}
if (count) setActiveIndex((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
return;
}
if ((event.key === "Enter" || event.key === "Tab") && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
if (event.key === "Tab" && (loading || !count)) {
setDismissed(true);
return;
}
event.preventDefault();
if (!loading && count) choose(Math.min(activeIndex, count - 1));
return;
}
}
onKeyDown?.(event);
}
const Control = inputGroup ? InputGroupTextarea : Textarea;
return (
<div className="min-w-0 flex-1 self-stretch">
<Popover
open={open}
onOpenChange={(next) => {
if (!next) setDismissed(true);
}}
>
<PopoverAnchor asChild>
<div>
<Control
{...props}
ref={textarea}
value={value}
disabled={disabled}
className={className}
aria-label={props["aria-label"] ?? "消息,输入 @ 引用记录"}
aria-autocomplete="list"
aria-controls={open ? listId : undefined}
aria-expanded={open}
aria-activedescendant={open && count ? `${listId}-${Math.min(activeIndex, count - 1)}` : undefined}
onChange={(event) => {
pendingKeyboardIndex.current = null;
onValueChange(event.target.value);
setCursor(event.target.selectionStart);
setDismissed(false);
setActiveIndex(0);
}}
onSelect={(event) => setCursor(event.currentTarget.selectionStart)}
onCompositionStart={() => {
composing.current = true;
}}
onCompositionEnd={() => {
composing.current = false;
}}
onKeyDown={keyDown}
/>
</div>
</PopoverAnchor>
<PopoverContent
side="top"
align="start"
className="w-[min(28rem,calc(100vw-2rem))] gap-1 p-1.5"
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
if (event.target === textarea.current) event.preventDefault();
}}
aria-label="选择引用记录"
>
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-xs">
<span>
{categories.length
? "选择引用类型"
: `搜索${mentionKinds.find((kind) => kind.kind === search.kind)?.label ?? "全部记录"}`}
</span>
<span>↑↓ 选择 · Enter 确认 · Esc 关闭</span>
</div>
<div
id={listId}
role="listbox"
aria-label="引用候选"
className="max-h-60 overflow-y-auto"
onScroll={(event) => {
const list = event.currentTarget;
if (list.scrollHeight - list.scrollTop - list.clientHeight < 64) loadMore();
}}
>
{categories.map((kind, index) => (
<button
key={kind.kind}
id={`${listId}-${index}`}
type="button"
role="option"
aria-selected={activeIndex === index}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm hover:bg-accent",
activeIndex === index && "bg-accent",
)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(index)}
>
<AtSignIcon className="size-4 text-muted-foreground" />
<span>{kind.label}</span>
<ChevronRightIcon className="ml-auto size-4 text-muted-foreground" />
</button>
))}
{!categories.length && loading && (
<div role="status" className="flex items-center gap-2 p-3 text-muted-foreground text-sm">
<Loader2Icon className="size-4 animate-spin" />
搜索中…
</div>
)}
{!categories.length && !loading && error && (
<div role="alert" className="p-3 text-destructive text-sm">
搜索失败:{error}。请重新输入重试。
</div>
)}
{!categories.length && !loading && !error && !items.length && (
<div role="status" className="p-3 text-muted-foreground text-sm">
没有匹配记录,请更换名称、地址或 ID
</div>
)}
{items.map((item, index) => (
<button
key={`${item.kind}:${item.id}`}
id={`${listId}-${index}`}
type="button"
role="option"
aria-selected={activeIndex === index}
className={cn(
"flex w-full flex-col gap-0.5 rounded-md px-2 py-2 text-left hover:bg-accent",
activeIndex === index && "bg-accent",
)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(index)}
>
<span className="flex w-full items-center gap-2">
<Badge variant="outline">{mentionKinds.find((kind) => kind.kind === item.kind)?.label}</Badge>
<span className="truncate text-sm">{item.label}</span>
<span className="ml-auto shrink-0 text-muted-foreground text-xs">#{item.id}</span>
</span>
<span className="w-full truncate text-muted-foreground text-xs">{item.description}</span>
</button>
))}
</div>
{!categories.length && (
<p className="px-2 py-1 text-muted-foreground text-xs">
{loadingMore && <span role="status">正在加载更多…</span>}
{!loadingMore && nextCursor && (
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={loadMore}>
{pageError ? "加载失败,点击重试" : `已显示 ${items.length} 条,向下滚动加载更多`}
</button>
)}
{!loadingMore && !nextCursor && !loading && !error && items.length > 0 && `已显示全部 ${items.length} 条`}
{!loadingMore &&
!nextCursor &&
(loading || !!error || items.length === 0) &&
"输入名称、地址或 ID 搜索记录"}
</p>
)}
</PopoverContent>
</Popover>
{selected.length > 0 && (
<div className="flex flex-wrap items-center gap-1 px-1 pt-1">
{selected.map((item) => (
<Badge key={item.start} variant="secondary" className="max-w-full gap-1">
<span className="max-w-64 truncate" title={item.label}>
{item.label}
</span>
<button
type="button"
disabled={disabled}
aria-label={`移除引用 ${item.label}`}
onClick={() => {
onValueChange(value.slice(0, item.start) + value.slice(item.start + item.token.length));
setCursor(null);
}}
>
<XIcon className="size-3" />
</button>
</Badge>
))}
<span className="text-muted-foreground text-xs">发送时读取最新详情 · 最多 10 条</span>
</div>
)}
</div>
);
}
+5 -5
View File
@@ -602,14 +602,14 @@ function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean
}, [inView, step.seq, getDetail, step.summary, inline]);
const { text, attachments } = parseUserBody(full ?? step.summary);
return (
<div ref={ref} className="mt-3 mb-2 flex justify-end gap-2">
<div className="flex max-w-[85%] flex-col items-end gap-1.5">
<div ref={ref} className="mt-3 mb-2 flex min-w-0 justify-end gap-2">
<div className="flex min-w-0 max-w-[85%] flex-col items-end gap-1.5">
{attachments.length > 0 && (
<div className="flex flex-wrap justify-end gap-1.5">
<div className="flex min-w-0 max-w-full flex-wrap justify-end gap-1.5">
{attachments.map((a) => (
<div
key={a.path}
className="flex items-center gap-1.5 rounded-md border bg-card px-2 py-1 text-xs shadow-sm"
className="flex min-w-0 max-w-full items-center gap-1.5 rounded-md border bg-card px-2 py-1 text-xs shadow-sm"
title={a.path}
>
<PaperclipIcon className="size-3 shrink-0 text-primary" />
@@ -620,7 +620,7 @@ function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean
</div>
)}
{text && (
<div className="whitespace-pre-wrap break-words rounded-lg rounded-tr-sm bg-primary px-3 py-1.5 text-sm text-primary-foreground">
<div className="min-w-0 max-w-full whitespace-pre-wrap rounded-lg rounded-tr-sm bg-primary px-3 py-1.5 text-sm text-primary-foreground [overflow-wrap:anywhere]">
{text}
</div>
)}
+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" };