diff --git a/.changeset/wacky-teeth-kneel.md b/.changeset/wacky-teeth-kneel.md new file mode 100644 index 0000000000..f0985e89dc --- /dev/null +++ b/.changeset/wacky-teeth-kneel.md @@ -0,0 +1,7 @@ +--- +'@xpert-ai/plugin-sdk': patch +'@xpert-ai/contracts': patch +'@xpert-ai/xpert-ui': patch +--- + +Publish a browser-safe collaboration client entry at `@xpert-ai/plugin-sdk/collaboration-client`. diff --git a/apps/cloud/src/app/@shared/view-extension/renderers/remote-component-renderer.component.spec.ts b/apps/cloud/src/app/@shared/view-extension/renderers/remote-component-renderer.component.spec.ts index 616dc1ffa1..71f4f381bc 100644 --- a/apps/cloud/src/app/@shared/view-extension/renderers/remote-component-renderer.component.spec.ts +++ b/apps/cloud/src/app/@shared/view-extension/renderers/remote-component-renderer.component.spec.ts @@ -17,7 +17,10 @@ import { TestBed } from '@angular/core/testing' import { of, Subject } from 'rxjs' import { ToastrService, ViewExtensionApiService } from '@cloud/app/@core' import { environment } from '@cloud/environments/environment' -import { XpertExtensionViewManifest } from '@xpert-ai/contracts' +import { + XPERT_REMOTE_COMPONENT_INVOKE_CLIENT_COMMAND_MESSAGE_TYPE, + XpertExtensionViewManifest +} from '@xpert-ai/contracts' import { RemoteComponentRendererComponent } from './remote-component-renderer.component' import { ViewClientCommandRegistry } from '../view-client-command-registry.service' import { ViewHostEventBus } from '../view-host-event-bus.service' @@ -418,7 +421,7 @@ describe('RemoteComponentRendererComponent', () => { channel: 'xpertai.remote_component', protocolVersion: 1, instanceId: component.instanceId(), - type: 'invokeClientCommand', + type: XPERT_REMOTE_COMPONENT_INVOKE_CLIENT_COMMAND_MESSAGE_TYPE, requestId: 'request-1', commandKey: 'assistant.chat.send_message', payload: { diff --git a/apps/cloud/src/app/@shared/view-extension/renderers/remote-component-renderer.component.ts b/apps/cloud/src/app/@shared/view-extension/renderers/remote-component-renderer.component.ts index 7f69377149..147de959ad 100644 --- a/apps/cloud/src/app/@shared/view-extension/renderers/remote-component-renderer.component.ts +++ b/apps/cloud/src/app/@shared/view-extension/renderers/remote-component-renderer.component.ts @@ -13,6 +13,7 @@ import { } from '@angular/core' import { firstValueFrom } from 'rxjs' import { + XPERT_REMOTE_COMPONENT_INVOKE_CLIENT_COMMAND_MESSAGE_TYPE, XpertExtensionViewManifest, XpertViewActionDefinition, XpertViewHostEventSubscription, @@ -253,7 +254,7 @@ export class RemoteComponentRendererComponent { case 'executeFileAction': void this.handleRequest(message, 'fileActionResult', () => this.handleFileActionRequest(message)) return - case 'invokeClientCommand': + case XPERT_REMOTE_COMPONENT_INVOKE_CLIENT_COMMAND_MESSAGE_TYPE: void this.handleRequest(message, 'clientCommandResult', () => this.handleClientCommandRequest(message)) return default: diff --git a/apps/cloud/src/app/features/assistant/pdfjs-dist.d.ts b/apps/cloud/src/app/features/assistant/pdfjs-dist.d.ts new file mode 100644 index 0000000000..9df72deb12 --- /dev/null +++ b/apps/cloud/src/app/features/assistant/pdfjs-dist.d.ts @@ -0,0 +1 @@ +declare module 'pdfjs-dist/build/pdf.worker.entry' diff --git a/apps/cloud/src/app/features/assistant/workbench-file-open-client-command.ts b/apps/cloud/src/app/features/assistant/workbench-file-open-client-command.ts index f5b970eae0..a1bda92dcb 100644 --- a/apps/cloud/src/app/features/assistant/workbench-file-open-client-command.ts +++ b/apps/cloud/src/app/features/assistant/workbench-file-open-client-command.ts @@ -12,6 +12,29 @@ export type WorkbenchOpenFile = { size?: number url: string previewUrl?: string + evidence?: WorkbenchOpenFileEvidence +} + +export type WorkbenchOpenFileEvidenceBox = { + x: number + y: number + width: number + height: number +} + +export type WorkbenchOpenFileEvidence = { + observationId?: string + attributeCode?: string + displayValue?: string + text?: string + method?: string + region?: string + confidence?: number + locator?: { + sourceType?: string + page?: number + box?: WorkbenchOpenFileEvidenceBox + } } type WorkbenchFileOpenCommandOptions = { @@ -56,6 +79,7 @@ function toWorkbenchOpenFile(payload: unknown): WorkbenchOpenFile | null { return null } + const evidence = toWorkbenchOpenFileEvidence(payload.evidence) return { id: getString(payload.id) ?? getString(payload.fileAssetId) ?? getString(payload.fileId), fileId: getString(payload.fileId) ?? getString(payload.fileAssetId), @@ -65,8 +89,62 @@ function toWorkbenchOpenFile(payload: unknown): WorkbenchOpenFile | null { mimeType: getString(payload.mimeType) ?? getString(payload.mimetype), size: typeof payload.size === 'number' && Number.isFinite(payload.size) ? payload.size : undefined, url, - previewUrl: getString(payload.previewUrl) ?? url + previewUrl: getString(payload.previewUrl) ?? url, + ...(evidence ? { evidence } : {}) + } +} + +function toWorkbenchOpenFileEvidence(value: unknown): WorkbenchOpenFileEvidence | undefined { + if (!isRecord(value)) { + return undefined + } + + const locator = toWorkbenchOpenFileEvidenceLocator(value.locator) + const confidence = getFiniteNumber(value.confidence) + const evidence: WorkbenchOpenFileEvidence = { + ...(getString(value.observationId) ? { observationId: getString(value.observationId) } : {}), + ...(getString(value.attributeCode) ? { attributeCode: getString(value.attributeCode) } : {}), + ...(getString(value.displayValue) ? { displayValue: getString(value.displayValue) } : {}), + ...(getString(value.text) ? { text: getString(value.text) } : {}), + ...(getString(value.method) ? { method: getString(value.method) } : {}), + ...(getString(value.region) ? { region: getString(value.region) } : {}), + ...(confidence !== undefined ? { confidence } : {}), + ...(locator ? { locator } : {}) } + + return Object.values(evidence).some((item) => item !== undefined) ? evidence : undefined +} + +function toWorkbenchOpenFileEvidenceLocator(value: unknown): WorkbenchOpenFileEvidence['locator'] | undefined { + if (!isRecord(value)) { + return undefined + } + + const page = getPositiveInteger(value.page) + const box = toWorkbenchOpenFileEvidenceBox(value.box) + const locator: NonNullable = { + ...(getString(value.sourceType) ? { sourceType: getString(value.sourceType) } : {}), + ...(page !== undefined ? { page } : {}), + ...(box ? { box } : {}) + } + + return Object.values(locator).some((item) => item !== undefined) ? locator : undefined +} + +function toWorkbenchOpenFileEvidenceBox(value: unknown): WorkbenchOpenFileEvidenceBox | undefined { + if (!isRecord(value)) { + return undefined + } + + const x = getFiniteNumber(value.x) + const y = getFiniteNumber(value.y) + const width = getFiniteNumber(value.width) + const height = getFiniteNumber(value.height) + if (x === undefined || y === undefined || width === undefined || height === undefined) { + return undefined + } + + return { x, y, width, height } } function isRecord(value: unknown): value is Record { @@ -76,3 +154,11 @@ function isRecord(value: unknown): value is Record { function getString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined } + +function getFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function getPositiveInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined +} diff --git a/apps/cloud/src/app/features/assistant/workbench-file-preview-dialog.component.ts b/apps/cloud/src/app/features/assistant/workbench-file-preview-dialog.component.ts index f5be9c3aab..12c7bb481a 100644 --- a/apps/cloud/src/app/features/assistant/workbench-file-preview-dialog.component.ts +++ b/apps/cloud/src/app/features/assistant/workbench-file-preview-dialog.component.ts @@ -1,17 +1,22 @@ import { Dialog, DIALOG_DATA, DialogRef } from '@angular/cdk/dialog' import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core' +import { TranslateModule } from '@ngx-translate/core' import { FilePreviewContentComponent } from '../../@shared/files/preview/file-preview-content.component' import { createFilePreviewState, toFilePreviewSource } from '../../@shared/files/preview/file-preview.utils' -import type { WorkbenchOpenFile } from './workbench-file-open-client-command' +import type { WorkbenchOpenFile, WorkbenchOpenFileEvidenceBox } from './workbench-file-open-client-command' +import { WorkbenchPdfEvidencePreviewComponent } from './workbench-pdf-evidence-preview.component' @Component({ standalone: true, selector: 'xp-workbench-file-preview-dialog', - imports: [FilePreviewContentComponent], + imports: [TranslateModule, FilePreviewContentComponent, WorkbenchPdfEvidencePreviewComponent], + host: { + class: 'block h-full w-full' + }, template: `
@@ -31,7 +36,11 @@ import type { WorkbenchOpenFile } from './workbench-file-open-client-command' }
-

{{ file.name || 'Source document' }}

+

+ {{ + file.name || ('PAC.Assistant.FilePreview.SourceDocument' | translate: { Default: 'Source document' }) + }} +

@if (file.mimeType) {

{{ file.mimeType }}

} @@ -42,7 +51,7 @@ import type { WorkbenchOpenFile } from './workbench-file-open-client-command'
- + @if (evidence(); as currentEvidence) { +
+
+ @if (controlledPdfEvidence()) { + + } @else { + + } +
+ + +
+ } @else { + + }
`, changeDetection: ChangeDetectionStrategy.OnPush @@ -87,7 +191,16 @@ export class WorkbenchFilePreviewDialogComponent { readonly file = inject(DIALOG_DATA) readonly #dialogRef = inject(DialogRef) - readonly previewUrl = computed(() => this.file.previewUrl || this.file.url) + readonly evidence = computed(() => this.file.evidence ?? null) + readonly evidencePage = computed(() => this.evidence()?.locator?.page) + readonly evidenceBox = computed(() => normalizeEvidenceBox(this.evidence()?.locator?.box)) + readonly evidenceSearchTerms = computed(() => { + const evidence = this.evidence() + return [evidence?.displayValue, evidence?.text].filter((value): value is string => Boolean(value?.trim())) + }) + readonly basePreviewUrl = computed(() => this.file.previewUrl || this.file.url) + readonly previewUrl = computed(() => appendPdfPageAnchor(this.basePreviewUrl(), this.evidencePage())) + readonly controlledPdfEvidence = computed(() => this.previewKind() === 'pdf' && !!this.evidenceBox()) readonly previewSource = computed(() => toFilePreviewSource({ mimeType: this.file.mimeType, @@ -128,9 +241,10 @@ export class WorkbenchFilePreviewDialogComponent { export function openWorkbenchFilePreviewDialog(dialog: Dialog, file: WorkbenchOpenFile) { return dialog.open(WorkbenchFilePreviewDialogComponent, { data: file, - width: 'min(1120px, calc(100vw - 32px))', - maxWidth: 'calc(100vw - 32px)', - maxHeight: '90vh', + width: 'min(1440px, calc(100vw - 28px))', + height: 'min(960px, calc(100vh - 28px))', + maxWidth: 'calc(100vw - 28px)', + maxHeight: 'calc(100vh - 28px)', backdropClass: 'backdrop-blur-sm-black' }) } @@ -143,3 +257,37 @@ async function loadTextPreview(url: string) { return response.text() } + +function appendPdfPageAnchor(url: string, page?: number) { + if (!page || !Number.isInteger(page) || page <= 0) { + return url + } + + const [base, hash = ''] = url.split('#', 2) + const params = new URLSearchParams(hash.replace(/^#/, '')) + params.set('page', String(page)) + return `${base}#${params.toString()}` +} + +function normalizeEvidenceBox(box?: WorkbenchOpenFileEvidenceBox) { + if (!box) { + return null + } + + const x = clamp01(box.x) + const y = clamp01(box.y) + return { + x, + y, + width: Math.min(clamp01(box.width), 1 - x), + height: Math.min(clamp01(box.height), 1 - y) + } +} + +function clamp01(value: number) { + if (!Number.isFinite(value)) { + return 0 + } + + return Math.min(1, Math.max(0, value)) +} diff --git a/apps/cloud/src/app/features/assistant/workbench-navigation-open-client-command.spec.ts b/apps/cloud/src/app/features/assistant/workbench-navigation-open-client-command.spec.ts index 4ea1983e4b..95bd642c86 100644 --- a/apps/cloud/src/app/features/assistant/workbench-navigation-open-client-command.spec.ts +++ b/apps/cloud/src/app/features/assistant/workbench-navigation-open-client-command.spec.ts @@ -2,6 +2,7 @@ import { WORKBENCH_NAVIGATION_OPEN_COMMAND } from '@xpert-ai/contracts' import { ViewClientCommandRegistry } from '../../@shared/view-extension/view-client-command-registry.service' import { registerWorkbenchNavigationOpenCommand, + WORKBENCH_ASSISTANT_CONVERSATION_TARGET, WORKBENCH_KNOWLEDGEBASE_DOCUMENTS_TARGET } from './workbench-navigation-open-client-command' @@ -60,6 +61,62 @@ describe('registerWorkbenchNavigationOpenCommand', () => { ) }) + it('opens a persisted assistant conversation', async () => { + const registry = new ViewClientCommandRegistry() + const navigate = jest.fn(async () => true) + const openAssistantConversation = jest.fn(async () => true) + registerWorkbenchNavigationOpenCommand(registry, { navigate, openAssistantConversation }) + + const result = await registry.execute( + WORKBENCH_NAVIGATION_OPEN_COMMAND, + { + target: WORKBENCH_ASSISTANT_CONVERSATION_TARGET, + conversationId: 'conversation-1', + threadId: 'thread-1', + executionId: 'execution-1' + }, + context + ) + + expect(navigate).not.toHaveBeenCalled() + expect(openAssistantConversation).toHaveBeenCalledWith({ + conversationId: 'conversation-1', + threadId: 'thread-1', + executionId: 'execution-1' + }) + expect(result).toEqual({ + success: true, + status: 'opened', + target: WORKBENCH_ASSISTANT_CONVERSATION_TARGET, + conversationId: 'conversation-1', + threadId: 'thread-1', + executionId: 'execution-1' + }) + }) + + it('does not fall back to the legacy chat route when the host has no embedded chatkit opener', async () => { + const registry = new ViewClientCommandRegistry() + const navigate = jest.fn(async () => true) + registerWorkbenchNavigationOpenCommand(registry, { navigate }) + + const result = await registry.execute( + WORKBENCH_NAVIGATION_OPEN_COMMAND, + { + target: WORKBENCH_ASSISTANT_CONVERSATION_TARGET, + conversationId: 'conversation-1', + threadId: 'thread-1' + }, + context + ) + + expect(navigate).not.toHaveBeenCalled() + expect(result).toEqual({ + success: false, + code: 'unsupported', + message: 'Assistant conversation opening is not available in this host.' + }) + }) + it('rejects unsupported navigation targets', async () => { const registry = new ViewClientCommandRegistry() const navigate = jest.fn() diff --git a/apps/cloud/src/app/features/assistant/workbench-navigation-open-client-command.ts b/apps/cloud/src/app/features/assistant/workbench-navigation-open-client-command.ts index f4d0fe1fe5..e197fe010a 100644 --- a/apps/cloud/src/app/features/assistant/workbench-navigation-open-client-command.ts +++ b/apps/cloud/src/app/features/assistant/workbench-navigation-open-client-command.ts @@ -1,17 +1,22 @@ -import { WORKBENCH_NAVIGATION_OPEN_COMMAND } from '@xpert-ai/contracts' +import { + WORKBENCH_ASSISTANT_CONVERSATION_TARGET, + WORKBENCH_KNOWLEDGEBASE_DOCUMENTS_TARGET, + WORKBENCH_NAVIGATION_OPEN_COMMAND, + type WorkbenchAssistantConversationOpenRequest +} from '@xpert-ai/contracts' import { ViewClientCommandRegistry } from '../../@shared/view-extension/view-client-command-registry.service' -export const WORKBENCH_KNOWLEDGEBASE_DOCUMENTS_TARGET = 'knowledgebase.documents' - -export type WorkbenchNavigationOpenTarget = typeof WORKBENCH_KNOWLEDGEBASE_DOCUMENTS_TARGET - -export type WorkbenchNavigationOpenPayload = { - target: WorkbenchNavigationOpenTarget - knowledgebaseId: string -} +export { + WORKBENCH_ASSISTANT_CONVERSATION_TARGET, + WORKBENCH_KNOWLEDGEBASE_DOCUMENTS_TARGET, + type WorkbenchAssistantConversationOpenRequest, + type WorkbenchNavigationOpenPayload, + type WorkbenchNavigationOpenTarget +} from '@xpert-ai/contracts' type WorkbenchNavigationOpenCommandOptions = { navigate?: (commands: string[]) => Promise | unknown + openAssistantConversation?: (request: WorkbenchAssistantConversationOpenRequest) => Promise | unknown } export function registerWorkbenchNavigationOpenCommand( @@ -28,7 +33,7 @@ export function registerWorkbenchNavigationOpenCommand( } } - if (target !== WORKBENCH_KNOWLEDGEBASE_DOCUMENTS_TARGET) { + if (target !== WORKBENCH_KNOWLEDGEBASE_DOCUMENTS_TARGET && target !== WORKBENCH_ASSISTANT_CONVERSATION_TARGET) { return { success: false, code: 'unsupported_target', @@ -36,12 +41,45 @@ export function registerWorkbenchNavigationOpenCommand( } } - const knowledgebaseId = getString(payload, 'knowledgebaseId') - if (!knowledgebaseId) { + const resourceId = + target === WORKBENCH_ASSISTANT_CONVERSATION_TARGET + ? getString(payload, 'conversationId') + : getString(payload, 'knowledgebaseId') + if (!resourceId) { return { success: false, code: 'bad_request', - message: 'Knowledgebase id is required.' + message: + target === WORKBENCH_ASSISTANT_CONVERSATION_TARGET + ? 'Conversation id is required.' + : 'Knowledgebase id is required.' + } + } + + if (target === WORKBENCH_ASSISTANT_CONVERSATION_TARGET) { + if (!options.openAssistantConversation) { + return { + success: false, + code: 'unsupported', + message: 'Assistant conversation opening is not available in this host.' + } + } + + const threadId = getString(payload, 'threadId') + const executionId = getString(payload, 'executionId') + await options.openAssistantConversation({ + conversationId: resourceId, + ...(threadId ? { threadId } : {}), + ...(executionId ? { executionId } : {}) + }) + + return { + success: true, + status: 'opened', + target, + conversationId: resourceId, + ...(threadId ? { threadId } : {}), + ...(executionId ? { executionId } : {}) } } @@ -53,13 +91,13 @@ export function registerWorkbenchNavigationOpenCommand( } } - await options.navigate(['/xpert/knowledges', knowledgebaseId, 'documents']) + await options.navigate(['/xpert/knowledges', resourceId, 'documents']) return { success: true, status: 'opened', target, - knowledgebaseId + knowledgebaseId: resourceId } }) } diff --git a/apps/cloud/src/app/features/assistant/workbench-pdf-evidence-preview.component.ts b/apps/cloud/src/app/features/assistant/workbench-pdf-evidence-preview.component.ts new file mode 100644 index 0000000000..eb47c8aaa6 --- /dev/null +++ b/apps/cloud/src/app/features/assistant/workbench-pdf-evidence-preview.component.ts @@ -0,0 +1,534 @@ +import 'pdfjs-dist/build/pdf.worker.entry' + +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + computed, + effect, + inject, + input, + signal, + viewChild +} from '@angular/core' +import { TranslateModule, TranslateService } from '@ngx-translate/core' +import { + getDocument, + Util, + type PDFDocumentLoadingTask, + type PDFDocumentProxy, + type PDFPageProxy, + type RenderTask +} from 'pdfjs-dist' + +import type { WorkbenchOpenFileEvidenceBox } from './workbench-file-open-client-command' + +type PageSize = { + width: number + height: number +} + +type MarkerStyle = { + left: number + top: number + width: number + height: number +} + +type PdfTextContentItem = Awaited>['items'][number] +type PdfTextItem = Extract + +@Component({ + standalone: true, + selector: 'xp-workbench-pdf-evidence-preview', + imports: [TranslateModule], + template: ` +
+ @if (loading()) { +
+
+ + + {{ + 'PAC.Assistant.FilePreview.RenderingEvidencePage' | translate: { Default: 'Rendering evidence page…' } + }} + +
+
+ } + + @if (error(); as message) { +
+
+
+ +
+

+ {{ + 'PAC.Assistant.FilePreview.EvidencePageUnavailable' + | translate: { Default: 'Evidence page unavailable' } + }} +

+

{{ message }}

+
+
+ } @else { +
+
+ + {{ fileName() || ('PAC.Assistant.FilePreview.PdfDocument' | translate: { Default: 'PDF document' }) }} + + + P{{ renderedPage() || requestedPage() }} + @if (pageCount()) { + / {{ pageCount() }} + } + +
+ +
+ + + @if (markerStyle(); as marker) { +
+ + {{ 'PAC.Assistant.FilePreview.Evidence' | translate: { Default: 'Evidence' } }} + +
+ } +
+
+ } +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class WorkbenchPdfEvidencePreviewComponent { + readonly #destroyRef = inject(DestroyRef) + readonly #translate = inject(TranslateService) + + readonly url = input.required() + readonly fileName = input('') + readonly page = input(null) + readonly box = input(null) + readonly searchTerms = input([]) + + readonly loading = signal(false) + readonly error = signal(null) + readonly pageSize = signal(null) + readonly pageCount = signal(null) + readonly renderedPage = signal(null) + readonly viewportWidth = signal(0) + readonly textMarkerStyle = signal(null) + + readonly requestedPage = computed(() => { + const page = this.page() + return Number.isInteger(page) && Number(page) > 0 ? Number(page) : 1 + }) + + readonly normalizedMarkerStyle = computed(() => { + const box = this.box() + const pageSize = this.pageSize() + if (!box || !pageSize) { + return null + } + + return { + left: box.x * pageSize.width, + top: box.y * pageSize.height, + width: box.width * pageSize.width, + height: box.height * pageSize.height + } + }) + readonly markerStyle = computed(() => this.textMarkerStyle() ?? this.normalizedMarkerStyle()) + + private readonly scrollHost = viewChild>('scrollHost') + private readonly canvasRef = viewChild>('canvasRef') + + #loadedUrl: string | null = null + #loadingTask: PDFDocumentLoadingTask | null = null + #pdf: PDFDocumentProxy | null = null + #renderTask: RenderTask | null = null + #renderSerial = 0 + #lastScrollTarget: string | null = null + + readonly #observeSizeEffect = effect((onCleanup) => { + const host = this.scrollHost()?.nativeElement + if (!host || typeof ResizeObserver === 'undefined') { + return + } + + const updateWidth = () => this.viewportWidth.set(host.clientWidth) + updateWidth() + + let frame = 0 + const observer = new ResizeObserver(() => { + cancelAnimationFrame(frame) + frame = requestAnimationFrame(updateWidth) + }) + observer.observe(host) + + onCleanup(() => { + cancelAnimationFrame(frame) + observer.disconnect() + }) + }) + + readonly #renderEffect = effect(() => { + const url = this.url() + const page = this.requestedPage() + const width = this.viewportWidth() + const searchTerms = this.searchTerms() + const canvas = this.canvasRef()?.nativeElement + + if (!url || !canvas || width <= 0) { + return + } + + void this.render(url, page, width, canvas, searchTerms) + }) + + constructor() { + this.#destroyRef.onDestroy(() => { + this.cancelRenderTask() + this.destroyDocument() + }) + } + + private async render( + url: string, + page: number, + hostWidth: number, + canvas: HTMLCanvasElement, + searchTerms: readonly string[] + ) { + const serial = ++this.#renderSerial + this.loading.set(true) + this.error.set(null) + this.textMarkerStyle.set(null) + + try { + const pdf = await this.ensureDocument(url) + if (serial !== this.#renderSerial) { + return + } + + this.pageCount.set(pdf.numPages) + const pageNumber = clampInteger(page, 1, pdf.numPages) + const pdfPage = await pdf.getPage(pageNumber) + if (serial !== this.#renderSerial) { + pdfPage.cleanup() + return + } + + await this.renderPage(pdfPage, pageNumber, hostWidth, canvas, searchTerms) + if (serial !== this.#renderSerial) { + return + } + + this.scrollMarkerIntoView(url, pageNumber) + } catch (error) { + if (serial === this.#renderSerial) { + this.error.set( + error instanceof Error + ? error.message + : this.#translate.instant('PAC.Assistant.FilePreview.UnknownPdfPreviewError', { + Default: 'Unknown PDF preview error' + }) + ) + this.pageSize.set(null) + } + } finally { + if (serial === this.#renderSerial) { + this.loading.set(false) + } + } + } + + private async ensureDocument(url: string) { + if (this.#pdf && this.#loadedUrl === url) { + return this.#pdf + } + + this.destroyDocument() + this.#loadedUrl = url + this.#loadingTask = getDocument({ url }) + this.#pdf = await this.#loadingTask.promise + return this.#pdf + } + + private async renderPage( + pdfPage: PDFPageProxy, + pageNumber: number, + hostWidth: number, + canvas: HTMLCanvasElement, + searchTerms: readonly string[] + ) { + this.cancelRenderTask() + + const baseViewport = pdfPage.getViewport({ scale: 1 }) + const availableWidth = Math.max(320, hostWidth - 48) + const viewportScale = clampNumber(availableWidth / baseViewport.width, 0.35, 3) + const viewport = pdfPage.getViewport({ scale: viewportScale }) + const outputScale = Math.min(globalThis.devicePixelRatio || 1, 2) + const canvasContext = canvas.getContext('2d') + if (!canvasContext) { + throw new Error( + this.#translate.instant('PAC.Assistant.FilePreview.CanvasUnavailable', { + Default: 'Canvas rendering context is unavailable.' + }) + ) + } + + const viewportWidth = Math.floor(viewport.width) + const viewportHeight = Math.floor(viewport.height) + canvas.width = Math.max(1, Math.floor(viewport.width * outputScale)) + canvas.height = Math.max(1, Math.floor(viewport.height * outputScale)) + canvas.style.width = `${viewportWidth}px` + canvas.style.height = `${viewportHeight}px` + canvasContext.clearRect(0, 0, canvas.width, canvas.height) + + this.pageSize.set({ + width: viewportWidth, + height: viewportHeight + }) + this.renderedPage.set(pageNumber) + + const transform = outputScale === 1 ? undefined : [outputScale, 0, 0, outputScale, 0, 0] + const renderTask = pdfPage.render({ + canvasContext, + viewport, + transform + }) + this.#renderTask = renderTask + + try { + await renderTask.promise + this.textMarkerStyle.set(await resolveTextMarker(pdfPage, viewport, searchTerms, this.normalizedMarkerStyle())) + } finally { + if (this.#renderTask === renderTask) { + this.#renderTask = null + } + pdfPage.cleanup() + } + } + + private scrollMarkerIntoView(url: string, pageNumber: number) { + const marker = this.markerStyle() + const host = this.scrollHost()?.nativeElement + if (!marker || !host) { + return + } + + const targetKey = `${url}:${pageNumber}:${marker.left}:${marker.top}:${marker.width}:${marker.height}` + if (this.#lastScrollTarget === targetKey) { + return + } + this.#lastScrollTarget = targetKey + + requestAnimationFrame(() => { + const centerLeft = marker.left + marker.width / 2 + const centerTop = marker.top + marker.height / 2 + host.scrollTo({ + left: Math.max(0, centerLeft - host.clientWidth / 2), + top: Math.max(0, centerTop - host.clientHeight / 2), + behavior: 'smooth' + }) + }) + } + + private cancelRenderTask() { + if (!this.#renderTask) { + return + } + + this.#renderTask.cancel() + this.#renderTask = null + } + + private destroyDocument() { + this.cancelRenderTask() + + const loadingTask = this.#loadingTask + const pdf = this.#pdf + this.#loadingTask = null + this.#pdf = null + this.#loadedUrl = null + + if (pdf) { + void pdf.destroy().catch(noop) + return + } + + void loadingTask?.destroy().catch(noop) + } +} + +function clampInteger(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, Math.trunc(value))) +} + +function clampNumber(value: number, min: number, max: number) { + if (!Number.isFinite(value)) { + return min + } + + return Math.min(max, Math.max(min, value)) +} + +function noop() {} + +async function resolveTextMarker( + pdfPage: PDFPageProxy, + viewport: ReturnType, + searchTerms: readonly string[], + fallback: MarkerStyle | null +): Promise { + const terms = searchTerms + .map(normalizeSearchText) + .filter((term, index, values) => term.length >= 2 && values.indexOf(term) === index) + if (!terms.length) { + return null + } + + const content = await pdfPage.getTextContent() + const textItems = content.items.filter(isPdfTextItem) + if (!textItems.length) { + return calibrateImageOnlyMarker(fallback, viewport.width, viewport.height) + } + + for (const term of terms) { + const candidates = textItems + .filter((item) => { + const itemText = normalizeSearchText(item.str) + return itemText.includes(term) || (itemText.length >= 4 && term.includes(itemText)) + }) + .map((item) => textItemMarker(item, viewport)) + .filter((marker): marker is MarkerStyle => marker !== null) + + if (candidates.length) { + return closestMarker(candidates, fallback, viewport.width, viewport.height) + } + } + + return null +} + +/** + * Drawing evidence produced from image-only PDF pages is anchored to the + * neighbouring title-block label: half a locator width to the left and one + * locator height below the value. PDF.js cannot refine these pages through a + * text layer, so normalize that VLM anchor before drawing the marker. + */ +function calibrateImageOnlyMarker( + marker: MarkerStyle | null, + pageWidth: number, + pageHeight: number +): MarkerStyle | null { + if (!marker) { + return null + } + + return clampMarker( + { + ...marker, + left: marker.left + marker.width / 2, + top: marker.top - marker.height + }, + pageWidth, + pageHeight + ) +} + +function textItemMarker( + item: { str: string; transform: number[]; width: number; height: number }, + viewport: ReturnType +): MarkerStyle | null { + const transform = Util.transform(viewport.transform, item.transform) + const angle = Math.atan2(transform[1], transform[0]) + if (Math.abs(angle) > 0.12) { + return null + } + + const textHeight = Math.max(2, Math.hypot(transform[2], transform[3])) + const textWidth = Math.max(2, Math.abs(item.width * viewport.scale)) + const padding = Math.max(2, Math.min(6, textHeight * 0.2)) + return clampMarker( + { + left: transform[4] - padding, + top: transform[5] - textHeight - padding, + width: textWidth + padding * 2, + height: textHeight + padding * 2 + }, + viewport.width, + viewport.height + ) +} + +function closestMarker( + candidates: MarkerStyle[], + fallback: MarkerStyle | null, + pageWidth: number, + pageHeight: number +): MarkerStyle { + if (!fallback || candidates.length === 1) { + return candidates[0] + } + const fallbackX = (fallback.left + fallback.width / 2) / pageWidth + const fallbackY = (fallback.top + fallback.height / 2) / pageHeight + return candidates.reduce((closest, candidate) => { + const distance = normalizedDistance(candidate, fallbackX, fallbackY, pageWidth, pageHeight) + const closestDistance = normalizedDistance(closest, fallbackX, fallbackY, pageWidth, pageHeight) + return distance < closestDistance ? candidate : closest + }) +} + +function normalizedDistance( + marker: MarkerStyle, + targetX: number, + targetY: number, + pageWidth: number, + pageHeight: number +) { + const x = (marker.left + marker.width / 2) / pageWidth - targetX + const y = (marker.top + marker.height / 2) / pageHeight - targetY + return x * x + y * y +} + +function clampMarker(marker: MarkerStyle, pageWidth: number, pageHeight: number): MarkerStyle { + const left = Math.max(0, Math.min(pageWidth, marker.left)) + const top = Math.max(0, Math.min(pageHeight, marker.top)) + return { + left, + top, + width: Math.max(1, Math.min(marker.width, pageWidth - left)), + height: Math.max(1, Math.min(marker.height, pageHeight - top)) + } +} + +function normalizeSearchText(value: string): string { + return value.normalize('NFKC').replace(/\s+/g, '').toLocaleLowerCase() +} + +function isPdfTextItem(item: PdfTextContentItem): item is PdfTextItem { + return 'str' in item && Boolean(item.str.trim()) +} diff --git a/apps/cloud/src/app/features/chat/clawxpert/clawxpert-conversation-detail.component.spec.ts b/apps/cloud/src/app/features/chat/clawxpert/clawxpert-conversation-detail.component.spec.ts index 4165d5e652..544860dceb 100644 --- a/apps/cloud/src/app/features/chat/clawxpert/clawxpert-conversation-detail.component.spec.ts +++ b/apps/cloud/src/app/features/chat/clawxpert/clawxpert-conversation-detail.component.spec.ts @@ -235,7 +235,12 @@ import { Component, Input, signal } from '@angular/core' import { TestBed } from '@angular/core/testing' import { By } from '@angular/platform-browser' import { TranslateModule } from '@ngx-translate/core' -import type { IconDefinition, XpertExtensionViewManifest } from '@xpert-ai/contracts' +import { + WORKBENCH_ASSISTANT_CONVERSATION_TARGET, + WORKBENCH_NAVIGATION_OPEN_COMMAND, + type IconDefinition, + type XpertExtensionViewManifest +} from '@xpert-ai/contracts' import { of } from 'rxjs' import { AiThreadService, ChatConversationService, IChatConversation, ViewExtensionApiService } from '../../../@core' import { ChatSharedTerminalComponent } from '../../../@shared/chat/terminal/terminal.component' @@ -1068,6 +1073,63 @@ describe('ClawXpertConversationDetailComponent', () => { expect(conversationService.markRead).toHaveBeenCalledWith('history-conversation-1') }) + it('opens assistant conversation client commands inside the embedded chatkit', async () => { + const setThreadId = jest.fn().mockResolvedValue(undefined) + runtimeModule.injectHostedAssistantChatkitControl.mockReturnValueOnce( + signal({ + element: {}, + setThreadId, + focusComposer: jest.fn() + }) + ) + conversationService.getById.mockReturnValue( + of({ + id: 'job-conversation-1', + threadId: 'persisted-thread-1', + status: 'busy', + messages: [] + } as IChatConversation) + ) + const fixture = TestBed.createComponent(ClawXpertConversationDetailComponent) + await settle(fixture) + + facade.onChatThreadChange.mockClear() + conversationService.markRead.mockClear() + const registry = TestBed.inject(ViewClientCommandRegistry) + const result = await registry.execute( + WORKBENCH_NAVIGATION_OPEN_COMMAND, + { + target: WORKBENCH_ASSISTANT_CONVERSATION_TARGET, + conversationId: 'job-conversation-1', + threadId: 'job-thread-1', + executionId: 'job-execution-1' + }, + { + hostType: 'agent', + hostId: 'assistant-1', + viewKey: 'drawing-material', + manifest: buildFixedViewManifest('drawing-material') + } + ) + await settle(fixture) + + expect(result).toEqual({ + success: true, + status: 'opened', + target: WORKBENCH_ASSISTANT_CONVERSATION_TARGET, + conversationId: 'job-conversation-1', + threadId: 'job-thread-1', + executionId: 'job-execution-1' + }) + expect(conversationService.getById).toHaveBeenCalledWith('job-conversation-1', { relations: ['messages'] }) + expect(setThreadId).toHaveBeenCalledWith('job-thread-1') + expect(facade.onChatThreadChange).toHaveBeenCalledWith('job-thread-1') + expect(facade.setActiveConversation).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'job-conversation-1', threadId: 'job-thread-1' }) + ) + expect(conversationService.markRead).toHaveBeenCalledWith('job-conversation-1') + }) + it('adds a browser tab with the resolved conversation context', async () => { const fixture = TestBed.createComponent(ClawXpertConversationDetailComponent) await settle(fixture) diff --git a/apps/cloud/src/app/features/chat/clawxpert/clawxpert-conversation-detail.component.ts b/apps/cloud/src/app/features/chat/clawxpert/clawxpert-conversation-detail.component.ts index 5c7ccbf453..b48ee1315c 100644 --- a/apps/cloud/src/app/features/chat/clawxpert/clawxpert-conversation-detail.component.ts +++ b/apps/cloud/src/app/features/chat/clawxpert/clawxpert-conversation-detail.component.ts @@ -43,7 +43,10 @@ import { import { injectHostedAssistantChatkitControl } from '../../assistant/assistant-chatkit.runtime' import { createKnowledgebaseCitationOpenHostEvent } from '../../assistant/knowledgebase-citation-effect' import { registerWorkbenchFileOpenCommand } from '../../assistant/workbench-file-open-client-command' -import { registerWorkbenchNavigationOpenCommand } from '../../assistant/workbench-navigation-open-client-command' +import { + registerWorkbenchNavigationOpenCommand, + type WorkbenchAssistantConversationOpenRequest +} from '../../assistant/workbench-navigation-open-client-command' import { openWorkbenchFilePreviewDialog } from '../../assistant/workbench-file-preview-dialog.component' import { WORKBENCH_CHAT_FACADE, WorkbenchChatFacade } from '../workbench-chat/workbench-chat.facade' import { ClawXpertConversationFilesComponent } from './clawxpert-conversation-files.component' @@ -963,7 +966,8 @@ export class ClawXpertConversationDetailComponent implements OnDestroy { } }) this.#unregisterNavigationOpenCommand = registerWorkbenchNavigationOpenCommand(this.#clientCommands, { - navigate: (commands) => this.#router.navigate(commands) + navigate: (commands) => this.#router.navigate(commands), + openAssistantConversation: (request) => this.openWorkbenchAssistantConversation(request) }) effect((onCleanup) => { @@ -1403,6 +1407,31 @@ export class ClawXpertConversationDetailComponent implements OnDestroy { } } + async openWorkbenchAssistantConversation(request: WorkbenchAssistantConversationOpenRequest) { + const conversation = await this.loadConversationDetail(request.conversationId) + const threadId = normalizeConversationThreadId(request.threadId ?? conversation?.threadId) + if (!threadId) { + throw new Error('This assistant conversation has no ChatKit thread.') + } + + const control = this.control() + if (!control) { + throw new Error('Chat is not ready yet.') + } + + this.isChatMinimizedToPet.set(false) + if (conversation) { + this.syncResolvedConversation(request.conversationId, { + ...conversation, + id: conversation.id ?? request.conversationId, + threadId + }) + } + await control.setThreadId(threadId) + this.facade.onChatThreadChange(threadId) + this.markConversationRead(request.conversationId) + } + openFixedViewTab(fixedView: ClawXpertFixedViewMenuItem) { const existing = this.fixedViewTabs().find((tab) => tab.viewKey === fixedView.viewKey) if (existing) { diff --git a/apps/cloud/src/app/xpert/chat.service.ts b/apps/cloud/src/app/xpert/chat.service.ts index 1cdefd4c05..f6e8b11fa5 100644 --- a/apps/cloud/src/app/xpert/chat.service.ts +++ b/apps/cloud/src/app/xpert/chat.service.ts @@ -1,4 +1,5 @@ import { HttpErrorResponse } from '@angular/common/http' +import { EventSourceMessage } from '@microsoft/fetch-event-source' import { computed, DestroyRef, effect, inject, Injectable, signal } from '@angular/core' import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { linkedModel } from '@xpert-ai/core' @@ -34,6 +35,7 @@ import { } from '../@core' import { ChatConversationService, + AiThreadService, ChatMessageFeedbackService, ChatMessageService, ChatService as ChatServerService, @@ -85,6 +87,7 @@ export abstract class ChatService { readonly feedbackService = inject(ChatMessageFeedbackService) readonly chatMessageService = inject(ChatMessageService) readonly xpertService = inject(XpertAPIService) + readonly aiThreadService = inject(AiThreadService) readonly appService = inject(AppService) readonly homeService = inject(XpertHomeService) readonly #logger = inject(NGXLogger) @@ -112,6 +115,11 @@ export abstract class ChatService { readonly pendingFollowUps = signal([]) readonly contextUsageByAgentKey = signal>({}) protected chatSubscription: Subscription = null + private joinedRunSubscription: Subscription = null + private joinedRunKey: string | null = null + private joinedRunLastEventId: string | null = null + private joinedRunReconnectTimer: ReturnType | null = null + private localChatRequestActive = false private readonly messageAppendContextTracker = createMessageAppendContextTracker() private shouldStartFreshAssistantMessageAfterSteer = false @@ -224,6 +232,7 @@ export abstract class ChatService { constructor() { this.#destroyRef.onDestroy(() => { + this.stopJoinedRunStream() if (this.answering() && this.conversation()?.id) { this.cancelMessage() } @@ -245,6 +254,16 @@ export abstract class ChatService { } }) + effect(() => { + const conversation = this.conversation() + const target = this.resolveJoinedRunTarget(conversation) + if (!target) { + this.stopJoinedRunStream() + return + } + this.startJoinedRunStream(target.threadId, target.runId) + }) + effect(() => { // Sync feedbacks from remote conversation to local signal const feedbacks = this.#conversation()?.feedbacks @@ -278,6 +297,7 @@ export abstract class ChatService { 'messages.execution', 'messages.attachments', 'messages.fileAssets', + 'executions', 'task' ] }) @@ -497,6 +517,7 @@ export abstract class ChatService { } this.answering.set(true) + this.localChatRequestActive = true this.messageAppendContextTracker.reset() this.conversation.update((state) => ({ ...(state ?? {}), status: 'busy', error: null }) as IChatConversation) @@ -654,6 +675,7 @@ export abstract class ChatService { } }, error: (error) => { + this.localChatRequestActive = false this.answering.set(false) this.shouldStartFreshAssistantMessageAfterSteer = false this.messageAppendContextTracker.reset() @@ -669,6 +691,7 @@ export abstract class ChatService { this.drainQueuedFollowUps() }, complete: () => { + this.localChatRequestActive = false this.answering.set(false) this.shouldStartFreshAssistantMessageAfterSteer = false this.messageAppendContextTracker.reset() @@ -692,6 +715,7 @@ export abstract class ChatService { cancelMessage() { this.chatSubscription?.unsubscribe() this.answering.set(false) + this.localChatRequestActive = false this.shouldStartFreshAssistantMessageAfterSteer = false // Update conversation status to indicate it's no longer busy @@ -844,6 +868,112 @@ export abstract class ChatService { this.#messages.update((messages) => [...(messages ?? []), message]) } + private resolveJoinedRunTarget(conversation: IChatConversation | null) { + if (this.localChatRequestActive || conversation?.status !== 'busy' || !conversation.threadId) return null + const executions = [...(conversation.executions ?? [])] + .filter((execution) => !execution.parentId) + .sort( + (left, right) => + new Date(right.updatedAt ?? right.createdAt ?? 0).getTime() - + new Date(left.updatedAt ?? left.createdAt ?? 0).getTime() + ) + const execution = + executions.find((item) => + [XpertAgentExecutionStatusEnum.RUNNING, XpertAgentExecutionStatusEnum.PENDING].includes(item.status) + ) ?? executions[0] + return execution?.id ? { threadId: conversation.threadId, runId: execution.id } : null + } + + private startJoinedRunStream(threadId: string, runId: string, lastEventId?: string | null) { + const key = `${threadId}:${runId}` + if (this.joinedRunKey === key && this.joinedRunSubscription && !this.joinedRunSubscription.closed) return + if (this.joinedRunKey !== key) { + this.stopJoinedRunStream() + this.joinedRunKey = key + this.joinedRunLastEventId = null + this.messageAppendContextTracker.reset() + } + this.answering.set(true) + this.joinedRunSubscription = this.aiThreadService.joinRunStream(threadId, runId, lastEventId).subscribe({ + next: (message) => { + if (message.id) this.joinedRunLastEventId = message.id + this.handleJoinedRunMessage(message, runId) + }, + error: () => { + this.joinedRunSubscription = null + if (this.joinedRunKey !== key || this.conversation()?.status !== 'busy') return + this.joinedRunReconnectTimer = setTimeout( + () => this.startJoinedRunStream(threadId, runId, this.joinedRunLastEventId), + 1_000 + ) + }, + complete: () => { + this.joinedRunSubscription = null + this.joinedRunKey = null + this.joinedRunLastEventId = null + this.answering.set(false) + this.messageAppendContextTracker.reset() + } + }) + } + + private stopJoinedRunStream() { + if (this.joinedRunReconnectTimer) clearTimeout(this.joinedRunReconnectTimer) + this.joinedRunReconnectTimer = null + this.joinedRunSubscription?.unsubscribe() + this.joinedRunSubscription = null + this.joinedRunKey = null + this.joinedRunLastEventId = null + } + + private handleJoinedRunMessage(message: EventSourceMessage, runId: string) { + if (message.event === 'error') { + this.#logger.error('Background assistant stream reported an error event') + return + } + if (!message.data || message.data.startsWith(':')) return + const event = JSON.parse(message.data) + if (event.type === ChatMessageTypeEnum.MESSAGE) { + if (![...this.messages()].reverse().some((item) => item.role === 'ai')) { + this.appendMessage({ id: uuid(), role: 'ai', content: '', status: 'thinking' }) + } + const { messageContext } = this.messageAppendContextTracker.resolve({ + incoming: event.data, + fallbackSource: typeof event.data === 'string' ? 'redis_stream' : undefined, + fallbackStreamId: runId + }) + if (typeof event.data === 'string') this.appendStreamMessage(event.data, messageContext) + else this.appendMessageComponent(event.data, messageContext) + return + } + if (event.type !== ChatMessageTypeEnum.EVENT) return + switch (event.event) { + case ChatMessageEventTypeEnum.ON_CONVERSATION_START: + case ChatMessageEventTypeEnum.ON_CONVERSATION_END: + this.updateConversation(omit(event.data, 'messages')) + break + case ChatMessageEventTypeEnum.ON_MESSAGE_START: + if (![...this.messages()].reverse().some((item) => item.role === 'ai')) { + this.appendMessage({ id: uuid(), role: 'ai', content: '', status: 'thinking' }) + } + this.updateLatestMessage((item) => ({ ...item, ...event.data })) + break + case ChatMessageEventTypeEnum.ON_MESSAGE_END: + this.updateLatestMessage((item) => ({ ...item, status: event.data.status, error: event.data.error })) + break + case ChatMessageEventTypeEnum.ON_AGENT_START: + case ChatMessageEventTypeEnum.ON_AGENT_END: + this.upsertAgentExecution(event.data as IXpertAgentExecution) + break + case ChatMessageEventTypeEnum.ON_CHAT_EVENT: + if (isThreadContextUsageEvent(event.data)) this.setContextUsage(event.data) + else this.updateEvent(event) + break + default: + this.updateEvent(event) + } + } + private enqueueFollowUp(item: PendingFollowUp) { this.pendingFollowUps.update((state) => [...(state ?? []), item]) diff --git a/apps/cloud/src/assets/i18n/en.json b/apps/cloud/src/assets/i18n/en.json index b7f0dfe0f4..6f54b2de1e 100644 --- a/apps/cloud/src/assets/i18n/en.json +++ b/apps/cloud/src/assets/i18n/en.json @@ -1702,6 +1702,21 @@ "ClawXpert": { "Label": "ClawXpert", "Description": "Embedded assistant experience for the ClawXpert page." + }, + "FilePreview": { + "SourceDocument": "Source document", + "PdfDocument": "PDF document", + "OpenFile": "Open file", + "DownloadFile": "Download file", + "Close": "Close", + "EvidenceLocation": "Evidence location", + "EvidenceText": "Evidence text", + "EvidenceHint": "The host preview overlays the evidence box using the rendered PDF page dimensions. An external browser tab can only open the original file and cannot render this evidence box.", + "RenderingEvidencePage": "Rendering evidence page…", + "EvidencePageUnavailable": "Evidence page unavailable", + "Evidence": "Evidence", + "UnknownPdfPreviewError": "Unknown PDF preview error", + "CanvasUnavailable": "Canvas rendering context is unavailable." } }, "Workflow": { diff --git a/apps/cloud/src/assets/i18n/zh-Hans.json b/apps/cloud/src/assets/i18n/zh-Hans.json index 5fd88d79db..982488fa57 100644 --- a/apps/cloud/src/assets/i18n/zh-Hans.json +++ b/apps/cloud/src/assets/i18n/zh-Hans.json @@ -1201,6 +1201,21 @@ "ClawXpert": { "Label": "ClawXpert", "Description": "用于 ClawXpert 页面的嵌入式助手体验。" + }, + "FilePreview": { + "SourceDocument": "源文档", + "PdfDocument": "PDF 文档", + "OpenFile": "打开文件", + "DownloadFile": "下载文件", + "Close": "关闭", + "EvidenceLocation": "证据位置", + "EvidenceText": "证据原文", + "EvidenceHint": "宿主预览会按 PDF 页面实际渲染尺寸叠加证据框;外部浏览器标签页只能打开原文件,不能渲染该证据框。", + "RenderingEvidencePage": "正在渲染证据页…", + "EvidencePageUnavailable": "证据页无法预览", + "Evidence": "证据", + "UnknownPdfPreviewError": "未知的 PDF 预览错误", + "CanvasUnavailable": "Canvas 渲染上下文不可用。" } }, "MenuGroup": { diff --git a/docs/plans/tag.mdx b/docs/plans/tag.mdx new file mode 100644 index 0000000000..053a4e594c --- /dev/null +++ b/docs/plans/tag.mdx @@ -0,0 +1,1124 @@ +# 产品需求说明书:Xpert Tag — 面向飞书 / 钉钉 / 企业微信的群组智能体能力 + +版本:v0.1 +目标平台:Xpert 智能体平台 +目标 IM:飞书、钉钉、企业微信 +参考产品:Claude Tag for Slack + +## 1. 背景与调研结论 + +Claude Tag 的本质不是一个普通 Slack 聊天机器人,而是一个“频道级共享智能体”。用户在 Slack 频道中 `@Claude` 后,可以把一个团队任务交给 Claude,Claude 会在该线程中返回进度、结果和工作产物,整个过程对频道成员可见。官方文档明确描述了“Tag @Claude in. Get results back in the thread”的交互方式,并展示了它可以调查问题、对比部署、复现问题、打开 PR 等长任务能力。 + +Claude Tag 的关键架构是:每个 Slack 线程对应一个工作 session,session 在隔离 sandbox 中执行;session 可读取当前线程、频道历史、固定消息等上下文,可以写代码、跑代码、生成图表或文档,并把结果发回线程。长任务会先发 checklist,然后持续更新任务进度。 + +Claude Tag 的权限模型也不是“谁问就用谁的权限”,而是“频道决定智能体能访问什么”。在频道中,Claude 使用管理员配置的服务账号和 Access Bundle;在 DM 中才使用个人连接器。官方文档强调,频道里的 Claude 以自己的 service accounts 行动,连接和权限由管理员按 scope 配置,任何频道成员都共享同一组能力。 + +安全模型上,Claude Tag 使用 Agent Proxy 控制 sandbox 的出站请求。凭证不直接进入 sandbox,而是保存在 credential store 中;当请求命中规则时,Agent Proxy 在边界处注入凭证,默认未授权 host 会被阻断。 + +Claude Tag 还包含频道记忆、例行任务和场景模板。记忆属于频道而非个人,公开频道记忆可在 workspace 内共享,私有频道有自己的记忆隔离;用户可以让 Claude 记住频道规范、查询记忆、修正记忆。 例行任务支持定时任务、频道 watch、PR follow 等模式,任务使用该频道的连接和权限运行。 官方用例覆盖请求分流、频道总结、文档与工单生成、项目状态追踪、数据问答、知识库检索、文档审查、销售状态、监控告警和代码修复等。 + +因此,Xpert 平台要实现“同等能力”,不应只做一个飞书/钉钉/企业微信 webhook 机器人,而应做成一套 **IM 原生 Agent Runtime + 频道级权限治理 + 多平台消息适配 + 任务会话 + 频道记忆 + 例行任务** 的产品能力。 + +--- + +## 2. 产品定位 + +产品名称建议:**Xpert Tag** + +一句话定位: + +> Xpert Tag 让企业可以把 Xpert 数字专家接入飞书、钉钉和企业微信,让团队在群聊中通过 @智能体 直接交付任务,智能体基于群上下文、知识库、业务系统和插件工具完成工作,并在群线程中透明展示过程、结果和审计记录。 + +核心价值: + +1. 把 Xpert 智能体从“平台内对话”扩展到企业真实协作场景。 +2. 让飞书、钉钉、企业微信中的项目群、支持群、告警群、销售群、需求群直接拥有可工作的 Agent。 +3. 用“群组 / 频道”作为权限边界,避免个人权限混乱。 +4. 用 Xpert 现有插件、知识库、工具集、工作流和多智能体能力形成差异化。 + +--- + +## 3. 目标用户与角色 + +| 角色 | 诉求 | +| --------------------- | ------------------------------------------------------------------------------------- | +| 企业管理员 | 安装 Xpert 机器人,绑定飞书 / 钉钉 / 企业微信企业应用,控制可用范围、权限、费用和审计 | +| 群管理员 / 项目负责人 | 给某个群绑定一个或多个 Xpert 智能体,配置群内工作规范、默认模型、知识库和工具 | +| 普通群成员 | 在群里 @智能体 交付任务,查看进度,补充上下文,拿到结果 | +| 业务系统管理员 | 给智能体授权访问知识库、CRM、ERP、BI、Git、工单、监控等系统 | +| 安全 / 审计人员 | 查看智能体被谁触发、读取了什么、调用了什么工具、产生了什么结果 | +| Xpert 平台运营者 | 观察 IM 侧使用量、成本、失败率、常见任务类型,并优化插件和模板 | + +--- + +## 4. 产品目标 + +### 4.1 P0 目标 + +实现“群聊中 @Xpert 智能体完成任务”的闭环: + +1. 支持飞书、钉钉至少两个 IM 平台的企业应用接入。 +2. 用户在群聊中 @机器人后,Xpert 能创建任务 session。 +3. Xpert 能读取本次消息、线程上下文、附件和绑定的知识库 / 工具。 +4. Xpert 能先回复“已开始处理”,随后更新进度,最终在群内返回结果。 +5. 管理员可以按群配置智能体、知识库、工具集、模型和权限。 +6. 所有工具调用、消息输入、输出结果都有审计记录。 + +### 4.2 P1 目标 + +实现接近 Claude Tag 的团队协作体验: + +1. 支持频道 / 群记忆。 +2. 支持定时任务和群消息 watch。 +3. 支持卡片式进度、按钮交互、继续追问、取消任务。 +4. 支持 Access Bundle:把知识库、插件、业务系统凭证打包并绑定到群。 +5. 支持“同一线程内多人接力指挥”。 +6. 支持输出文件、图表、文档、工单、审批建议等 artifact。 + +### 4.3 P2 目标 + +实现更高级的 agentic work: + +1. 支持长任务后台执行和跨小时任务恢复。 +2. 支持代码仓库 / 工单 / BI / ERP / PLM / CRM 等专业插件组合。 +3. 支持技能仓库:把群里沉淀出的流程转为技能模板。 +4. 支持企业级网络出站代理、凭证注入、工具调用沙箱。 +5. 支持跨群共享记忆、组织级知识沉淀和多智能体协作。 + +--- + +## 5. 非目标范围 + +P0 阶段不做以下内容: + +1. 不完整复制 Slack thread 体验,因为飞书、钉钉、企业微信的线程 / 引用 / 回复模型不同。 +2. 不默认监听群内所有消息,除非客户明确授权并且 IM 平台支持。 +3. 不直接使用个人用户的业务系统权限执行操作,P0 统一采用群绑定的服务账号 / 平台凭证。 +4. 不在 P0 实现完整 Claude Code 等级的自动改代码和 PR 创建能力。 +5. 企业微信群机器人能力与飞书、钉钉不完全对等,P0 应按“可接入、可通知、部分互动”降级设计,复杂双向交互需专项验证企业微信当前官方能力。 + +--- + +## 6. 平台适配策略 + +### 6.1 飞书适配 + +推荐使用 **飞书自建应用机器人**,不要只使用自定义 webhook 机器人。飞书开放平台的消息接收事件支持机器人在收到用户消息后触发操作,通过事件订阅和权限配置可接收单聊或群聊消息。 飞书 FAQ 中也说明,自定义机器人主要用于群聊自动发送通知,不能响应用户 @ 机器人的消息,也不能获取用户、租户信息,因此 Xpert Tag 必须优先采用“应用机器人”模式。 + +飞书侧需要支持: + +| 能力 | P0 / P1 | +| ------------------------------------------ | -------------- | +| 单聊消息接收 | P0 | +| 群聊 @机器人消息接收 | P0 | +| 消息发送:文本、富文本、卡片、文件 | P0 | +| 卡片进度更新 | P1 | +| 卡片按钮交互:继续、取消、查看详情、转工单 | P1 | +| 群历史消息读取 | P1,需客户授权 | +| 飞书文档 / 多维表格 / 知识库连接 | P1 / P2 | + +飞书卡片回传交互支持用户与卡片交互,服务端需要在 3 秒内响应回调请求,并可更新卡片或保持原内容。 这意味着 Xpert 的长任务卡片需要采用“快速 ack + 异步更新”的设计,不能把模型长推理阻塞在卡片回调中。 + +### 6.2 钉钉适配 + +推荐使用 **钉钉应用机器人**,接收方式优先支持 Stream,其次支持 HTTP Webhook。钉钉开发者百科说明,机器人可以通过 Webhook 和 Stream 两种方式接收消息;在单聊中用户直接发送消息即可被机器人接收,在群聊中只有 @ 机器人的消息会被机器人接收。 + +钉钉侧回复消息有两种方式:Webhook 和 OpenAPI。Webhook 支持简单文本、Markdown;OpenAPI 支持图片、卡片等丰富交互形式。文档也说明,Stream / WebSocket 通道目前不能直接用于回复 IM 消息。 + +钉钉消息接收类型包括文本、图片、视频、语音、文件、富文本等,语音消息中还包含识别文本字段,可直接进入智能体处理。 + +钉钉侧需要支持: + +| 能力 | P0 / P1 | +| ------------------------------ | ------- | +| 应用机器人接收单聊消息 | P0 | +| 群聊 @机器人触发 | P0 | +| Stream 模式接收事件 | P0 | +| Webhook / OpenAPI 回复消息 | P0 | +| Markdown / 文本回复 | P0 | +| 互动卡片进度与按钮 | P1 | +| 文件、图片、语音消息解析 | P1 | +| 审批、宜搭、钉盘、钉钉文档集成 | P2 | + +### 6.3 企业微信适配 + +企业微信建议拆成两种接入形态: + +1. **企业自建应用模式**:适合企业内部用户与应用对话、接收消息、调用企业微信 API。 +2. **群机器人 webhook 模式**:适合告警、通知、日报、任务结果推送,但不应假设其具备与飞书 / 钉钉应用机器人一致的群内双向 @ 交互能力。 + +企业微信 P0 可以先做: + +| 能力 | P0 / P1 | +| ------------------------------------------ | -------------- | +| 企业微信应用配置 | P0 | +| 单聊 / 应用消息接入 | P0 | +| 群机器人结果推送 | P0 | +| 群内任务通知、日报、告警摘要 | P0 | +| 群内复杂 @ 交互 | P1,需专项验证 | +| 企业微信通讯录、审批、客户联系、微盘等插件 | P2 | + +企业微信侧需要明确产品降级提示:若客户只提供群机器人 webhook,则 Xpert Tag 可主动推送结果,但不能承诺完整的“群内 @ 后多轮任务 session”能力。 + +--- + +## 7. 核心功能需求 + +### FR-01:IM 连接管理 + +管理员可以在 Xpert 后台创建 IM 连接。 + +字段包括: + +| 字段 | 说明 | +| ------------------------------ | --------------------------------------- | +| 平台类型 | 飞书 / 钉钉 / 企业微信 | +| 企业 ID / Corp ID / Tenant Key | 对应 IM 企业主体 | +| App ID / Client ID / Agent ID | IM 应用标识 | +| App Secret / Client Secret | 加密保存 | +| 事件接收方式 | Webhook / Stream / WebSocket / Callback | +| 消息发送方式 | OpenAPI / Webhook / SessionWebhook | +| 可见范围 | 全组织 / 指定部门 / 指定群 / 指定成员 | +| 状态 | 未配置 / 待验证 / 已连接 / 异常 / 禁用 | + +验收标准: + +1. 管理员能完成飞书、钉钉连接创建。 +2. 系统能完成回调 URL 验证。 +3. 系统能发送测试消息到指定群。 +4. 所有密钥只允许写入,不允许二次明文展示。 + +--- + +### FR-02:群组绑定与 Scope 管理 + +管理员可以把一个 Xpert 智能体绑定到一个或多个 IM 群。 + +绑定对象: + +| Scope 层级 | 示例 | +| ---------- | ----------------------------------- | +| 组织级 | 所有群默认可用某个基础问答专家 | +| 应用级 | 某个飞书企业应用下所有群 | +| 群级 | 某个项目群绑定“项目经理专家” | +| 线程级 | 某个任务 session 临时使用指定上下文 | + +每个群绑定配置包括: + +1. 默认智能体。 +2. 默认模型。 +3. 知识库范围。 +4. 工具集 / 插件范围。 +5. 是否允许写操作。 +6. 是否允许读取群历史。 +7. 是否允许主动回复非 @ 消息。 +8. 是否开启频道记忆。 +9. 是否开启例行任务。 +10. 费用预算与用量上限。 + +设计原则参考 Claude Tag:连接、插件、技能、指令应按 scope 生效,而非按个人生效;Claude 官方也将 connections、plugins、custom instructions、channel memory 作为四层行为配置。 + +--- + +### FR-03:触发方式 + +Xpert Tag 应支持以下触发方式: + +| 触发方式 | 说明 | 优先级 | +| -------------- | ----------------------------------------------------------- | ------ | +| @智能体 | 用户在群聊中 @Xpert 或 @某个专家 | P0 | +| 单聊直接发送 | 用户在机器人单聊中直接提问 | P0 | +| 线程内继续回复 | session 已创建后,同一线程 / 引用消息中的回复自动进入上下文 | P0 | +| 卡片按钮 | 点击“继续处理 / 取消 / 转工单 / 查看来源” | P1 | +| 定时任务 | 每天 / 每周 / 工作日定时执行 | P1 | +| 群消息 watch | 监听指定群内符合条件的消息 | P1 | +| 外部事件触发 | Git、监控、工单、审批等事件触发 | P2 | + +触发规则: + +1. P0 中,群聊必须 @ 机器人后才触发。 +2. P1 中,允许群管理员配置“非 @ 主动响应”,但默认关闭。 +3. 线程内回复默认进入当前 session,无需再次 @。 +4. 群成员可以说“停止 / 取消 / 暂停这个任务”终止 session。 +5. 系统必须做消息去重,避免 IM 重试导致重复执行。 + +--- + +### FR-04:Agent Session 生命周期 + +每个 IM 任务创建一个 Agent Session。 + +状态机: + +```text +Created + -> Acknowledged + -> Planning + -> Running + -> WaitingUserInput + -> RenderingResult + -> Completed + -> Failed + -> Cancelled +``` + +生命周期流程: + +1. 用户在群中 @Xpert。 +2. IM Adapter 接收事件并标准化消息。 +3. Trigger Router 判断是否触发智能体。 +4. Scope Resolver 找到该群绑定的智能体、知识库、工具和权限。 +5. Session Manager 创建 session。 +6. Xpert Agent Runtime 生成计划并执行。 +7. Progress Renderer 在群内发送进度消息或卡片。 +8. Tool Gateway 执行工具调用。 +9. Result Renderer 返回最终结果。 +10. Audit Logger 记录完整过程。 + +Claude Tag 的官方生命周期是:用户 @Claude 或 routine 启动 session;系统构建 sandbox;Claude 使用频道权限工作并更新 checklist;结果回到线程;静默后释放 sandbox,新回复可重建 session。Xpert Tag 可参考这一流程设计自己的 session runtime。 + +--- + +### FR-05:上下文读取 + +Xpert Tag 需要构建统一上下文包: + +| 上下文类型 | P0 / P1 | +| ---------------------------- | ---------- | +| 当前触发消息 | P0 | +| 当前消息引用 / 回复链 | P0 | +| 当前 session 历史 | P0 | +| 附件:图片、文档、音频、文件 | P1 | +| 群最近 N 条消息 | P1,需权限 | +| 群置顶消息 / 公告 / 群描述 | P1 | +| 群记忆 | P1 | +| 绑定知识库检索结果 | P0 | +| 绑定业务系统数据 | P1 / P2 | + +上下文策略: + +1. 对群历史读取必须有显式授权。 +2. 默认只读取当前线程 / 当前引用链。 +3. 长线程应摘要压缩后进入上下文。 +4. 每次回答应尽量标注信息来源:群消息、知识库、业务系统、附件。 +5. 敏感字段进入模型前应经过脱敏策略。 + +--- + +### FR-06:进度展示 + +P0 先支持普通文本进度: + +```text +我已开始处理,将按以下步骤执行: +1. 读取当前讨论上下文 +2. 检索相关知识库 +3. 汇总结论并给出建议 +``` + +P1 支持卡片进度: + +| 展示元素 | 说明 | +| ------------ | ------------------------------------------ | +| 当前状态 | 排队中 / 执行中 / 等待确认 / 已完成 / 失败 | +| Checklist | 已完成、进行中、未开始 | +| 工具调用摘要 | 例如“已检索 5 篇知识库文档” | +| 操作按钮 | 取消、继续、查看详情、转工单 | +| 最终结果入口 | 文档、图表、工单、文件、详情页 | + +Claude Tag 对长任务使用 live checklist,并通过编辑同一条消息持续更新进度;Xpert Tag 应在飞书 / 钉钉中优先用卡片更新实现类似效果,在企业微信中根据能力退化为多条消息或链接。 + +--- + +### FR-07:结果输出 + +Xpert Tag 应支持多种结果形态: + +| 输出类型 | 示例 | 优先级 | +| ----------------- | ------------------------------- | ------ | +| 文本回复 | 结论、摘要、建议 | P0 | +| 富文本 / Markdown | 表格、列表、引用 | P0 | +| 卡片 | 状态卡、审批卡、任务卡 | P1 | +| 文件 | Word、Excel、PDF、图片 | P1 | +| 图表 | BI 图表、指标趋势 | P1 | +| 工单 | Jira / 禅道 / Linear / 自研工单 | P2 | +| 文档 | 飞书文档、钉钉文档、知识库页面 | P2 | +| 代码 PR | GitHub / GitLab PR | P2 | + +Claude Tag 官方用例中,结果可以是回答、文档、图表、PR、ticket 等,而不是单纯聊天回复。 Xpert Tag 也应将“artifact 生成”作为核心能力,而不是只返回自然语言。 + +--- + +### FR-08:频道级 Access Bundle + +Xpert 平台需要实现类似 Claude Tag Access Bundle 的能力。 + +Access Bundle 是一组可复用的能力包,包括: + +1. 知识库范围。 +2. 工具集 / 插件。 +3. 业务系统凭证。 +4. 可访问域名 / API 白名单。 +5. 默认指令。 +6. 可写权限策略。 +7. 模型策略。 +8. 费用限制。 + +示例: + +| Bundle 名称 | 内容 | +| --------------------- | -------------------------------- | +| `kb-company-readonly` | 公司制度知识库,只读 | +| `project-delivery` | 项目知识库 + 工单系统 + 文档生成 | +| `sap-bom-readonly` | SAP / PLM / BOM 查询工具,只读 | +| `bi-analytics` | 数据库只读连接 + 指标语义模型 | +| `support-triage` | 客服知识库 + 工单创建工具 | + +Claude Tag 的官方文档建议将连接按能力拆分成多个 bundle,例如 data-readonly、github-write、monitoring,然后组合到不同频道;每个凭证只定义一次,便于轮换和复用。 + +--- + +### FR-09:服务账号与凭证治理 + +Xpert Tag 默认采用“服务账号”执行任务,而不是“触发用户个人账号”。 + +要求: + +1. 每个外部系统连接都应有独立服务账号。 +2. 凭证进入 Xpert Credential Vault,不暴露给模型。 +3. 工具执行通过 Tool Gateway / Credential Proxy 注入凭证。 +4. 工具调用必须记录调用人、群、session、工具名、参数摘要、结果摘要。 +5. 写操作默认需要二次确认。 +6. 高风险工具可配置审批流。 +7. 管理员可随时停用 bundle 或轮换凭证。 + +这与 Claude Tag 的设计一致:频道任务中的连接属于 agent identity,而不是个人;连接的服务账号能力对该 scope 下所有频道成员可用。 + +--- + +### FR-10:频道记忆 + +频道记忆用于保存群内长期有效的约定、决策和偏好。 + +支持命令: + +```text +@Xpert 记住:这个群的日报输出格式使用表格。 +@Xpert 你记得这个群有哪些规则? +@Xpert 删除关于“旧报价流程”的记忆。 +@Xpert 修正:我们的发布窗口是每周三,不是周五。 +``` + +记忆类型: + +| 类型 | 示例 | +| -------- | -------------------------------- | +| 群规则 | “回复先给结论,再给依据” | +| 项目事实 | “当前项目客户是华域三电” | +| 决策记录 | “报价口径按 2000/人天计算” | +| 输出格式 | “日报必须包含风险、阻塞、下一步” | +| 禁止事项 | “不要在群里展示敏感价格明细” | + +权限策略: + +1. 群成员可添加普通记忆。 +2. 群管理员可删除 / 锁定记忆。 +3. 私密群记忆不得跨群共享。 +4. 组织管理员可关闭频道记忆。 +5. 记忆变更必须有审计。 + +Claude Tag 的记忆属于频道而不是个人,公开频道记忆可在 workspace 内共享,私有频道独立保存。Xpert Tag 可以采用类似模型,但要结合中国企业对数据隔离的要求,默认更保守:群记忆不跨群共享,除非管理员显式开启。 + +--- + +### FR-11:例行任务 Routines + +群成员或群管理员可以让 Xpert 设置持续任务。 + +示例: + +```text +@Xpert 每个工作日 9 点,总结这个群昨天未关闭的问题,并列出负责人。 +@Xpert 每天下午 5 点,汇总今天项目进展、阻塞和明天计划。 +@Xpert 监听这个群里的“紧急”“故障”“客户投诉”,出现时自动总结并提醒负责人。 +@Xpert 每周五 3 点,汇总本周所有需求,并按主题分类。 +``` + +Routine 类型: + +| 类型 | 说明 | +| -------- | ---------------------------------------- | +| 定时任务 | 每日 / 每周 / 每月执行 | +| 条件监听 | 群消息匹配条件时执行 | +| 外部事件 | 工单、监控、审批、Git 事件触发 | +| 跟踪任务 | 持续追踪某个审批、PR、合同、问题直到完成 | + +管理能力: + +1. 查看本群所有 routines。 +2. 暂停 / 恢复 / 删除 routine。 +3. 修改执行时间和输出格式。 +4. 查看最近执行记录。 +5. 配置失败重试和通知。 + +Claude Tag 支持 scheduled jobs、watch channels、follow pull request 等 standing work,并且 routine 使用所在频道的连接和权限。 + +--- + +### FR-12:插件、技能与 Xpert 平台集成 + +Xpert Tag 应复用 Xpert 平台已有能力: + +| Xpert 能力 | 在 Xpert Tag 中的作用 | +| ---------- | -------------------------------------------- | +| 数字专家 | 作为群内可 @ 的智能体 | +| 工具集 | 作为智能体可调用能力 | +| 工作流 | 作为固定业务流程,如报价、审批、工单分流 | +| 知识库 | 支撑群内问答、政策查询、项目资料查询 | +| Agentic BI | 支持群内指标查询、图表生成、日报 | +| 插件系统 | 对接飞书、钉钉、企业微信、SAP、PLM、CRM、Git | +| 本体系统 | 支持 PBOM、物料、合同、报价等结构化语义 | +| 多智能体 | Supervisor 分发任务给专业智能体 | + +技能设计: + +1. 每个群可绑定一个或多个技能包。 +2. 技能包包含:触发意图、工具说明、业务流程、输出模板、风险提示。 +3. 技能可由平台管理员统一发布。 +4. P2 支持从高频群任务中沉淀技能模板。 + +Claude Tag 官方也支持 plugins 和 skills,用于教 Claude 如何使用工具或遵循流程,并支持通过技能仓库持续更新。 + +--- + +## 8. 关键用户流程 + +### 8.1 管理员接入流程 + +```text +进入 Xpert 后台 + -> 选择“IM 集成” + -> 创建飞书 / 钉钉 / 企业微信连接 + -> 填写 App ID、Secret、回调地址 + -> 平台校验连接 + -> 创建 Access Bundle + -> 选择知识库、工具集、插件、模型 + -> 绑定到指定群 + -> 发送测试消息 + -> 发布上线 +``` + +### 8.2 群成员使用流程 + +```text +用户在群里 @Xpert: +“@Xpert 总结一下这个需求讨论,给出 PRD 草稿。” + +Xpert 回复: +“收到,我会读取当前线程、提取需求点、生成 PRD 草稿。” + +Xpert 更新进度: +- 已读取 23 条讨论 +- 已识别 5 个需求点 +- 已整理用户角色和流程 +- 正在生成 PRD + +Xpert 最终回复: +“以下是 PRD 草稿……” +并附带: +[生成文档] [继续细化] [转成任务] [查看来源] +``` + +### 8.3 频道记忆流程 + +```text +用户: +@Xpert 记住:这个群里的客户方案输出不要出现内部产品代号。 + +Xpert: +已记住。之后本群生成客户方案时,我会避免使用内部产品代号。 +``` + +### 8.4 Routine 流程 + +```text +用户: +@Xpert 每天 18:00 总结今天项目进展、阻塞和明天计划。 + +Xpert: +已创建例行任务: +名称:项目日报 +时间:每个工作日 18:00 +输出位置:当前群 +内容:进展 / 阻塞 / 明日计划 +``` + +--- + +## 9. 系统架构设计 + +建议架构: + +```text +飞书 / 钉钉 / 企业微信 + | + v +IM Adapter Layer +- Feishu Adapter +- DingTalk Adapter +- WeCom Adapter + | + v +Message Normalizer +统一转换为 XpertMessage + | + v +Trigger Router +判断 @、单聊、线程、routine、watch + | + v +Scope Resolver +解析群绑定的 Agent、Access Bundle、权限、模型 + | + v +Agent Session Manager +创建、恢复、取消、追踪 session + | + v +Xpert Agent Runtime +规划、工具调用、多智能体协作、生成结果 + | + +----------------------+ + | Tool Gateway | + | Credential Proxy | + | Knowledge Retrieval | + | Workflow Engine | + +----------------------+ + | + v +Result Renderer +文本 / Markdown / 卡片 / 文件 / 图表 + | + v +IM Adapter Layer +返回群聊 / 单聊 / 卡片更新 + | + v +Audit & Observability +审计、日志、费用、质量评估 +``` + +--- + +## 10. 核心数据模型 + +### 10.1 IMConnection + +| 字段 | 说明 | +| ------------ | ------------------------- | +| id | 连接 ID | +| platform | feishu / dingtalk / wecom | +| tenantId | Xpert 租户 | +| corpId | IM 企业 ID | +| appId | IM 应用 ID | +| appSecretRef | 凭证引用 | +| callbackUrl | 回调地址 | +| status | 状态 | +| createdBy | 创建人 | + +### 10.2 ChannelBinding + +| 字段 | 说明 | +| ----------------- | --------------------- | +| id | 绑定 ID | +| connectionId | IM 连接 | +| platformChannelId | 群 ID | +| channelName | 群名 | +| agentId | 默认智能体 | +| accessBundleIds | 绑定的能力包 | +| memoryEnabled | 是否开启记忆 | +| routineEnabled | 是否开启例行任务 | +| proactiveMode | 是否允许非 @ 主动回复 | + +### 10.3 AgentSession + +| 字段 | 说明 | +| ---------------- | ---------------- | +| id | session ID | +| tenantId | 租户 | +| platform | IM 平台 | +| channelId | 群 ID | +| threadId | 线程 / 引用链 ID | +| triggerMessageId | 触发消息 | +| agentId | 智能体 | +| status | 状态 | +| model | 模型 | +| startedBy | 触发用户 | +| startedAt | 开始时间 | +| completedAt | 完成时间 | +| cost | 成本 | + +### 10.4 AccessBundle + +| 字段 | 说明 | +| ---------------- | ---------- | +| id | 能力包 ID | +| name | 名称 | +| knowledgeBaseIds | 知识库 | +| toolsetIds | 工具集 | +| pluginIds | 插件 | +| credentialRefs | 凭证 | +| allowedDomains | 出站域名 | +| writePolicy | 写权限策略 | +| instructions | 默认指令 | + +### 10.5 ChannelMemory + +| 字段 | 说明 | +| ---------------- | ----------------------------------- | +| id | 记忆 ID | +| channelBindingId | 所属群 | +| content | 记忆内容 | +| type | rule / fact / decision / preference | +| visibility | channel / workspace / private | +| createdBy | 创建人 | +| confidence | 置信度 | +| locked | 是否锁定 | + +### 10.6 Routine + +| 字段 | 说明 | +| ---------------- | ------------------------- | +| id | routine ID | +| channelBindingId | 所属群 | +| name | 名称 | +| triggerType | schedule / watch / event | +| schedule | cron / rrule | +| condition | 监听条件 | +| prompt | 执行指令 | +| status | active / paused / deleted | +| lastRunAt | 上次执行时间 | +| nextRunAt | 下次执行时间 | + +--- + +## 11. 权限与安全需求 + +### 11.1 权限原则 + +1. 群能访问什么,由群绑定的 Access Bundle 决定。 +2. 用户不能通过 @Xpert 绕过自己本来没有权限的系统,除非该群被授权了服务账号能力。 +3. 服务账号权限必须最小化。 +4. 写操作必须可控、可审计、可撤销。 +5. 敏感工具默认需要确认。 + +### 11.2 审计日志 + +每个 session 必须记录: + +| 审计项 | 说明 | +| ---------- | ------------------------------ | +| 触发用户 | 谁 @ 了智能体 | +| 触发群 | 哪个群 | +| 输入消息 | 原始消息摘要 | +| 上下文来源 | 群消息、附件、知识库、业务系统 | +| 工具调用 | 工具名、参数摘要、状态 | +| 凭证使用 | 使用哪个服务账号 | +| 输出结果 | 回复摘要、文件、卡片 | +| 成本 | token、模型、工具成本 | +| 风险事件 | 敏感信息、越权、失败、人工确认 | + +### 11.3 出站访问控制 + +P1 需要实现 Tool Gateway 的域名和 API allowlist: + +1. 未授权域名默认阻断。 +2. 可按 Access Bundle 配置 allowed domains。 +3. 可按工具配置 HTTP method、path prefix、read/write。 +4. 凭证只在工具执行边界注入。 +5. 模型不能直接看到原始 secret。 + +--- + +## 12. 管理后台需求 + +管理后台模块: + +1. **IM 集成管理** + - 创建连接 + - 查看连接状态 + - 测试发送消息 + - 查看回调健康状态 + +2. **群绑定管理** + - 搜索群 + - 绑定智能体 + - 配置默认模型 + - 配置是否开启记忆 / routine / 主动回复 + +3. **Access Bundle 管理** + - 创建能力包 + - 添加知识库 + - 添加工具集 + - 添加插件 + - 添加凭证 + - 配置 allowed domains + - 配置写权限策略 + +4. **频道行为配置** + - 默认系统提示词 + - 输出格式 + - 回复风格 + - 是否自动总结 + - 是否允许群成员修改频道记忆 + +5. **审计与用量** + - session 列表 + - 工具调用记录 + - 失败记录 + - 成本统计 + - 群维度使用排行 + +6. **Routine 管理** + - 查看所有例行任务 + - 启停任务 + - 修改任务 + - 查看执行记录 + +Claude Tag 也提供组织级设置、scope 级自定义指令、默认模型、连接、插件、版本开关、访问限制等管理能力;Xpert Tag 可以按 Xpert 的租户 / 组织 / 群组模型实现同类能力。 + +--- + +## 13. 典型场景模板 + +### 13.1 项目群:项目状态总结 + +用户: + +```text +@Xpert 总结一下这个项目当前进度,列出阻塞项、负责人和下一步。 +``` + +输出: + +```text +项目状态摘要: +1. 已完成…… +2. 进行中…… +3. 阻塞项…… +4. 需要跟进…… +``` + +对应 Claude Tag 用例:项目状态追踪、审批跟进、每日 digest。 + +--- + +### 13.2 支持群:请求分流 + +用户: + +```text +@Xpert 这个问题应该找谁处理? +``` + +Xpert: + +1. 检查是否是重复问题。 +2. 查知识库是否已有答案。 +3. 判断归属团队。 +4. 给出负责人。 +5. 必要时创建工单。 + +对应 Claude Tag 用例:intake channel triage,能回答、标记重复、路由请求、生成周报。 + +--- + +### 13.3 知识库问答 + +用户: + +```text +@Xpert 我们的数据保留政策是什么?引用对应制度文档。 +``` + +Xpert: + +1. 检索绑定知识库。 +2. 给出结论。 +3. 引用来源。 +4. 提醒过期或冲突文档。 + +对应 Claude Tag 用例:连接文档后查找政策、runbook、历史决策,并把来源返回线程。 + +--- + +### 13.4 BI 数据问答 + +用户: + +```text +@Xpert 看一下上周销售额趋势,按区域给我一个图表。 +``` + +Xpert: + +1. 调用 Agentic BI / 语义模型。 +2. 查询数据。 +3. 生成图表。 +4. 在群里发图和结论。 + +对应 Claude Tag 用例:从数据仓库查询数据,生成图表,或基于频道历史做统计。 + +--- + +### 13.5 讨论转文档 / 工单 + +用户: + +```text +@Xpert 把这个线程整理成一页 PRD,包括背景、需求、流程和待确认问题。 +``` + +Xpert: + +1. 读取线程。 +2. 提取结论。 +3. 生成文档。 +4. 可选:创建飞书文档 / 钉钉文档 / Xpert 项目文档。 +5. 可选:拆成工单。 + +对应 Claude Tag 用例:把 Slack thread 转为 decision doc、客户回复、ticket 或 planning outline。 + +--- + +## 14. MVP 范围建议 + +### MVP 版本:v0.1 + +优先实现飞书 + 钉钉,企业微信做通知型能力。 + +| 模块 | 范围 | +| ------------ | ---------------------------------------------------- | +| 飞书接入 | 自建应用机器人、@消息、单聊、文本 / 富文本回复 | +| 钉钉接入 | 应用机器人、Stream 接收、@消息、文本 / Markdown 回复 | +| 企业微信接入 | webhook 推送、应用单聊基础接入 | +| 群绑定 | 每个群绑定一个默认 Xpert 智能体 | +| 知识库 | 群绑定知识库,支持 RAG 问答 | +| 工具调用 | 支持 Xpert 工具集,只读工具优先 | +| session | 创建、执行、完成、失败、取消 | +| 进度 | 文本进度消息 | +| 审计 | session 和工具调用记录 | +| 管理后台 | IM 连接、群绑定、知识库绑定、工具绑定 | + +### MVP 暂不做 + +1. 主动监听非 @ 消息。 +2. 复杂卡片按钮交互。 +3. 长任务跨小时恢复。 +4. 多 Access Bundle 叠加。 +5. 频道记忆跨群共享。 +6. 自动改代码和 PR。 +7. 企业微信复杂群内双向交互。 + +--- + +## 15. 版本路线图 + +### v0.1:可用版 + +目标:群里 @Xpert 能处理知识库问答和简单工具任务。 + +交付: + +1. 飞书 / 钉钉基础接入。 +2. 群绑定智能体。 +3. @触发 session。 +4. 文本进度和结果回复。 +5. 知识库检索。 +6. 工具调用审计。 + +### v0.2:协作版 + +目标:具备 Claude Tag 样式的协作体验。 + +交付: + +1. 卡片进度。 +2. 线程内多人追问。 +3. 频道记忆。 +4. Routine 定时任务。 +5. Access Bundle。 +6. 费用统计。 +7. 文件和图表输出。 + +### v0.3:治理版 + +目标:企业级安全治理。 + +交付: + +1. Credential Vault。 +2. Tool Gateway / 出站 allowlist。 +3. 写操作确认。 +4. 管理员审批。 +5. 安全审计报表。 +6. 敏感信息脱敏。 +7. 多租户组织级策略。 + +### v0.4:Agentic Work 版 + +目标:让群内智能体真正执行复杂工作。 + +交付: + +1. 多智能体协作。 +2. 项目自动跟踪。 +3. 业务系统深度插件。 +4. 技能仓库。 +5. 自动生成工单 / 文档 / BI 图表。 +6. 代码仓库分析和 PR 草稿。 +7. 与 Xpert Project 深度集成。 + +--- + +## 16. 成功指标 + +### 使用指标 + +| 指标 | 目标 | +| ---------------- | ----------- | +| 已连接企业数 | v0.1 ≥ 3 | +| 已绑定群数 | v0.1 ≥ 20 | +| 周活跃群数 | v0.1 ≥ 10 | +| 每周 session 数 | v0.1 ≥ 200 | +| 首次响应时间 | P95 < 3 秒 | +| 普通任务完成时间 | P95 < 60 秒 | +| 任务成功率 | ≥ 90% | + +### 业务指标 + +| 指标 | 目标 | +| ------------------ | ------- | +| 知识库问答采纳率 | ≥ 70% | +| 群总结满意度 | ≥ 4 / 5 | +| 工单分流准确率 | ≥ 80% | +| 人工重复回答减少 | ≥ 30% | +| 客户试点转正式部署 | ≥ 50% | + +### 安全指标 + +| 指标 | 目标 | +| ---------------------- | ---- | +| 工具调用审计覆盖率 | 100% | +| 凭证明文暴露 | 0 | +| 未授权工具调用 | 0 | +| 高风险写操作确认覆盖率 | 100% | + +--- + +## 17. 风险与应对 + +| 风险 | 说明 | 应对 | +| ------------------ | ---------------------------------------------------- | ----------------------------------------------------------- | +| IM 平台能力不一致 | 飞书、钉钉、企业微信的消息、线程、卡片、回调能力不同 | 建立统一 Message Abstraction,平台能力按 capability degrade | +| 企业微信群交互受限 | 群机器人可能更偏通知,不适合完整双向任务 session | P0 明确降级为通知 / 单聊 / 应用模式,P1 专项验证 | +| 群历史读取敏感 | 企业可能不允许读取所有消息 | 默认只读触发消息和线程,群历史需显式授权 | +| 服务账号越权 | 群成员通过机器人间接访问超权限系统 | Access Bundle 最小权限 + 审计 + 写操作确认 | +| 长任务体验差 | IM 平台回调超时、卡片更新受限 | 快速 ack + 异步 session + 进度消息 | +| 消息重复触发 | IM 平台事件重试导致重复执行 | message_id 幂等去重 | +| 成本不可控 | 群里频繁 @ 导致模型成本上升 | 群级预算、组织级额度、限流、费用看板 | +| 输出不可靠 | Agent 总结群消息可能遗漏上下文 | 明确来源、允许追问修正、重要操作需确认 | + +--- + +## 18. 验收标准 + +### P0 验收 + +1. 管理员能在 Xpert 后台成功创建飞书连接。 +2. 管理员能在 Xpert 后台成功创建钉钉连接。 +3. 管理员能把某个群绑定到指定 Xpert 智能体。 +4. 用户在飞书群 @Xpert 后,Xpert 能回复。 +5. 用户在钉钉群 @Xpert 后,Xpert 能回复。 +6. Xpert 能基于绑定知识库回答问题。 +7. Xpert 能调用至少一个只读工具。 +8. Xpert 能返回“处理中 / 已完成 / 失败”状态。 +9. 后台能看到 session 记录和工具调用记录。 +10. 禁用群绑定后,该群无法继续触发 Xpert。 +11. App Secret / Client Secret 不可明文回显。 +12. IM 事件重复投递不会导致重复 session。 + +### P1 验收 + +1. 支持卡片式进度展示。 +2. 支持用户在同一线程继续追问。 +3. 支持频道记忆的新增、查询、删除。 +4. 支持定时日报 routine。 +5. 支持 Access Bundle 管理。 +6. 支持按群查看用量。 +7. 支持文件 / 图表输出。 +8. 支持写操作前确认。 + +--- + +## 19. 推荐技术实现优先级 + +第一优先级: + +1. `@xpert/server-im`:统一 IM 接入服务。 +2. `FeishuAdapter`、`DingTalkAdapter`、`WeComAdapter`。 +3. `XpertMessage` 标准消息模型。 +4. `AgentSessionManager`。 +5. `ScopeResolver`。 +6. `ToolGateway`。 +7. `AuditLogger`。 + +第二优先级: + +1. `ProgressCardRenderer`。 +2. `ChannelMemoryService`。 +3. `RoutineScheduler`。 +4. `AccessBundleService`。 +5. `CredentialVault`。 +6. `CostMeteringService`。 + +第三优先级: + +1. `SkillRepository`。 +2. `AgentProxy / EgressPolicy`。 +3. `MultiAgentSupervisor`。 +4. `ArtifactService`。 +5. `IM Capability Matrix` 自动降级系统。 + +--- + +## 20. 最终建议 + +Xpert Tag 应优先把产品核心定义为: + +> 群组里的企业级共享智能体,而不是聊天机器人。 + +MVP 不要追求一次性覆盖 Claude Tag 的所有能力,而应先打通以下最小闭环: + +```text +IM 群 @Xpert + -> 创建 session + -> 读取上下文 + -> 使用群绑定知识库 / 工具 + -> 进度反馈 + -> 结果回群 + -> 全链路审计 +``` + +在此基础上,再逐步补齐 Claude Tag 的关键高级能力: + +1. Access Bundle。 +2. 频道记忆。 +3. Routine。 +4. 卡片式进度。 +5. 频道级权限治理。 +6. 工具凭证代理。 +7. Artifact 输出。 +8. 多智能体协作。 + +如果 Xpert 能把这些能力与现有的知识库、工作流、Agentic BI、本体系统、插件体系结合起来,它在中国企业 IM 场景中会比单纯复制 Claude Tag 更有优势。 diff --git a/package.json b/package.json index 31e71d84f6..472ac07300 100644 --- a/package.json +++ b/package.json @@ -172,6 +172,7 @@ "ollama": "^0.5.6", "passport-dingtalk2": "^2.1.1", "pg": "^8.7.3", + "pdfjs-dist": "2.12.313", "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", diff --git a/packages/contracts/src/view-extension/model.ts b/packages/contracts/src/view-extension/model.ts index 3f551bc151..e183327410 100644 --- a/packages/contracts/src/view-extension/model.ts +++ b/packages/contracts/src/view-extension/model.ts @@ -289,6 +289,35 @@ export interface XpertViewClientCommandDefinition { export const ASSISTANT_CHAT_SEND_MESSAGE_COMMAND = 'assistant.chat.send_message' export const ASSISTANT_CONTEXT_SET_COMMAND = 'assistant.context.set' export const WORKBENCH_NAVIGATION_OPEN_COMMAND = 'workbench.navigation.open' +export const WORKBENCH_KNOWLEDGEBASE_DOCUMENTS_TARGET = 'knowledgebase.documents' +export const WORKBENCH_ASSISTANT_CONVERSATION_TARGET = 'assistant.conversation' +export const XPERT_REMOTE_COMPONENT_INVOKE_CLIENT_COMMAND_MESSAGE_TYPE = 'invokeClientCommand' + +export type WorkbenchNavigationOpenTarget = + | typeof WORKBENCH_KNOWLEDGEBASE_DOCUMENTS_TARGET + | typeof WORKBENCH_ASSISTANT_CONVERSATION_TARGET + +export interface WorkbenchNavigationOpenPayload { + target: WorkbenchNavigationOpenTarget + knowledgebaseId?: string + conversationId?: string + threadId?: string + executionId?: string +} + +export interface WorkbenchAssistantConversationOpenRequest { + conversationId: string + threadId?: string + executionId?: string +} + +export interface XpertRemoteComponentInvokeClientCommandRequest { + type: typeof XPERT_REMOTE_COMPONENT_INVOKE_CLIENT_COMMAND_MESSAGE_TYPE + requestId: string + commandKey: string + payload?: unknown +} + export const ASSISTANT_CITATION_OPEN_EVENT = 'assistant.citation.open' export const KNOWLEDGEBASE_OPEN_CITATION_EFFECT = 'knowledgebase.open_citation' diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 1a86575add..d7f796f222 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -19,3 +19,18 @@ Run `nx test plugin-sdk` to execute the unit tests via [Jest](https://jestjs.io) ## View extensions [View Extension Protocol](./docs/view-extension-protocol.md) + +## Browser collaboration client + +Remote Components should import the framework-neutral collaboration client from the browser-safe entry point: + +```ts +import { + createCollaborationClient, + createCollaborationPresenceStore, + createSocketIoTransportAdapter, + createYjsDocumentAdapter +} from '@xpert-ai/plugin-sdk/collaboration-client' +``` + +This entry point excludes the NestJS and Node.js dependencies used by the server SDK. diff --git a/packages/plugin-sdk/docs/view-extension-protocol.md b/packages/plugin-sdk/docs/view-extension-protocol.md index def09fbdf2..d52969dd3c 100644 --- a/packages/plugin-sdk/docs/view-extension-protocol.md +++ b/packages/plugin-sdk/docs/view-extension-protocol.md @@ -74,6 +74,19 @@ Iframe to parent: No other message type may perform backend I/O until it is added to this document, the host bridge, and the provider interface. +Remote components and hosts should import the public identifiers from `@xpert-ai/contracts` instead of repeating +their wire values: + +```ts +import { + WORKBENCH_ASSISTANT_CONVERSATION_TARGET, + WORKBENCH_NAVIGATION_OPEN_COMMAND, + XPERT_REMOTE_COMPONENT_INVOKE_CLIENT_COMMAND_MESSAGE_TYPE, + type WorkbenchNavigationOpenPayload, + type XpertRemoteComponentInvokeClientCommandRequest +} from '@xpert-ai/contracts' +``` + ## Manifest Rules The manifest is the capability whitelist. A remote component may only request operations that the manifest declares. diff --git a/packages/plugin-sdk/project.json b/packages/plugin-sdk/project.json index bdbaa2325a..de06cb409f 100644 --- a/packages/plugin-sdk/project.json +++ b/packages/plugin-sdk/project.json @@ -11,6 +11,8 @@ "options": { "outputPath": "packages/plugin-sdk/dist", "main": "packages/plugin-sdk/src/index.ts", + "additionalEntryPoints": ["packages/plugin-sdk/src/collaboration-client.ts"], + "generateExportsField": true, "tsConfig": "packages/plugin-sdk/tsconfig.lib.json", "assets": [ { diff --git a/packages/plugin-sdk/src/collaboration-client.ts b/packages/plugin-sdk/src/collaboration-client.ts new file mode 100644 index 0000000000..42b517706c --- /dev/null +++ b/packages/plugin-sdk/src/collaboration-client.ts @@ -0,0 +1,3 @@ +/** Browser-safe collaboration client entry point without server-side SDK dependencies. */ +export * from './lib/collaboration/client' +export type * from './lib/collaboration/types' diff --git a/packages/plugin-sdk/src/lib/agent/middleware/capabilities/assistant-task.ts b/packages/plugin-sdk/src/lib/agent/middleware/capabilities/assistant-task.ts index 4a6fd43adf..c989f4b7fb 100644 --- a/packages/plugin-sdk/src/lib/agent/middleware/capabilities/assistant-task.ts +++ b/packages/plugin-sdk/src/lib/agent/middleware/capabilities/assistant-task.ts @@ -25,6 +25,7 @@ export type AgentMiddlewareAssistantTaskInput = { xpertId: string agentKey?: string conversationId?: string | null + executionId?: string | null projectId?: string | null taskId?: string clientMessageId?: string diff --git a/packages/server-ai/src/shared/agent/middleware-runtime.service.spec.ts b/packages/server-ai/src/shared/agent/middleware-runtime.service.spec.ts index 32a4c16754..a52962f7b0 100644 --- a/packages/server-ai/src/shared/agent/middleware-runtime.service.spec.ts +++ b/packages/server-ai/src/shared/agent/middleware-runtime.service.spec.ts @@ -82,6 +82,7 @@ import { CopilotTokenRecordCommand } from '../../copilot-user/commands/token-rec import { ExceedingLimitException } from '../../core/errors' import { CopilotGetOneQuery } from '../../copilot/queries/get-one.query' import { GetChatConversationQuery } from '../../chat-conversation/queries/conversation-get.query' +import { ChatConversationUpsertCommand } from '../../chat-conversation/commands/upsert.command' import { CreateWorkspaceFileAssetCommand, GetFileAssetQuery } from '../../file-understanding' import { CreateKnowledgebaseDocumentsCommand, @@ -95,6 +96,7 @@ import { KnowledgeSearchQuery, ListWorkspaceKnowledgebasesQuery } from '../../kn import { XpertAgentExecutionUpsertCommand } from '../../xpert-agent-execution/commands/upsert.command' import { XpertAgentExecutionOneQuery } from '../../xpert-agent-execution/queries/get-one.query' import { XpertChatCommand } from '../../xpert/commands/chat.command' +import { CollaborationService } from '../../collaboration' import { WorkspaceFilesRuntimeCapabilityService } from '../runtime/workspace-files-runtime-capability.service' import { AgentMiddlewareRuntimeService } from './middleware-runtime.service' @@ -105,6 +107,7 @@ describe('AgentMiddlewareRuntimeService', () => { let volumeRoot: string let workspaceFiles: WorkspaceFilesRuntimeCapabilityService let artifacts: { createScopedApi: jest.Mock } + let collaboration: CollaborationService let service: AgentMiddlewareRuntimeService beforeEach(() => { @@ -136,6 +139,7 @@ describe('AgentMiddlewareRuntimeService', () => { defaults })) } + collaboration = new CollaborationService(null!, null!, null!) service = new AgentMiddlewareRuntimeService( commandBus as any, queryBus as any, @@ -146,7 +150,8 @@ describe('AgentMiddlewareRuntimeService', () => { getRuntimeConnector: jest.fn().mockResolvedValue(undefined) } as any, workspaceFiles, - artifacts as any + artifacts as any, + collaboration ) jest.spyOn(RequestContext, 'currentTenantId').mockReturnValue('tenant-1') @@ -1149,6 +1154,15 @@ describe('AgentMiddlewareRuntimeService', () => { it('starts an assistant task through the runtime facade', async () => { commandBus.execute.mockImplementation(async (command: unknown) => { + if (command instanceof ChatConversationUpsertCommand) { + return { + ...command.entity, + threadId: 'thread-1' + } + } + if (command instanceof XpertAgentExecutionUpsertCommand) { + return command.execution + } if (command instanceof XpertChatCommand) { return of({ data: { event: 'done' } } as MessageEvent) } @@ -1160,6 +1174,8 @@ describe('AgentMiddlewareRuntimeService', () => { xpertId: 'assistant-1', agentKey: 'agent-main', taskId: 'task-1', + conversationId: 'conversation-1', + executionId: 'execution-1', prompt: '重新解析合同', files: [ { @@ -1174,11 +1190,18 @@ describe('AgentMiddlewareRuntimeService', () => { } }) - expect(result).toEqual(expect.objectContaining({ status: 'running', taskId: 'task-1' })) - const command = commandBus.execute.mock.calls[0][0] as XpertChatCommand + expect(result).toEqual({ + status: 'running', + taskId: 'task-1', + conversationId: 'conversation-1', + threadId: 'thread-1', + executionId: 'execution-1' + }) + const command = commandBus.execute.mock.calls[2][0] as XpertChatCommand expect(command.request).toEqual( expect.objectContaining({ action: 'send', + conversationId: 'conversation-1', message: expect.objectContaining({ input: expect.objectContaining({ input: '重新解析合同', @@ -1198,11 +1221,82 @@ describe('AgentMiddlewareRuntimeService', () => { xpertId: 'assistant-1', from: 'job', taskId: 'task-1', - context: { source: 'test' } + context: { source: 'test' }, + execution: { id: 'execution-1' }, + streamPersistence: { + transport: 'redis-stream', + threadId: 'thread-1', + runId: 'execution-1' + } + }) + ) + const conversationCommand = commandBus.execute.mock.calls[0][0] as ChatConversationUpsertCommand + expect(conversationCommand.entity).toEqual( + expect.objectContaining({ + id: 'conversation-1', + createdById: 'user-1', + status: 'busy', + taskId: 'task-1', + xpertId: 'assistant-1', + from: 'job' + }) + ) + const executionCommand = commandBus.execute.mock.calls[1][0] as XpertAgentExecutionUpsertCommand + expect(executionCommand.execution).toEqual( + expect.objectContaining({ + id: 'execution-1', + xpertId: 'assistant-1', + agentKey: 'agent-main', + status: XpertAgentExecutionStatusEnum.RUNNING, + threadId: 'thread-1' }) ) }) + it('does not write a generated assistant task id into chat conversation taskId', async () => { + commandBus.execute.mockImplementation(async (command: unknown) => { + if (command instanceof ChatConversationUpsertCommand) { + return { + ...command.entity, + threadId: 'thread-generated' + } + } + if (command instanceof XpertAgentExecutionUpsertCommand) { + return command.execution + } + if (command instanceof XpertChatCommand) { + return of({ data: { event: 'done' } } as MessageEvent) + } + + throw new Error(`Unexpected command: ${command?.constructor?.name}`) + }) + + const result = await service.api.capabilities?.require(AssistantTaskRuntimeCapability).startTask({ + xpertId: 'assistant-1', + prompt: '处理图纸抽取任务' + }) + + expect(result).toEqual(expect.objectContaining({ status: 'running', taskId: expect.any(String) })) + const conversationCommand = commandBus.execute.mock.calls[0][0] as ChatConversationUpsertCommand + expect(conversationCommand.entity).not.toHaveProperty('taskId') + const command = commandBus.execute.mock.calls[2][0] as XpertChatCommand + expect(command.request).toEqual( + expect.objectContaining({ + message: expect.objectContaining({ + clientMessageId: `assistant-task:${result?.taskId}`, + input: expect.objectContaining({}) + }) + }) + ) + expect(command.options).toEqual( + expect.objectContaining({ + xpertId: 'assistant-1', + from: 'job' + }) + ) + expect(command.options).not.toHaveProperty('taskId') + }) + it('checks assistant task status from the chat conversation thread', async () => { queryBus.execute.mockImplementation(async (query: unknown) => { if (query instanceof GetChatConversationQuery) { @@ -1235,6 +1329,42 @@ describe('AgentMiddlewareRuntimeService', () => { xpertId: 'assistant-1' }) }) + + it('prefers the persisted execution status over the conversation status', async () => { + queryBus.execute.mockImplementation(async (query: unknown) => { + if (query instanceof XpertAgentExecutionOneQuery) { + return { + id: 'execution-1', + threadId: 'thread-1', + status: XpertAgentExecutionStatusEnum.ERROR, + error: 'safe failure summary' + } + } + if (query instanceof GetChatConversationQuery) { + return { + id: 'conversation-1', + threadId: 'thread-1', + status: 'idle' + } + } + + throw new Error(`Unexpected query: ${query?.constructor?.name}`) + }) + + const result = await service.api.capabilities?.require(AssistantTaskRuntimeCapability).getTaskStatus?.({ + executionId: 'execution-1', + conversationId: 'conversation-1' + }) + + expect(result).toEqual({ + status: 'failed', + taskId: undefined, + executionId: 'execution-1', + conversationId: 'conversation-1', + threadId: 'thread-1', + errorMessage: 'safe failure summary' + }) + }) }) function createTestVolumeHandle(scope: Record, root: string) { diff --git a/packages/server-ai/src/shared/agent/middleware-runtime.service.ts b/packages/server-ai/src/shared/agent/middleware-runtime.service.ts index fce01b0cd1..c49a5ed93c 100644 --- a/packages/server-ai/src/shared/agent/middleware-runtime.service.ts +++ b/packages/server-ai/src/shared/agent/middleware-runtime.service.ts @@ -8,6 +8,7 @@ import { IXpertAgentExecution, TChatConversationStatus, TChatRequest, + XpertAgentExecutionStatusEnum, mapTranslationLanguage } from '@xpert-ai/contracts' import { omit } from '@xpert-ai/server-common' @@ -82,8 +83,11 @@ import { } from '../../knowledgebase/commands' import { KnowledgeSearchQuery, ListWorkspaceKnowledgebasesQuery } from '../../knowledgebase/queries' import { GetChatConversationQuery } from '../../chat-conversation/queries/conversation-get.query' +import { ChatConversationUpsertCommand } from '../../chat-conversation/commands/upsert.command' import { FileAsset, GetFileAssetQuery } from '../../file-understanding' import { XpertChatCommand } from '../../xpert/commands/chat.command' +import { XpertAgentExecutionUpsertCommand } from '../../xpert-agent-execution/commands/upsert.command' +import { XpertAgentExecutionOneQuery } from '../../xpert-agent-execution/queries/get-one.query' import { ConnectorService } from '../../connector/connector.service' import { ArtifactsService } from '../../artifacts' import { CollaborationService } from '../../collaboration' @@ -381,18 +385,21 @@ export class AgentMiddlewareRuntimeService { async getAssistantTaskStatus( input: AgentMiddlewareAssistantTaskStatusInput ): Promise { + const execution = await this.findAssistantTaskExecution(input) const conversation = await this.findAssistantTaskConversation(input) - if (!conversation) { + if (!execution && !conversation) { return null } return { - status: mapConversationStatusToTaskStatus(conversation.status), + status: execution + ? mapExecutionStatusToTaskStatus(execution.status) + : mapConversationStatusToTaskStatus(conversation?.status), taskId: normalizeOptionalString(input.taskId), - executionId: normalizeOptionalString(input.executionId), - conversationId: conversation.id, - threadId: conversation.threadId, - errorMessage: conversation.error + executionId: execution?.id ?? normalizeOptionalString(input.executionId), + conversationId: conversation?.id ?? normalizeOptionalString(input.conversationId), + threadId: execution?.threadId ?? conversation?.threadId ?? normalizeOptionalString(input.threadId), + errorMessage: execution?.error ?? conversation?.error } } @@ -406,12 +413,40 @@ export class AgentMiddlewareRuntimeService { throw new Error('prompt is required to start an assistant task') } - const taskId = normalizeOptionalString(input.taskId) ?? randomUUID() + const requestedTaskId = normalizeOptionalString(input.taskId) + const taskId = requestedTaskId ?? randomUUID() + const conversationId = normalizeOptionalString(input.conversationId) ?? randomUUID() + const executionId = normalizeOptionalString(input.executionId) ?? randomUUID() + const conversation = await this.commandBus.execute( + new ChatConversationUpsertCommand({ + id: conversationId, + createdById: RequestContext.currentUserId(), + status: 'busy', + xpertId, + from: 'job', + options: { + parameters: { + input: prompt + } + }, + ...(requestedTaskId ? { taskId: requestedTaskId } : {}) + }) + ) + const execution = await this.commandBus.execute( + new XpertAgentExecutionUpsertCommand({ + id: executionId, + xpertId, + agentKey: normalizeOptionalString(input.agentKey), + status: XpertAgentExecutionStatusEnum.RUNNING, + threadId: conversation.threadId, + metadata: { + from: 'job' + } + }) + ) const request: TChatRequest = { action: 'send', - ...(normalizeOptionalString(input.conversationId) - ? { conversationId: normalizeOptionalString(input.conversationId) } - : {}), + conversationId: conversation.id, ...(normalizeOptionalString(input.projectId) ? { projectId: normalizeOptionalString(input.projectId) } : {}), @@ -428,21 +463,32 @@ export class AgentMiddlewareRuntimeService { new XpertChatCommand(request, { xpertId, from: 'job', - taskId, + ...(requestedTaskId ? { taskId: requestedTaskId } : {}), projectId: normalizeOptionalString(input.projectId) ?? undefined, - context: input.context + context: input.context, + execution: { id: execution.id }, + streamPersistence: { + transport: 'redis-stream', + threadId: conversation.threadId, + runId: execution.id + } }) ) stream.subscribe({ - error: (error) => this.#logger.error(error), + error: (error) => + this.#logger.error( + `Assistant task stream failed (${error instanceof Error ? error.name : 'UnknownError'})` + ), complete: () => undefined }) return { status: 'running', taskId, - conversationId: normalizeOptionalString(input.conversationId) + conversationId: conversation.id, + threadId: conversation.threadId, + executionId: execution.id } } @@ -577,6 +623,26 @@ export class AgentMiddlewareRuntimeService { return null } } + + private async findAssistantTaskExecution( + input: AgentMiddlewareAssistantTaskStatusInput + ): Promise { + const executionId = normalizeOptionalString(input.executionId) + if (!executionId) { + return null + } + + try { + return await this.queryBus.execute( + new XpertAgentExecutionOneQuery(executionId) + ) + } catch (error) { + this.#logger.debug( + `Assistant task execution was not found: ${error instanceof Error ? error.message : String(error)}` + ) + return null + } + } } function normalizeOptionalString(value: unknown) { @@ -612,6 +678,26 @@ function mapConversationStatusToTaskStatus( } } +function mapExecutionStatusToTaskStatus( + status: XpertAgentExecutionStatusEnum | undefined +): AgentMiddlewareAssistantTaskStatus { + switch (status) { + case XpertAgentExecutionStatusEnum.PENDING: + return 'queued' + case XpertAgentExecutionStatusEnum.RUNNING: + return 'running' + case XpertAgentExecutionStatusEnum.SUCCESS: + return 'succeeded' + case XpertAgentExecutionStatusEnum.INTERRUPTED: + return 'interrupted' + case XpertAgentExecutionStatusEnum.ERROR: + case XpertAgentExecutionStatusEnum.TIMEOUT: + return 'failed' + default: + return 'unknown' + } +} + function normalizeTaskFiles(files: AgentMiddlewareAssistantTaskFile[] | undefined) { if (!Array.isArray(files)) { return [] diff --git a/packages/server-ai/src/tracing/application-tracing.spec.ts b/packages/server-ai/src/tracing/application-tracing.spec.ts index 52b0e10df7..a5be6ef1fc 100644 --- a/packages/server-ai/src/tracing/application-tracing.spec.ts +++ b/packages/server-ai/src/tracing/application-tracing.spec.ts @@ -1,3 +1,12 @@ +import { + RequestContext as PluginRequestContext, + runWithRequestContext as runWithPluginRequestContext +} from '@xpert-ai/plugin-sdk' +import { + RequestContext as LegacyRequestContext, + runWithRequestContext as runWithLegacyRequestContext +} from '@xpert-ai/server-core' +import { AsyncLocalStorage } from 'node:async_hooks' import { Observable } from 'rxjs' import { ApplicationTracing, TracingDriver, TracingSpan } from './application-tracing' @@ -29,6 +38,7 @@ class FakeSpan implements TracingSpan { class FakeTracingDriver implements TracingDriver { spans: FakeSpan[] = [] + private readonly spanContext = new AsyncLocalStorage() constructor(private readonly enabledValue: boolean) {} @@ -42,8 +52,8 @@ class FakeTracingDriver implements TracingDriver { return span } - withSpan(_span: TracingSpan, handler: () => T): T { - return handler() + withSpan(span: TracingSpan, handler: () => T): T { + return this.spanContext.run(span, handler) } } @@ -75,6 +85,50 @@ describe('ApplicationTracing', () => { expect(driver.spans[0].ended).toBe(1) }) + it.each([false, true])( + 'preserves plugin and legacy request contexts through traced async work when tracing enabled=%s', + async (tracingEnabled) => { + const driver = new FakeTracingDriver(tracingEnabled) + const tracing = new ApplicationTracing(driver) + const user: NonNullable[0]['user']> = { + id: 'user-1', + tenantId: 'tenant-1' + } + const headers = { + 'tenant-id': 'tenant-1', + 'organization-id': 'organization-1', + 'x-scope-level': 'organization' + } + + await new Promise((resolve, reject) => { + runWithPluginRequestContext({ user, headers }, {}, () => { + runWithLegacyRequestContext({ user, headers }, () => { + tracing + .traceAsync('conversation.upsert', { operation: 'save' }, async () => { + assertRequestContexts() + await Promise.resolve() + assertRequestContexts() + await new Promise((resume) => setImmediate(resume)) + assertRequestContexts() + }) + .then(resolve, reject) + }) + }) + }) + + expect(driver.spans).toHaveLength(tracingEnabled ? 1 : 0) + + function assertRequestContexts() { + expect(PluginRequestContext.currentTenantId()).toBe('tenant-1') + expect(PluginRequestContext.getOrganizationId()).toBe('organization-1') + expect(PluginRequestContext.currentUserId()).toBe('user-1') + expect(LegacyRequestContext.currentTenantId()).toBe('tenant-1') + expect(LegacyRequestContext.getOrganizationId()).toBe('organization-1') + expect(LegacyRequestContext.currentUserId()).toBe('user-1') + } + } + ) + it('ends observable spans once on normal completion', async () => { const driver = new FakeTracingDriver(true) const tracing = new ApplicationTracing(driver) diff --git a/packages/server-ai/src/xpert-agent-execution/commands/handlers/upsert.handler.spec.ts b/packages/server-ai/src/xpert-agent-execution/commands/handlers/upsert.handler.spec.ts new file mode 100644 index 0000000000..ffb62f7032 --- /dev/null +++ b/packages/server-ai/src/xpert-agent-execution/commands/handlers/upsert.handler.spec.ts @@ -0,0 +1,48 @@ +import { XpertAgentExecutionStatusEnum } from '@xpert-ai/contracts' +import { XpertAgentExecutionUpsertCommand } from '../upsert.command' +import { XpertAgentExecutionUpsertHandler } from './upsert.handler' + +describe('XpertAgentExecutionUpsertHandler', () => { + it('creates a caller-specified execution id when it does not exist', async () => { + const service = { + findOneOrFailByIdString: jest.fn(async () => ({ success: false })), + create: jest.fn(async (entity) => entity), + update: jest.fn(), + findOne: jest.fn() + } + const handler = new XpertAgentExecutionUpsertHandler(service as never, {} as never, {} as never) + + const result = await handler.execute( + new XpertAgentExecutionUpsertCommand({ + id: 'execution-1', + threadId: 'thread-1', + status: XpertAgentExecutionStatusEnum.RUNNING + }) + ) + + expect(service.create).toHaveBeenCalledWith( + expect.objectContaining({ id: 'execution-1', threadId: 'thread-1' }) + ) + expect(result.id).toBe('execution-1') + }) + + it('updates the same execution id on a retry', async () => { + const service = { + findOneOrFailByIdString: jest.fn(async () => ({ success: true, record: { id: 'execution-1' } })), + create: jest.fn(), + update: jest.fn(async () => undefined), + findOne: jest.fn(async () => ({ id: 'execution-1', status: XpertAgentExecutionStatusEnum.RUNNING })) + } + const handler = new XpertAgentExecutionUpsertHandler(service as never, {} as never, {} as never) + + await handler.execute( + new XpertAgentExecutionUpsertCommand({ + id: 'execution-1', + status: XpertAgentExecutionStatusEnum.RUNNING + }) + ) + + expect(service.update).toHaveBeenCalledWith('execution-1', expect.objectContaining({ id: 'execution-1' })) + expect(service.create).not.toHaveBeenCalled() + }) +}) diff --git a/packages/server-ai/src/xpert-agent-execution/commands/handlers/upsert.handler.ts b/packages/server-ai/src/xpert-agent-execution/commands/handlers/upsert.handler.ts index 52ece1d342..3227c684cd 100644 --- a/packages/server-ai/src/xpert-agent-execution/commands/handlers/upsert.handler.ts +++ b/packages/server-ai/src/xpert-agent-execution/commands/handlers/upsert.handler.ts @@ -6,20 +6,23 @@ import { XpertAgentExecutionUpsertCommand } from '../upsert.command' @CommandHandler(XpertAgentExecutionUpsertCommand) export class XpertAgentExecutionUpsertHandler implements ICommandHandler { - readonly #logger = new Logger(XpertAgentExecutionUpsertHandler.name) + readonly #logger = new Logger(XpertAgentExecutionUpsertHandler.name) - constructor( - private readonly executionService: XpertAgentExecutionService, - private readonly commandBus: CommandBus, - private readonly queryBus: QueryBus - ) {} + constructor( + private readonly executionService: XpertAgentExecutionService, + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus + ) {} - public async execute(command: XpertAgentExecutionUpsertCommand): Promise { - const entity = command.execution - if (entity.id) { - await this.executionService.update(entity.id, entity) - return await this.executionService.findOne(entity.id) - } - return await this.executionService.create(entity) - } + public async execute(command: XpertAgentExecutionUpsertCommand): Promise { + const entity = command.execution + if (entity.id) { + const existing = await this.executionService.findOneOrFailByIdString(entity.id) + if (existing.success) { + await this.executionService.update(entity.id, entity) + return await this.executionService.findOne(entity.id) + } + } + return await this.executionService.create(entity) + } } diff --git a/packages/shadcn-ui/src/components/ui/alert-dialog.tsx b/packages/shadcn-ui/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000000..f5c0682d38 --- /dev/null +++ b/packages/shadcn-ui/src/components/ui/alert-dialog.tsx @@ -0,0 +1,76 @@ +import * as React from 'react' +import { AlertDialog as AlertDialogPrimitive } from 'radix-ui' +import { cn } from '@/lib/utils' +import { buttonVariants } from '@/components/ui/button' + +const AlertDialog = AlertDialogPrimitive.Root +const AlertDialogTrigger = AlertDialogPrimitive.Trigger +const AlertDialogPortal = AlertDialogPrimitive.Portal + +function AlertDialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ className, ...props }: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function AlertDialogTitle({ className, ...props }: React.ComponentProps) { + return +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogAction({ className, ...props }: React.ComponentProps) { + return +} + +function AlertDialogCancel({ className, ...props }: React.ComponentProps) { + return +} + +export { + AlertDialog, + AlertDialogTrigger, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel +} diff --git a/packages/shadcn-ui/src/components/ui/label.tsx b/packages/shadcn-ui/src/components/ui/label.tsx new file mode 100644 index 0000000000..34f5bca5a1 --- /dev/null +++ b/packages/shadcn-ui/src/components/ui/label.tsx @@ -0,0 +1,18 @@ +import * as React from 'react' +import { Label as LabelPrimitive } from 'radix-ui' +import { cn } from '@/lib/utils' + +function Label({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/packages/shadcn-ui/src/components/ui/sheet.tsx b/packages/shadcn-ui/src/components/ui/sheet.tsx new file mode 100644 index 0000000000..c461de76c8 --- /dev/null +++ b/packages/shadcn-ui/src/components/ui/sheet.tsx @@ -0,0 +1,93 @@ +import * as React from 'react' +import { XIcon } from 'lucide-react' +import { Dialog as SheetPrimitive } from 'radix-ui' +import { cn } from '@/lib/utils' + +const Sheet = SheetPrimitive.Root +const SheetTrigger = SheetPrimitive.Trigger +const SheetClose = SheetPrimitive.Close +const SheetPortal = SheetPrimitive.Portal + +function SheetOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = 'right', + ...props +}: React.ComponentProps & { side?: 'top' | 'right' | 'bottom' | 'left' }) { + return ( + + + + {children} + + + Close + + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription } diff --git a/packages/shadcn-ui/src/components/ui/skeleton.tsx b/packages/shadcn-ui/src/components/ui/skeleton.tsx new file mode 100644 index 0000000000..a93c1ca826 --- /dev/null +++ b/packages/shadcn-ui/src/components/ui/skeleton.tsx @@ -0,0 +1,8 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +export { Skeleton } diff --git a/packages/shadcn-ui/src/components/ui/switch.tsx b/packages/shadcn-ui/src/components/ui/switch.tsx new file mode 100644 index 0000000000..c563c88490 --- /dev/null +++ b/packages/shadcn-ui/src/components/ui/switch.tsx @@ -0,0 +1,23 @@ +import * as React from 'react' +import { Switch as SwitchPrimitive } from 'radix-ui' +import { cn } from '@/lib/utils' + +function Switch({ className, ...props }: React.ComponentProps) { + return ( + + + + ) +} + +export { Switch } diff --git a/packages/shadcn-ui/src/components/ui/table.tsx b/packages/shadcn-ui/src/components/ui/table.tsx new file mode 100644 index 0000000000..befff06e52 --- /dev/null +++ b/packages/shadcn-ui/src/components/ui/table.tsx @@ -0,0 +1,57 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +function Table({ className, ...props }: React.ComponentProps<'table'>) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) { + return +} + +function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) { + return +} + +function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) { + return +} + +function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<'th'>) { + return ( +
+ ) +} + +function TableCell({ className, ...props }: React.ComponentProps<'td'>) { + return +} + +function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) { + return ( +
+ ) +} + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption } diff --git a/packages/shadcn-ui/src/index.ts b/packages/shadcn-ui/src/index.ts index 94703c33a5..efc2b9f77b 100644 --- a/packages/shadcn-ui/src/index.ts +++ b/packages/shadcn-ui/src/index.ts @@ -13,3 +13,9 @@ export * from './components/ui/separator' export * from './components/ui/tabs' export * from './components/ui/textarea' export * from './components/ui/tooltip' +export * from './components/ui/table' +export * from './components/ui/label' +export * from './components/ui/switch' +export * from './components/ui/skeleton' +export * from './components/ui/sheet' +export * from './components/ui/alert-dialog' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c357492ce8..6946c363c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -282,6 +282,9 @@ importers: passport-dingtalk2: specifier: ^2.1.1 version: 2.1.1 + pdfjs-dist: + specifier: 2.12.313 + version: 2.12.313 pg: specifier: ^8.7.3 version: 8.18.0 @@ -38694,7 +38697,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.3 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.18.1) + retry-axios: 2.6.0(axios@1.18.1(debug@4.4.3)) tough-cookie: 4.1.4 transitivePeerDependencies: - supports-color @@ -44957,7 +44960,7 @@ snapshots: ret@0.1.15: {} - retry-axios@2.6.0(axios@1.18.1): + retry-axios@2.6.0(axios@1.18.1(debug@4.4.3)): dependencies: axios: 1.18.1(debug@4.4.3)