diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index ab7c95ae..9ea1234f 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -14,6 +14,7 @@ import { FileService } from "../services/file-service"; import { MigrationService } from "../services/migration-service"; import { OnboardingService } from "../services/onboarding-service"; import { ComplianceService } from "../services/compliance-service"; +import { SearchService } from "../services/search-service"; export interface IpcDeps { connection: DatabaseConnection; @@ -32,6 +33,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { const files = new FileService(deps.connection.db, deps.artifactRoot); const migrations = new MigrationService(deps.connection.db); const compliance = new ComplianceService(deps.connection.db); + const search = new SearchService(deps.connection.db); const onboarding = new OnboardingService({ db: deps.connection.db, saveProviderCredential: (input) => providers.saveCredential(input), @@ -151,6 +153,13 @@ export function registerIpcHandlers(deps: IpcDeps): void { ipcMain.handle(IPC_CHANNELS.setLegalHoldState, (_event, input) => mutate(() => compliance.setLegalHoldState(input))); ipcMain.handle(IPC_CHANNELS.createRetentionFreeze, (_event, input) => mutate(() => compliance.createRetentionFreeze(input))); ipcMain.handle(IPC_CHANNELS.setRetentionFreezeState, (_event, input) => mutate(() => compliance.setRetentionFreezeState(input))); + ipcMain.handle(IPC_CHANNELS.search, (_event, input) => { + try { + return ok(search.search(input)); + } catch (error) { + return fail("search_failed", error instanceof Error ? error.message : String(error)); + } + }); ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize()); ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize()); diff --git a/AgentCompany-New/src/main/database/migrations/013-search-documents.ts b/AgentCompany-New/src/main/database/migrations/013-search-documents.ts new file mode 100644 index 00000000..1e77c764 --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/013-search-documents.ts @@ -0,0 +1,13 @@ +export const searchDocumentsSchema = ` + create virtual table if not exists search_documents using fts5( + org_id unindexed, + entity_type unindexed, + entity_id unindexed, + title, + body, + facet_json unindexed + ); + + insert into schema_migrations(version, name, applied_at) + values (13, 'search_documents', datetime('now')); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index 91948028..a614d138 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -11,6 +11,7 @@ import { socialGovernanceSchema } from "./migrations/009-social-governance"; import { fileHandoffSecuritySchema } from "./migrations/010-file-handoff-security"; import { boardInterventionSchema } from "./migrations/011-board-interventions"; import { complianceControlsSchema } from "./migrations/012-compliance-controls"; +import { searchDocumentsSchema } from "./migrations/013-search-documents"; interface Migration { version: number; @@ -31,6 +32,7 @@ const MIGRATIONS: Migration[] = [ { version: 10, name: "file_handoff_security", sql: fileHandoffSecuritySchema }, { version: 11, name: "board_interventions", sql: boardInterventionSchema }, { version: 12, name: "compliance_controls", sql: complianceControlsSchema }, + { version: 13, name: "search_documents", sql: searchDocumentsSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/migration-service.ts b/AgentCompany-New/src/main/services/migration-service.ts index 93f86a59..ea62bc09 100644 --- a/AgentCompany-New/src/main/services/migration-service.ts +++ b/AgentCompany-New/src/main/services/migration-service.ts @@ -47,6 +47,7 @@ const LEGACY_TABLES = [ "compliance_exports", "legal_holds", "retention_freezes", + "search_documents", "audit_events", ]; diff --git a/AgentCompany-New/src/main/services/search-service.ts b/AgentCompany-New/src/main/services/search-service.ts new file mode 100644 index 00000000..64f7c414 --- /dev/null +++ b/AgentCompany-New/src/main/services/search-service.ts @@ -0,0 +1,238 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { SearchInput } from "../../shared/commands"; +import { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } from "../../shared/domain"; + +interface SearchRow { + entity_type: SearchEntityType; + entity_id: string; + title: string; + body: string; + facet_json: string; +} + +interface SearchDoc { + entityType: SearchEntityType; + entityId: string; + title: string; + body: string; + facets: Record; +} + +export class SearchService { + constructor(private readonly db: DatabaseSync) {} + + search(input: SearchInput): SearchResultRecord[] { + this.assertEmployee(input.orgId, input.employeeId); + const query = normalizeQuery(input.query); + const limit = Math.max(1, Math.min(input.limit ?? 20, 50)); + const entityTypes = validEntityTypes(input.entityTypes ?? [...SEARCH_ENTITY_TYPES]); + this.reindexOrg(input.orgId); + const rows = query + ? this.db.prepare(` + select entity_type, entity_id, title, body, facet_json + from search_documents + where search_documents match ? and org_id = ? + limit ? + `).all(query, input.orgId, limit * 4) as unknown as SearchRow[] + : this.db.prepare(` + select entity_type, entity_id, title, body, facet_json + from search_documents + where org_id = ? + order by rowid desc + limit ? + `).all(input.orgId, limit * 4) as unknown as SearchRow[]; + return rows + .filter((row) => entityTypes.has(row.entity_type)) + .filter((row) => this.canRead(input.orgId, input.employeeId, row.entity_type, row.entity_id)) + .slice(0, limit) + .map((row) => ({ + entityType: row.entity_type, + entityId: row.entity_id, + title: row.title, + preview: preview(row.body), + facets: parseFacets(row.facet_json), + })); + } + + private reindexOrg(orgId: string): void { + this.db.exec("savepoint search_reindex"); + try { + this.db.prepare("delete from search_documents where org_id = ?").run(orgId); + const insert = this.db.prepare(` + insert into search_documents(org_id, entity_type, entity_id, title, body, facet_json) + values (?, ?, ?, ?, ?, ?) + `); + for (const doc of this.documents(orgId)) { + insert.run(orgId, doc.entityType, doc.entityId, doc.title, doc.body, JSON.stringify(doc.facets)); + } + this.db.exec("release search_reindex"); + } catch (error) { + this.db.exec("rollback to search_reindex"); + this.db.exec("release search_reindex"); + throw error; + } + } + + private documents(orgId: string): SearchDoc[] { + return [ + ...this.employeeDocs(orgId), + ...this.agentDocs(orgId), + ...this.taskDocs(orgId), + ...this.projectDocs(orgId), + ...this.goalDocs(orgId), + ...this.workspaceDocs(orgId), + ...this.approvalDocs(orgId), + ...this.chatSpaceDocs(orgId), + ...this.chatMessageDocs(orgId), + ...this.fileDocs(orgId), + ...this.providerDocs(orgId), + ...this.socialDocs(orgId), + ...this.scheduledPostDocs(orgId), + ]; + } + + private employeeDocs(orgId: string): SearchDoc[] { + const rows = this.db.prepare("select id, display_name, role from employees where org_id = ?").all(orgId) as Array<{ id: string; display_name: string; role: string }>; + return rows.map((row) => doc("employee", row.id, row.display_name, row.role, { type: "People" })); + } + + private agentDocs(orgId: string): SearchDoc[] { + const rows = this.db.prepare("select id, name, role, department, title from agents where org_id = ?").all(orgId) as Array<{ id: string; name: string; role: string; department: string; title: string }>; + return rows.map((row) => doc("agent", row.id, row.name, [row.role, row.department, row.title].join(" "), { type: "People", department: row.department })); + } + + private taskDocs(orgId: string): SearchDoc[] { + const rows = this.db.prepare("select id, title, description, status, risk from tasks where org_id = ?").all(orgId) as Array<{ id: string; title: string; description: string; status: string; risk: string }>; + return rows.map((row) => doc("task", row.id, row.title, row.description, { type: "Work", status: row.status, risk: row.risk })); + } + + private projectDocs(orgId: string): SearchDoc[] { + const rows = this.db.prepare("select id, name, description, status from projects where org_id = ?").all(orgId) as Array<{ id: string; name: string; description: string; status: string }>; + return rows.map((row) => doc("project", row.id, row.name, row.description, { type: "Work", status: row.status })); + } + + private goalDocs(orgId: string): SearchDoc[] { + const rows = this.db.prepare("select id, title, description, status from goals where org_id = ?").all(orgId) as Array<{ id: string; title: string; description: string; status: string }>; + return rows.map((row) => doc("goal", row.id, row.title, row.description, { type: "Work", status: row.status })); + } + + private workspaceDocs(orgId: string): SearchDoc[] { + const rows = this.db.prepare("select id, name, local_path, repo_url from workspaces where org_id = ?").all(orgId) as Array<{ id: string; name: string; local_path: string; repo_url: string }>; + return rows.map((row) => doc("workspace", row.id, row.name, `${row.local_path} ${row.repo_url}`, { type: "Work" })); + } + + private approvalDocs(orgId: string): SearchDoc[] { + const rows = this.db.prepare("select id, title, details, state, risk from approvals where org_id = ?").all(orgId) as Array<{ id: string; title: string; details: string; state: string; risk: string }>; + return rows.map((row) => doc("approval", row.id, row.title, row.details, { type: "Governance", state: row.state, risk: row.risk })); + } + + private chatSpaceDocs(orgId: string): SearchDoc[] { + const rows = this.db.prepare("select id, name, topic, visibility from chat_spaces where org_id = ?").all(orgId) as Array<{ id: string; name: string; topic: string; visibility: string }>; + return rows.map((row) => doc("chat_space", row.id, row.name, row.topic, { type: "Messages", visibility: row.visibility })); + } + + private chatMessageDocs(orgId: string): SearchDoc[] { + const rows = this.db.prepare("select id, thread_id, body_md, sender_type from chat_messages where org_id = ? and deleted_at is null").all(orgId) as Array<{ id: string; thread_id: string; body_md: string; sender_type: string }>; + return rows.map((row) => doc("chat_message", row.id, createTitle(row.body_md), row.body_md, { type: "Messages", sender: row.sender_type, thread: row.thread_id })); + } + + private fileDocs(orgId: string): SearchDoc[] { + const rows = this.db.prepare("select id, name, kind, sha256, scan_state from file_objects where org_id = ?").all(orgId) as Array<{ id: string; name: string; kind: string; sha256: string; scan_state: string }>; + return rows.map((row) => doc("file", row.id, row.name, `${row.kind} ${row.sha256}`, { type: "Files", state: row.scan_state })); + } + + private providerDocs(orgId: string): SearchDoc[] { + const rows = this.db.prepare("select id, label, provider_key, auth_state, health_state from providers where org_id = ?").all(orgId) as Array<{ id: string; label: string; provider_key: string; auth_state: string; health_state: string }>; + return rows.map((row) => doc("provider", row.id, row.label, row.provider_key, { type: "Settings", auth: row.auth_state, health: row.health_state })); + } + + private socialDocs(orgId: string): SearchDoc[] { + const rows = this.db.prepare("select id, platform, handle, display_name from social_accounts where org_id = ?").all(orgId) as Array<{ id: string; platform: string; handle: string; display_name: string }>; + return rows.map((row) => doc("social_account", row.id, row.display_name, `${row.platform} ${row.handle}`, { type: "Social", platform: row.platform })); + } + + private scheduledPostDocs(orgId: string): SearchDoc[] { + const rows = this.db.prepare("select id, social_account_id, body_md, state from scheduled_posts where org_id = ?").all(orgId) as Array<{ id: string; social_account_id: string; body_md: string; state: string }>; + return rows.map((row) => doc("scheduled_post", row.id, createTitle(row.body_md), row.body_md, { type: "Social", state: row.state, account: row.social_account_id })); + } + + private canRead(orgId: string, employeeId: string, entityType: SearchEntityType, entityId: string): boolean { + if (entityType === "chat_space") return this.canReadSpace(entityId, employeeId); + if (entityType === "chat_message") return this.canReadMessage(orgId, entityId, employeeId); + if (entityType === "file") return this.canReadFile(orgId, entityId, employeeId); + if (entityType === "social_account") return this.canReadSocialAccount(entityId, employeeId); + if (entityType === "scheduled_post") return this.canReadScheduledPost(entityId, employeeId); + return true; + } + + private canReadSpace(spaceId: string, employeeId: string): boolean { + const row = this.db.prepare("select space_id from chat_memberships where space_id = ? and principal_type = 'employee' and principal_id = ?") + .get(spaceId, employeeId); + return Boolean(row); + } + + private canReadMessage(orgId: string, messageId: string, employeeId: string): boolean { + const row = this.db.prepare("select space_id from chat_messages where id = ? and org_id = ?").get(messageId, orgId) as { space_id: string } | undefined; + return row ? this.canReadSpace(row.space_id, employeeId) : false; + } + + private canReadFile(orgId: string, fileId: string, employeeId: string): boolean { + const file = this.db.prepare("select owner_type, owner_id from file_objects where id = ? and org_id = ?").get(fileId, orgId) as { owner_type: string; owner_id: string | null } | undefined; + if (!file) return false; + if (file.owner_type === "employee" && file.owner_id === employeeId) return true; + const workspaceGroupId = `${orgId}:workspace`; + const grant = this.db.prepare(` + select id from file_access_grants + where org_id = ? and file_id = ? and access_level in ('read', 'manage') + and ((granted_to_type = 'employee' and granted_to_id = ?) or (granted_to_type = 'org_group' and granted_to_id = ?)) + limit 1 + `).get(orgId, fileId, employeeId, workspaceGroupId); + return Boolean(grant); + } + + private canReadSocialAccount(accountId: string, employeeId: string): boolean { + const grant = this.db.prepare("select id from social_account_grants where social_account_id = ? and employee_id = ? limit 1").get(accountId, employeeId); + return Boolean(grant); + } + + private canReadScheduledPost(postId: string, employeeId: string): boolean { + const row = this.db.prepare("select social_account_id from scheduled_posts where id = ?").get(postId) as { social_account_id: string } | undefined; + return row ? this.canReadSocialAccount(row.social_account_id, employeeId) : false; + } + + private assertEmployee(orgId: string, employeeId: string): void { + const row = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId); + if (!row) throw new Error("Search employee was not found."); + } +} + +function doc(entityType: SearchEntityType, entityId: string, title: string, body: string, facets: Record): SearchDoc { + return { entityType, entityId, title: title.trim(), body: body.trim(), facets }; +} + +function normalizeQuery(query: string): string { + const terms = query.toLowerCase().match(/[a-z0-9_@.-]+/g) ?? []; + return terms.map((term) => `${term.replaceAll('"', "")}*`).join(" AND "); +} + +function validEntityTypes(entityTypes: SearchEntityType[]): Set { + const allowed = new Set(SEARCH_ENTITY_TYPES); + return new Set(entityTypes.filter((type) => allowed.has(type))); +} + +function createTitle(value: string): string { + const firstLine = value.split(/\r?\n/, 1)[0].trim(); + if (!firstLine) return "Untitled"; + return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine; +} + +function preview(value: string): string { + const text = value.replace(/\s+/g, " ").trim(); + return text.length > 140 ? `${text.slice(0, 137)}...` : text; +} + +function parseFacets(value: string): Record { + const parsed = JSON.parse(value) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Search facet payload is invalid."); + return Object.fromEntries(Object.entries(parsed).map(([key, item]) => [key, String(item)])); +} diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index f44c528b..da7aadd6 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -40,6 +40,7 @@ const api: DesktopApi = { setLegalHoldState: (input) => invoke(IPC_CHANNELS.setLegalHoldState, input), createRetentionFreeze: (input) => invoke(IPC_CHANNELS.createRetentionFreeze, input), setRetentionFreezeState: (input) => invoke(IPC_CHANNELS.setRetentionFreezeState, input), + search: (input) => invoke(IPC_CHANNELS.search, input), subscribe: (callback) => { const listener = (_event: Electron.IpcRendererEvent, payload: RendererEvent) => callback(payload); ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener); diff --git a/AgentCompany-New/src/renderer/src/shared/SearchDialog.tsx b/AgentCompany-New/src/renderer/src/shared/SearchDialog.tsx new file mode 100644 index 00000000..5e6b23a2 --- /dev/null +++ b/AgentCompany-New/src/renderer/src/shared/SearchDialog.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState, type CSSProperties, type FormEvent } from "react"; +import type { AppSnapshot, SearchEntityType, SearchResultRecord } from "../../../shared/domain"; +import { SEARCH_ENTITY_TYPES } from "../../../shared/domain"; +import type { RouteId } from "./Shell"; +import { Button, Icon, Pill } from "./ui"; + +type SearchFilter = "all" | SearchEntityType; + +const LABELS: Record = { + employee: "Employee", + agent: "Agent", + task: "Task", + goal: "Goal", + project: "Project", + workspace: "Workspace", + approval: "Approval", + chat_space: "Space", + chat_message: "Message", + file: "File", + provider: "Provider", + social_account: "Social", + scheduled_post: "Post", +}; + +export function SearchDialog({ + snapshot, + open, + onClose, + onNavigate, +}: { + snapshot: AppSnapshot; + open: boolean; + onClose: () => void; + onNavigate: (route: RouteId) => void; +}) { + const [query, setQuery] = useState(""); + const [filter, setFilter] = useState("all"); + const [results, setResults] = useState([]); + const orgId = snapshot.activeOrgId; + const employee = useMemo(() => snapshot.employees.find((item) => item.orgId === orgId), [snapshot.employees, orgId]); + const canSearch = Boolean(orgId && employee && query.trim().length >= 2); + const visibleResults = canSearch ? results : []; + + useEffect(() => { + if (!open) return; + const trimmed = query.trim(); + if (!orgId || !employee || trimmed.length < 2) return; + const timeout = window.setTimeout(() => { + void window.agentCompany.search({ + orgId, + employeeId: employee.id, + query: trimmed, + entityTypes: filter === "all" ? undefined : [filter], + limit: 12, + }).then((result) => { + if (!result.ok) throw new Error(result.error.message); + setResults(result.data); + }); + }, 120); + return () => window.clearTimeout(timeout); + }, [employee, filter, open, orgId, query]); + + useEffect(() => { + if (!open) return; + const handler = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose, open]); + + if (!open) return null; + + const submit = (event: FormEvent) => { + event.preventDefault(); + const first = visibleResults[0]; + if (!first) return; + onNavigate(routeFor(first.entityType)); + onClose(); + }; + + return ( +
+
+
+ + setQuery(event.target.value)} + style={inputStyle} + /> + + +
+
+ {visibleResults.map((result) => ( + + ))} +
+
+
+ ); +} + +function routeFor(entityType: SearchEntityType): RouteId { + if (entityType === "employee" || entityType === "agent") return "team"; + if (entityType === "approval") return "approvals"; + if (entityType === "chat_space" || entityType === "chat_message") return "chat"; + if (entityType === "file") return "files"; + if (entityType === "provider") return "settings"; + if (entityType === "social_account" || entityType === "scheduled_post") return "social"; + return "work"; +} + +const overlayStyle = { + position: "fixed", + inset: 0, + zIndex: 80, + background: "rgba(9, 9, 11, 0.16)", + display: "grid", + placeItems: "start center", + paddingTop: "12vh", +} satisfies CSSProperties; + +const panelStyle = { + width: "min(680px, calc(100vw - 32px))", + border: "1px solid var(--border)", + borderRadius: 12, + background: "var(--panel)", + boxShadow: "var(--shadow-md)", + overflow: "hidden", +} satisfies CSSProperties; + +const inputStyle = { + flex: 1, + minWidth: 0, + border: 0, + outline: "none", + background: "transparent", + fontSize: 14, +} satisfies CSSProperties; + +const rowStyle = { + width: "100%", + border: 0, + borderRadius: 8, + background: "transparent", + display: "flex", + alignItems: "center", + gap: 10, + padding: "9px 10px", + textAlign: "left", +} satisfies CSSProperties; diff --git a/AgentCompany-New/src/renderer/src/shared/Shell.tsx b/AgentCompany-New/src/renderer/src/shared/Shell.tsx index 9e5cb25b..95aaf037 100644 --- a/AgentCompany-New/src/renderer/src/shared/Shell.tsx +++ b/AgentCompany-New/src/renderer/src/shared/Shell.tsx @@ -1,8 +1,9 @@ -import type { ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import type { AppSnapshot } from "../../../shared/domain"; import { Avatar, Icon, IconButton } from "./ui"; import type { IconName } from "./Icon"; import { OrgSwitcher } from "./OrgSwitcher"; +import { SearchDialog } from "./SearchDialog"; export type RouteId = | "home" @@ -49,6 +50,18 @@ export function Shell({ onNewOrg: () => void; children: ReactNode; }) { + const [searchOpen, setSearchOpen] = useState(false); + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + setSearchOpen(true); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + const organization = snapshot.organizations.find((item) => item.id === snapshot.activeOrgId); const employee = snapshot.employees.find((item) => item.orgId === snapshot.activeOrgId); if (!organization) throw new Error("Active organization is missing."); @@ -66,7 +79,7 @@ export function Shell({ onNew={onNewOrg} onDelete={onDeleteOrg} /> -