Files
tutti/packages/agent/gui/agent-gui/agentGuiNode/AgentMentionSearchControllerBase.ts
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

801 lines
24 KiB
TypeScript

import type { RuntimeDiagnosticsDetailValue } from "../../shared/contracts/dto/debug";
import {
emitAgentMentionSearchDiagnostic,
logAgentMentionSearchInfo,
queryAgentMentionProviderWithDiagnostics,
type AgentMentionProviderQueryDiagnostic,
type AgentMentionSearchDiagnosticLog
} from "./agentMentionSearchDiagnostics";
import {
DEFAULT_AGENT_MENTION_FILTER,
mentionGroupPageSize
} from "./agentMentionSearchHelpers";
import type { AgentContextMentionItem } from "./agentRichText/agentFileMentionExtension";
import type { AgentContextMentionProvider } from "./agentContextMentionProvider";
import {
buildBrowseCategories,
DEFAULT_BROWSE_CACHE_TTL_MS,
DEFAULT_DEBOUNCE_MS,
DEFAULT_DIAGNOSTIC_SLOW_THRESHOLD_MS,
DEFAULT_FILE_LIMIT,
DEFAULT_ISSUE_LIMIT,
DEFAULT_PROVIDER_TIMEOUT_MS,
type AgentMentionFilterId,
type AgentMentionGroup,
type AgentMentionGroupId,
type AgentMentionIssueTopicGroup,
type AgentMentionLifecycleDiagnosticLog,
type AgentMentionRawGroups,
type AgentMentionSearchControllerOptions,
type AgentMentionSearchListener,
type AgentMentionSearchState,
type AgentMentionTotalCounts
} from "./AgentMentionSearchContracts";
import {
buildAgentMentionBrowseCacheKey,
loadAgentMentionBrowseFetchResult,
mergeAgentMentionBrowseIssueGroupPage,
readAgentMentionBrowseCache,
type AgentMentionBrowseCacheEntry,
type AgentMentionBrowseFetchResult,
type AgentMentionBrowseLoadReason
} from "./AgentMentionSearchCache";
import {
buildAgentMentionGroups,
cloneAgentMentionIssueTopicGroups,
cloneAgentMentionRawGroups,
elapsedDiagnosticMs,
emptyAgentMentionRawGroups,
issueTopicPaginationChanges,
logAgentMentionLifecycleDiagnostic,
rawGroupItemCount
} from "./AgentMentionSearchModel";
import {
fetchAgentMentionFilterResult,
queryAgentMentionProviderGroups,
queryAgentMentionProviderItems
} from "./AgentMentionSearchIndex";
import type {
ReferenceProvenanceCatalog,
ReferenceProvenanceFilter
} from "@tutti-os/workspace-file-reference/contracts";
import { referenceProvenanceFilterCacheKey } from "@tutti-os/workspace-file-reference/core";
import {
agentGuiScheduler,
type AgentGuiScheduledTask
} from "./agentGuiScheduler";
export class AgentMentionSearchControllerBase {
protected readonly contextMentionProviders: ReadonlyMap<
string,
AgentContextMentionProvider
>;
protected readonly debounceMs: number;
protected readonly fileLimit: number;
protected readonly issueLimit: number;
protected readonly browseCacheTtlMs: number;
protected readonly providerTimeoutMs: number;
protected readonly diagnosticInfoLogger: (
payload: AgentMentionSearchDiagnosticLog
) => void;
protected readonly diagnosticNow: () => number;
protected readonly diagnosticSlowThresholdMs: number;
protected readonly hiddenFilterIds: readonly string[];
protected readonly listeners = new Set<AgentMentionSearchListener>();
protected readonly expandedCounts: Partial<
Record<AgentMentionGroupId, number>
> = {};
protected readonly totalCounts: AgentMentionTotalCounts = {};
protected readonly scheduler = agentGuiScheduler;
protected timer: AgentGuiScheduledTask | null = null;
protected preloadCancel: (() => void) | null = null;
protected pendingPreloadKey: string | null = null;
protected requestId = 0;
protected activeRequestAbortController: AbortController | null = null;
protected disposed = false;
protected activeWorkspaceId = "";
protected currentUserId = "";
protected currentFilter: AgentMentionFilterId = DEFAULT_AGENT_MENTION_FILTER;
protected currentQuery = "";
protected currentSessionCwd = "";
protected currentSectionKey = "";
protected currentProvenanceFilter: ReferenceProvenanceFilter | null = null;
protected currentProvenanceCatalog: ReferenceProvenanceCatalog | null = null;
protected currentFileSearchLimit: number;
protected currentIssueSearchLimit: number;
protected agentGeneratedBrowsePath: string | null = null;
protected workspaceFileBrowsePaths: string[] = [];
protected rawGroups: AgentMentionRawGroups = emptyAgentMentionRawGroups();
protected issueTopicGroups: AgentMentionIssueTopicGroup[] | null = null;
protected state: AgentMentionSearchState = {
status: "idle",
query: "",
mode: "browse",
filter: DEFAULT_AGENT_MENTION_FILTER,
categories: [],
groups: [],
error: null
};
constructor(options: AgentMentionSearchControllerOptions) {
this.contextMentionProviders = new Map(
(options.contextMentionProviders ?? []).map((provider) => [
provider.id,
provider
])
);
this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
this.fileLimit = options.fileLimit ?? DEFAULT_FILE_LIMIT;
this.issueLimit = options.issueLimit ?? DEFAULT_ISSUE_LIMIT;
this.browseCacheTtlMs =
options.browseCacheTtlMs ?? DEFAULT_BROWSE_CACHE_TTL_MS;
this.providerTimeoutMs =
options.providerTimeoutMs ?? DEFAULT_PROVIDER_TIMEOUT_MS;
this.diagnosticInfoLogger =
options.diagnosticInfoLogger ?? logAgentMentionSearchInfo;
this.diagnosticNow = options.diagnosticNow ?? Date.now;
this.diagnosticSlowThresholdMs =
options.diagnosticSlowThresholdMs ?? DEFAULT_DIAGNOSTIC_SLOW_THRESHOLD_MS;
this.hiddenFilterIds = [...(options.hiddenFilterIds ?? [])];
this.currentFileSearchLimit = this.fileLimit;
this.currentIssueSearchLimit = this.issueLimit;
this.state.categories = buildBrowseCategories(this.hiddenFilterIds);
}
protected startBrowseModeFetch(filter: AgentMentionFilterId): void {
if (!this.activeWorkspaceId) {
this.resetRawGroups();
this.emitBrowseState("ready");
return;
}
this.clearTimer();
const requestId = ++this.requestId;
const cacheKey = this.browseCacheKey({
currentUserId: this.currentUserId,
filter,
sectionKey: this.currentSectionKey,
sessionCwd: this.currentSessionCwd,
workspaceId: this.activeWorkspaceId
});
this.logLifecycle("browse.open", {
filter,
providerIds: this.providerIdsForDiagnostics(),
requestId,
sessionCwdPresent: Boolean(this.currentSessionCwd),
workspaceId: this.activeWorkspaceId
});
const cached = this.readBrowseCache(cacheKey);
this.logBrowseCacheState({
cacheKey,
cached,
filter,
reason: "open",
workspaceId: this.activeWorkspaceId
});
if (cached.entry) {
this.applyBrowseFetchResult(cached.entry);
this.setState({
status: "ready",
query: "",
mode: "browse",
filter: this.currentFilter,
categories: buildBrowseCategories(this.hiddenFilterIds),
groups: this.groupsFromRawGroups(),
error: null
});
// A user-opened palette always revalidates. Cache freshness is used only
// to suppress redundant speculative preloads; here the cached entry is a
// zero-latency presentation while the provider query refreshes it.
} else {
this.rawGroups = emptyAgentMentionRawGroups();
this.resetTotalCounts();
this.emitBrowseState("loading");
}
const abortSignal = this.beginActiveRequest();
void this.runBrowseSearch({
workspaceId: this.activeWorkspaceId,
currentUserId: this.currentUserId,
requestId,
filter,
sectionKey: this.currentSectionKey,
sessionCwd: this.currentSessionCwd,
abortSignal
});
}
protected async runSearch(input: {
workspaceId: string;
currentUserId: string;
query: string;
requestId: number;
filter: AgentMentionFilterId;
provenanceFilter: ReferenceProvenanceFilter | null;
sectionKey: string;
sessionCwd: string;
abortSignal?: AbortSignal;
}): Promise<void> {
const startedAt = this.diagnosticNow();
let providerDiagnostics: AgentMentionProviderQueryDiagnostic[] = [];
try {
const result = await this.fetchFilterResult(
{
workspaceId: input.workspaceId,
currentUserId: input.currentUserId,
query: input.query,
filter: input.filter,
sectionKey: input.sectionKey,
sessionCwd: input.sessionCwd,
includeAgentGeneratedFiles: false
},
input.provenanceFilter,
input.abortSignal
);
providerDiagnostics = result.providerDiagnostics;
if (
!this.canApply(
input.requestId,
input.workspaceId,
input.query,
input.filter
)
) {
return;
}
this.applyBrowseFetchResult(result);
const groups = this.groupsFromRawGroups();
this.setState({
status: "ready",
query: input.query,
mode: "results",
filter: this.currentFilter,
categories: buildBrowseCategories(this.hiddenFilterIds),
groups,
error: null
});
this.logSearchDiagnostic({
filter: input.filter,
groups,
mode: "results",
providerDiagnostics,
query: input.query,
requestId: input.requestId,
startedAt,
status: "ready",
workspaceId: input.workspaceId
});
} catch (error) {
if (
!this.canApply(
input.requestId,
input.workspaceId,
input.query,
input.filter
)
) {
return;
}
this.setState({
status: "error",
query: input.query,
mode: "results",
filter: this.currentFilter,
categories: buildBrowseCategories(this.hiddenFilterIds),
groups: [],
error: error instanceof Error ? error.message : String(error)
});
this.logSearchDiagnostic({
error,
filter: input.filter,
groups: [],
mode: "results",
providerDiagnostics,
query: input.query,
requestId: input.requestId,
startedAt,
status: "error",
workspaceId: input.workspaceId
});
}
}
protected async runBrowseSearch(input: {
workspaceId: string;
currentUserId: string;
requestId: number;
filter: AgentMentionFilterId;
sectionKey: string;
sessionCwd: string;
abortSignal: AbortSignal;
}): Promise<void> {
const startedAt = this.diagnosticNow();
let providerDiagnostics: AgentMentionProviderQueryDiagnostic[] = [];
const provenanceFilter = this.currentProvenanceFilter;
const cacheKey = this.browseCacheKey(input, provenanceFilter);
const issueTopicGroupsAtStart = cloneAgentMentionIssueTopicGroups(
this.issueTopicGroups
);
try {
const result = await this.loadBrowseFetchResult(
input,
cacheKey,
"open",
provenanceFilter,
input.abortSignal
);
providerDiagnostics = result.providerDiagnostics;
if (
!this.canApply(input.requestId, input.workspaceId, "", input.filter)
) {
this.logLifecycle("browse.apply.skipped", {
filter: input.filter,
requestId: input.requestId,
reason: "open",
workspaceId: input.workspaceId
});
return;
}
const paginationChanges = issueTopicPaginationChanges(
issueTopicGroupsAtStart,
this.issueTopicGroups
);
for (const group of paginationChanges) {
mergeAgentMentionBrowseIssueGroupPage({
cacheKey,
group,
cachedAt: this.diagnosticNow()
});
}
const resultToApply =
paginationChanges.length > 0
? (this.readBrowseCache(cacheKey).entry ?? result)
: result;
this.applyBrowseFetchResult(resultToApply);
const groups = this.groupsFromRawGroups();
this.setState({
status: "ready",
query: "",
mode: "browse",
filter: this.currentFilter,
categories: buildBrowseCategories(this.hiddenFilterIds),
groups,
error: null
});
this.logSearchDiagnostic({
filter: input.filter,
groups,
mode: "browse",
providerDiagnostics,
query: "",
requestId: input.requestId,
startedAt,
status: "ready",
workspaceId: input.workspaceId
});
} catch (error) {
if (
!this.canApply(input.requestId, input.workspaceId, "", input.filter)
) {
return;
}
this.setState({
status: "error",
query: "",
mode: "browse",
filter: this.currentFilter,
categories: buildBrowseCategories(this.hiddenFilterIds),
groups: [],
error: error instanceof Error ? error.message : String(error)
});
this.logSearchDiagnostic({
error,
filter: input.filter,
groups: [],
mode: "browse",
providerDiagnostics,
query: "",
requestId: input.requestId,
startedAt,
status: "error",
workspaceId: input.workspaceId
});
}
}
protected async fetchBrowseResult(
input: {
workspaceId: string;
currentUserId: string;
filter: AgentMentionFilterId;
sectionKey: string;
sessionCwd: string;
},
provenanceFilter: ReferenceProvenanceFilter | null = this
.currentProvenanceFilter,
abortSignal?: AbortSignal
): Promise<AgentMentionBrowseFetchResult> {
return this.fetchFilterResult(
{
...input,
query: "",
includeAgentGeneratedFiles: input.filter === "file"
},
provenanceFilter,
abortSignal
);
}
protected applyBrowseFetchResult(
result: AgentMentionBrowseFetchResult
): void {
this.rawGroups = cloneAgentMentionRawGroups(result.rawGroups);
this.issueTopicGroups = cloneAgentMentionIssueTopicGroups(
result.issueTopicGroups
);
this.resetTotalCounts();
for (const [groupId, count] of Object.entries(result.totalCounts) as [
AgentMentionGroupId,
number
][]) {
this.totalCounts[groupId] = count;
}
}
protected logBrowseCacheState(input: {
cacheKey: string;
cached: { entry: AgentMentionBrowseCacheEntry | null; isFresh: boolean };
filter: AgentMentionFilterId;
reason: AgentMentionBrowseLoadReason;
workspaceId: string;
}): void {
this.logLifecycle("browse.cache", {
ageMs: input.cached.entry
? elapsedDiagnosticMs(this.diagnosticNow(), input.cached.entry.cachedAt)
: null,
cacheKeyLength: input.cacheKey.length,
cacheState: input.cached.entry
? input.cached.isFresh
? "fresh"
: "stale"
: "miss",
filter: input.filter,
itemCount: input.cached.entry
? rawGroupItemCount(input.cached.entry.rawGroups)
: 0,
reason: input.reason,
workspaceId: input.workspaceId
});
}
protected logLifecycle(
event: AgentMentionLifecycleDiagnosticLog["event"],
details: Record<string, RuntimeDiagnosticsDetailValue>
): void {
logAgentMentionLifecycleDiagnostic({ event, details });
}
protected providerIdsForDiagnostics(): string {
return [...this.contextMentionProviders.keys()].sort().join(",");
}
protected async queryProviderMentionItemsById(input: {
diagnostics: AgentMentionProviderQueryDiagnostic[];
providerId: string;
workspaceId: string;
currentUserId: string;
query: string;
limit?: number;
sessionCwd?: string;
sectionKey?: string;
provenanceFilter: ReferenceProvenanceFilter | null;
abortSignal?: AbortSignal;
}): Promise<AgentContextMentionItem[]> {
const provider = this.contextMentionProviders.get(input.providerId);
return queryAgentMentionProviderWithDiagnostics({
diagnosticNow: this.diagnosticNow,
abortSignal: input.abortSignal,
diagnostics: input.diagnostics,
fallback: [] as AgentContextMentionItem[],
providerId: input.providerId,
providerTimeoutMs: this.providerTimeoutMs,
query: provider
? (abortSignal) =>
queryAgentMentionProviderItems({
provider,
workspaceId: input.workspaceId,
currentUserId: input.currentUserId,
query: input.query,
limit: input.limit,
sectionKey: input.sectionKey ?? this.currentSectionKey,
sessionCwd: input.sessionCwd ?? this.currentSessionCwd,
abortSignal,
provenanceFilter: input.provenanceFilter
})
: null,
resultCount: (result) => result.length
});
}
protected async queryProviderMentionGroupsById(input: {
diagnostics: AgentMentionProviderQueryDiagnostic[];
providerId: string;
workspaceId: string;
currentUserId: string;
query: string;
limit?: number;
sessionCwd?: string;
sectionKey?: string;
provenanceFilter: ReferenceProvenanceFilter | null;
abortSignal?: AbortSignal;
}): Promise<AgentMentionIssueTopicGroup[] | null> {
const provider = this.contextMentionProviders.get(input.providerId);
if (!provider?.queryGroups) {
return null;
}
return queryAgentMentionProviderWithDiagnostics({
abortSignal: input.abortSignal,
diagnosticNow: this.diagnosticNow,
diagnostics: input.diagnostics,
fallback: [] as AgentMentionIssueTopicGroup[],
providerId: input.providerId,
providerTimeoutMs: this.providerTimeoutMs,
throwOnTimeout: true,
query: (abortSignal) =>
queryAgentMentionProviderGroups({
provider,
workspaceId: input.workspaceId,
currentUserId: input.currentUserId,
query: input.query,
limit: input.limit,
sectionKey: input.sectionKey ?? this.currentSectionKey,
sessionCwd: input.sessionCwd ?? this.currentSessionCwd,
abortSignal,
provenanceFilter: input.provenanceFilter
}).then((groups) => groups ?? []),
resultCount: (groups) =>
groups.reduce((count, group) => count + group.items.length, 0)
});
}
protected logSearchDiagnostic(input: {
error?: unknown;
filter: AgentMentionFilterId;
groups: readonly AgentMentionGroup[];
mode: "browse" | "results";
providerDiagnostics: readonly AgentMentionProviderQueryDiagnostic[];
query: string;
requestId: number;
startedAt: number;
status: "ready" | "error";
workspaceId: string;
}): void {
emitAgentMentionSearchDiagnostic({
debounceMs: this.debounceMs,
diagnosticInfoLogger: this.diagnosticInfoLogger,
diagnosticNow: this.diagnosticNow,
diagnosticSlowThresholdMs: this.diagnosticSlowThresholdMs,
error: input.error,
filter: input.filter,
groups: input.groups,
mode: input.mode,
providerDiagnostics: input.providerDiagnostics,
providerTimeoutMs: this.providerTimeoutMs,
query: input.query,
requestId: input.requestId,
startedAt: input.startedAt,
status: input.status,
workspaceId: input.workspaceId
});
}
protected emitBrowseState(status: "ready" | "loading"): void {
this.setState({
status,
query: "",
mode: "browse",
filter: this.currentFilter,
categories: buildBrowseCategories(this.hiddenFilterIds),
groups: [],
error: null
});
}
protected canApply(
requestId: number,
workspaceId: string,
query: string,
filter: AgentMentionFilterId
): boolean {
return (
!this.disposed &&
requestId === this.requestId &&
workspaceId === this.activeWorkspaceId &&
query === this.currentQuery &&
filter === this.currentFilter
);
}
protected clearTimer(): void {
if (this.timer !== null) {
this.timer.cancel();
this.timer = null;
}
}
protected resetExpandedCounts(): void {
for (const groupId of Object.keys(this.expandedCounts)) {
delete this.expandedCounts[groupId as AgentMentionGroupId];
}
for (const groupId of [
"files",
"opened_files",
"agent_generated_files",
"my_sessions",
"collab_sessions",
"agents",
"apps",
"issues"
] as const) {
this.expandedCounts[groupId] = mentionGroupPageSize(
this.currentFilter,
groupId
);
}
}
protected resetSearchLimits(): void {
this.currentFileSearchLimit = this.fileLimit;
this.currentIssueSearchLimit = this.issueLimit;
}
protected resetRawGroups(): void {
this.rawGroups = emptyAgentMentionRawGroups();
this.issueTopicGroups = null;
this.resetTotalCounts();
}
protected resetAgentGeneratedBrowsePath(): void {
this.agentGeneratedBrowsePath = null;
}
protected resetWorkspaceFileBrowsePaths(): void {
this.workspaceFileBrowsePaths = [];
}
protected resetTotalCounts(): void {
for (const groupId of [
"files",
"opened_files",
"agent_generated_files",
"my_sessions",
"collab_sessions",
"agents",
"apps",
"issues"
] as const) {
delete this.totalCounts[groupId];
}
}
protected setState(state: AgentMentionSearchState): void {
this.state = state;
for (const listener of this.listeners) {
listener(state);
}
}
protected groupsFromRawGroups(): AgentMentionGroup[] {
return buildAgentMentionGroups({
agentGeneratedBrowsePath: this.agentGeneratedBrowsePath,
currentFileSearchLimit: this.currentFileSearchLimit,
currentFilter: this.currentFilter,
currentQuery: this.currentQuery,
expandedCounts: this.expandedCounts,
rawGroups: this.rawGroups,
totalCounts: this.totalCounts,
issueTopicGroups: this.issueTopicGroups,
provenanceCatalog: this.currentProvenanceCatalog,
provenanceFilter: this.currentProvenanceFilter
});
}
protected browseCacheKey(
input: {
workspaceId: string;
currentUserId: string;
filter: AgentMentionFilterId;
sectionKey: string;
sessionCwd: string;
},
provenanceFilter: ReferenceProvenanceFilter | null = this
.currentProvenanceFilter
): string {
return buildAgentMentionBrowseCacheKey({
...input,
fileLimit: this.fileLimit,
issueLimit: this.currentIssueSearchLimit,
providerIds: [...this.contextMentionProviders.keys()].sort(),
provenanceFilterKey: provenanceFilter
? referenceProvenanceFilterCacheKey(provenanceFilter)
: "disabled"
});
}
protected readBrowseCache(cacheKey: string) {
return readAgentMentionBrowseCache({
cacheKey,
browseCacheTtlMs: this.browseCacheTtlMs,
diagnosticNow: this.diagnosticNow
});
}
protected loadBrowseFetchResult(
input: {
workspaceId: string;
currentUserId: string;
filter: AgentMentionFilterId;
sectionKey: string;
sessionCwd: string;
},
cacheKey: string,
reason: AgentMentionBrowseLoadReason,
provenanceFilter: ReferenceProvenanceFilter | null = this
.currentProvenanceFilter,
abortSignal?: AbortSignal
): Promise<AgentMentionBrowseFetchResult> {
return loadAgentMentionBrowseFetchResult({
input,
cacheKey,
reason,
abortSignal,
diagnosticNow: this.diagnosticNow,
providerIds: this.providerIdsForDiagnostics(),
fetchBrowseResult: (sharedAbortSignal) =>
this.fetchBrowseResult(input, provenanceFilter, sharedAbortSignal),
logLifecycle: (event, details) => this.logLifecycle(event, details)
});
}
protected fetchFilterResult(
input: {
workspaceId: string;
currentUserId: string;
query: string;
filter: AgentMentionFilterId;
sectionKey: string;
sessionCwd: string;
includeAgentGeneratedFiles: boolean;
},
provenanceFilter: ReferenceProvenanceFilter | null = this
.currentProvenanceFilter,
abortSignal?: AbortSignal
): Promise<AgentMentionBrowseFetchResult> {
return fetchAgentMentionFilterResult({
...input,
fileLimit: this.fileLimit,
currentFileSearchLimit: this.currentFileSearchLimit,
currentIssueSearchLimit: this.currentIssueSearchLimit,
provenanceCatalog: this.currentProvenanceCatalog,
provenanceFilter,
queryProviderMentionGroupsById: (queryInput) =>
this.queryProviderMentionGroupsById({ ...queryInput, abortSignal }),
queryProviderMentionItemsById: (queryInput) =>
this.queryProviderMentionItemsById({ ...queryInput, abortSignal })
});
}
protected beginActiveRequest(): AbortSignal {
this.abortActiveRequest();
const controller = new AbortController();
this.activeRequestAbortController = controller;
return controller.signal;
}
protected abortActiveRequest(): void {
this.activeRequestAbortController?.abort();
this.activeRequestAbortController = null;
}
}