diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 410e8a77..cb35760f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -30,7 +30,7 @@ "dependencies": { "@lucide/vue": "^1.41.0", "@nvisy/console": "*", - "@nvisy/sdk": "^0.48.0", + "@nvisy/sdk": "^0.49.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-deep-link": "^2", "@tauri-apps/plugin-http": "^2.6.0", diff --git a/apps/web/package.json b/apps/web/package.json index dee49372..21254b42 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,7 +19,7 @@ "dependencies": { "@nuxtjs/device": "^4.0.0", "@nvisy/console": "*", - "@nvisy/sdk": "^0.48.0", + "@nvisy/sdk": "^0.49.0", "jszip": "^3.10.1", "nuxt": "^4.5.2", "vue": "^3.5.42", diff --git a/package-lock.json b/package-lock.json index 5c35ccd5..af089257 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "dependencies": { "@lucide/vue": "^1.41.0", "@nvisy/console": "*", - "@nvisy/sdk": "^0.48.0", + "@nvisy/sdk": "^0.49.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-deep-link": "^2", "@tauri-apps/plugin-http": "^2.6.0", @@ -48,7 +48,7 @@ "dependencies": { "@nuxtjs/device": "^4.0.0", "@nvisy/console": "*", - "@nvisy/sdk": "^0.48.0", + "@nvisy/sdk": "^0.49.0", "jszip": "^3.10.1", "nuxt": "^4.5.2", "vue": "^3.5.42", @@ -3742,9 +3742,9 @@ "link": true }, "node_modules/@nvisy/sdk": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/@nvisy/sdk/-/sdk-0.48.0.tgz", - "integrity": "sha512-82WkJU3NcOq7vF1vqmrLk6wbhnjz/4M9syuY4DyVXSpbXHcB5L17/1g5cCa2Xny38Sys6g+HZjdwmSPgbkckCA==", + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@nvisy/sdk/-/sdk-0.49.0.tgz", + "integrity": "sha512-yLD7JiXSunJ5K3wPS35TRcHA2TEz3K2Cu4N15OfSGtyMmrokRxninYPxCdASHbYLPcnCaBBBQr/G4NnqsoWvKA==", "license": "MIT", "dependencies": { "openapi-fetch": "^0.17.0" @@ -19033,7 +19033,7 @@ "@lucide/vue": "^1.41.0", "@nuxtjs/color-mode": "^4.0.0", "@nuxtjs/i18n": "^10.6.0", - "@nvisy/sdk": "^0.48.0", + "@nvisy/sdk": "^0.49.0", "@pinia/colada": "^1.4.4", "@pinia/colada-nuxt": "^1.1.0", "@pinia/nuxt": "^1.0.2", diff --git a/packages/console/app/components/layout/AppChat.vue b/packages/console/app/components/layout/AppChat.vue new file mode 100644 index 00000000..c4142ea0 --- /dev/null +++ b/packages/console/app/components/layout/AppChat.vue @@ -0,0 +1,61 @@ + + + + !value && close()" + > + + + {{ t("chat.title") }} + {{ t("chat.description") }} + + + + + + + + + + diff --git a/packages/console/app/components/layout/AppHeader.vue b/packages/console/app/components/layout/AppHeader.vue index ee81f43f..5f6e6707 100644 --- a/packages/console/app/components/layout/AppHeader.vue +++ b/packages/console/app/components/layout/AppHeader.vue @@ -1,6 +1,8 @@ + + + + + + + + + + {{ currentSession?.title ?? t("chat.newChat") }} + + + + + + {{ t("chat.newChat") }} + + + + + + {{ s.title }} + + + + + + + + + + + + + + + + + + + + + + + {{ t("chat.needsProviderTitle") }} + + + {{ t("chat.needsProviderDescription") }} + + + + {{ t("chat.needsProviderCta") }} + + + + + + + + + + + {{ t("chat.emptyTitle") }} + + + {{ t("chat.emptyDescription") }} + + + + + + + + + + + + {{ errorMessage }} + + + + + diff --git a/packages/console/app/components/layout/chat/ChatInput.vue b/packages/console/app/components/layout/chat/ChatInput.vue new file mode 100644 index 00000000..64e3e022 --- /dev/null +++ b/packages/console/app/components/layout/chat/ChatInput.vue @@ -0,0 +1,54 @@ + + + + + + + + + + diff --git a/packages/console/app/components/layout/chat/ChatMessage.vue b/packages/console/app/components/layout/chat/ChatMessage.vue new file mode 100644 index 00000000..01085968 --- /dev/null +++ b/packages/console/app/components/layout/chat/ChatMessage.vue @@ -0,0 +1,104 @@ + + + + + + {{ message.content }} + + + + + + + + + + + + + + + {{ timestamp }} + + + + diff --git a/packages/console/app/components/layout/header/StudioFileTab.vue b/packages/console/app/components/layout/header/StudioFileTab.vue index 2168fc6a..f72d5c51 100644 --- a/packages/console/app/components/layout/header/StudioFileTab.vue +++ b/packages/console/app/components/layout/header/StudioFileTab.vue @@ -87,7 +87,11 @@ function onDragOver(event: DragEvent) { /> - {{ truncate(file.displayName, 20) }} + {{ + file.displayName + ? truncate(file.displayName, 20) + : t("studio.tabs.loading") + }} [ ]); const navObservability = computed(() => [ + { + title: t("sidebar.webhooks"), + url: wLink("/webhooks"), + icon: WebhookIcon, + isActive: false, + }, { title: t("sidebar.analytics"), url: wLink("/analytics"), diff --git a/packages/console/app/components/pages/analytics/AnalyticsAreaChart.vue b/packages/console/app/components/pages/analytics/AnalyticsAreaChart.vue index b12ef0da..b9c7aed8 100644 --- a/packages/console/app/components/pages/analytics/AnalyticsAreaChart.vue +++ b/packages/console/app/components/pages/analytics/AnalyticsAreaChart.vue @@ -12,12 +12,26 @@ import { } from "#console/components/ui/chart"; import { formatShortDate } from "#console/utils/date"; -const props = defineProps<{ spec: AreaChartSpec }>(); +const props = defineProps<{ spec: AreaChartSpec; compact?: boolean }>(); + +const { t } = useI18n(); type Row = Record; const series = computed(() => props.spec.series); +/** + * Whether the series carry any non-zero value. A window that is all zeros (a + * brand-new workspace, or a metric that hasn't fired yet) plots as a flat line + * pinned to the axis, which reads as broken rather than "quiet". In that case we + * show a quiet placeholder at the chart's height instead of an empty plot. + */ +const hasData = computed(() => + props.spec.data.some((row) => + series.value.some((s) => (row[s.key] as number) > 0), + ), +); + /** ChartConfig keyed by series key (plus an optional header label entry). */ const chartConfig = computed(() => { const config: ChartConfig = {}; @@ -83,7 +97,18 @@ const crosshair = computed(() => - + + {{ t("analytics.trends.noData") }} + + import("./AnalyticsAreaChart.vue"), ); -defineProps<{ spec: AreaChartSpec }>(); +/** `compact` shrinks the chart height for the smaller supporting cards. */ +defineProps<{ spec: AreaChartSpec; compact?: boolean }>(); - + - + diff --git a/packages/console/app/components/pages/analytics/DetectionActivityGrid.vue b/packages/console/app/components/pages/analytics/DetectionActivityGrid.vue index 7c027d51..2fcf6803 100644 --- a/packages/console/app/components/pages/analytics/DetectionActivityGrid.vue +++ b/packages/console/app/components/pages/analytics/DetectionActivityGrid.vue @@ -148,12 +148,9 @@ const weekdayLabels = computed(() => { - - - {{ t("analytics.activity.title") }} - + + {{ totalDetections.toLocaleString() diff --git a/packages/console/app/components/pages/integrations/ConfigureProviderDialog.vue b/packages/console/app/components/pages/integrations/ConfigureProviderDialog.vue new file mode 100644 index 00000000..68aebe0b --- /dev/null +++ b/packages/console/app/components/pages/integrations/ConfigureProviderDialog.vue @@ -0,0 +1,231 @@ + + + + + + + {{ t("providers.dialogs.configure.nameLabel") }} + + + + + + + {{ t("providers.dialogs.configure.updateCredentials") }} + + + + {{ t("providers.dialogs.configure.credentialsHint") }} + + + + + {{ t("connections.dialogs.llm.apiKey") }} + + + + + + {{ t("connections.dialogs.llm.baseUrl") }} + + + + + + {{ t("connections.dialogs.llm.defaultModel") }} + + + + + + + + + + + + {{ + isActive + ? t("connections.table.status.active") + : t("connections.table.status.paused") + }} + + + + + {{ t("providers.dialogs.configure.cancel") }} + + + + {{ t("providers.dialogs.configure.save") }} + + + + + + diff --git a/packages/console/app/components/pages/integrations/ConnectLlmDialog.vue b/packages/console/app/components/pages/integrations/ConnectLlmDialog.vue index 49a9604d..465ada04 100644 --- a/packages/console/app/components/pages/integrations/ConnectLlmDialog.vue +++ b/packages/console/app/components/pages/integrations/ConnectLlmDialog.vue @@ -1,5 +1,5 @@ diff --git a/packages/console/app/components/pages/integrations/ConnectionRunsPanel.vue b/packages/console/app/components/pages/integrations/ConnectionRunsPanel.vue new file mode 100644 index 00000000..c64270eb --- /dev/null +++ b/packages/console/app/components/pages/integrations/ConnectionRunsPanel.vue @@ -0,0 +1,293 @@ + + + + + + + + {{ t("connections.runs.runsFound", { count: syncs.length }) }} + + + + + + + + + + + {{ t("connections.runs.allStatus") }} + + + {{ t(`connections.runs.syncStatus.${s}`) }} + + + + + + + + + + + + + + + + + + + + + + {{ connectionName(row) }} + + + + + + + + + + {{ t(`connections.runs.syncStatus.${row.status}`) }} + + + + {{ row.errorMessage }} + + + + + + + + + + + + + diff --git a/packages/console/app/components/pages/integrations/ConnectionsTable.vue b/packages/console/app/components/pages/integrations/ConnectionsTable.vue index 56f66e9f..83d3c565 100644 --- a/packages/console/app/components/pages/integrations/ConnectionsTable.vue +++ b/packages/console/app/components/pages/integrations/ConnectionsTable.vue @@ -81,8 +81,8 @@ const columns = computed[]>(() => [ width: "160px", cell: (c) => ({ type: "text", - value: c.sync?.lastSynced - ? relativeTime(c.sync.lastSynced) + value: c.lastSyncedAt + ? relativeTime(c.lastSyncedAt) : t("common.time.never"), muted: true, }), @@ -94,7 +94,7 @@ function rowActions(connection: Connection): RowAction[] { // picker nor a redacted-export target). Import needs an active connection with // a wired picker (the shared eligibility rule); export applies to any file // service. - const fileService = connection.providerType === "file_service"; + const fileService = connection.connectionType === "file_service"; const importAction: RowAction[] = isImportableConnection(connection, { dropboxAppKey, }) diff --git a/packages/console/app/components/pages/integrations/ExportToConnectionDialog.vue b/packages/console/app/components/pages/integrations/ExportToConnectionDialog.vue index 151c6f12..caedb670 100644 --- a/packages/console/app/components/pages/integrations/ExportToConnectionDialog.vue +++ b/packages/console/app/components/pages/integrations/ExportToConnectionDialog.vue @@ -41,7 +41,7 @@ const emit = defineEmits<{ // can't. Inactive connections are excluded - a paused connection won't sync. const targets = computed(() => props.connections.filter( - (c) => c.providerType === "file_service" && c.isActive, + (c) => c.connectionType === "file_service" && c.isActive, ), ); diff --git a/packages/console/app/components/pages/integrations/ProvidersTable.vue b/packages/console/app/components/pages/integrations/ProvidersTable.vue new file mode 100644 index 00000000..bdc1a61c --- /dev/null +++ b/packages/console/app/components/pages/integrations/ProvidersTable.vue @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + {{ row.displayName }} + + + {{ providerLabel(row.provider) }} + + + + + + + + + + + + + diff --git a/packages/console/app/components/pages/integrations/index.ts b/packages/console/app/components/pages/integrations/index.ts index 0ae11891..00c21a52 100644 --- a/packages/console/app/components/pages/integrations/index.ts +++ b/packages/console/app/components/pages/integrations/index.ts @@ -7,6 +7,11 @@ export { default as ConnectLlmDialog } from "./ConnectLlmDialog.vue"; export { default as ConnectFileServiceDialog } from "./ConnectFileServiceDialog.vue"; export { default as ExportToConnectionDialog } from "./ExportToConnectionDialog.vue"; export { default as ImportFromConnectionDialog } from "./ImportFromConnectionDialog.vue"; +export { default as ConnectionRunsPanel } from "./ConnectionRunsPanel.vue"; + +// Providers (inference) +export { default as ProvidersTable } from "./ProvidersTable.vue"; +export { default as ConfigureProviderDialog } from "./ConfigureProviderDialog.vue"; // Webhooks export { default as WebhooksTable } from "./WebhooksTable.vue"; diff --git a/packages/console/app/components/pages/studio/chat/ChatMessage.vue b/packages/console/app/components/pages/studio/chat/ChatMessage.vue deleted file mode 100644 index 8c29f8d7..00000000 --- a/packages/console/app/components/pages/studio/chat/ChatMessage.vue +++ /dev/null @@ -1,165 +0,0 @@ - - - - - - - {{ message.content }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ message.timestamp.toLocaleTimeString(locale) }} - - - - diff --git a/packages/console/app/components/pages/studio/chat/MessageInput.vue b/packages/console/app/components/pages/studio/chat/MessageInput.vue deleted file mode 100644 index 7b098a73..00000000 --- a/packages/console/app/components/pages/studio/chat/MessageInput.vue +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - - - - {{ t("studio.chat.attach") }} - - - - {{ t("studio.chat.upload") }} - - - - {{ t("studio.chat.generate") }} - - - - {{ t("studio.chat.summarize") }} - - - - - - - - - - - diff --git a/packages/console/app/components/pages/studio/chat/StudioChatPanel.vue b/packages/console/app/components/pages/studio/chat/StudioChatPanel.vue deleted file mode 100644 index fbe64c11..00000000 --- a/packages/console/app/components/pages/studio/chat/StudioChatPanel.vue +++ /dev/null @@ -1,377 +0,0 @@ - - - - - - - - {{ message.content }} - - - - - - - - - - - - - - - - {{ - t("studio.chat.changes.title") - }} - ({{ layers.length }}) - - - - - {{ t("studio.chat.changes.rejectAll") }} - - {{ - getKbdKey("meta") - }} - {{ - getKbdKey("shift") - }} - R - - - - {{ t("studio.chat.changes.acceptAll") }} - - {{ - getKbdKey("meta") - }} - {{ - getKbdKey("shift") - }} - A - - - - - - - - - - - - - {{ layer.name }} - - {{ layer.fileName }} · Page {{ layer.page }} - - - - - {{ t("studio.chat.changes.reject") }} - - - {{ t("studio.chat.changes.accept") }} - - - - - - - - - - - {{ t("studio.chat.changes.empty") }} - - - - - - - - - - - - - - - {{ - getKbdKey("meta") - }} - {{ - getKbdKey("shift") - }} - {{ tool.shortcut }} - - - - - - - - - - - - - diff --git a/packages/console/app/components/pages/studio/chat/index.ts b/packages/console/app/components/pages/studio/chat/index.ts deleted file mode 100644 index 34e29bab..00000000 --- a/packages/console/app/components/pages/studio/chat/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as StudioChatPanel } from "./StudioChatPanel.vue"; -export { default as ChatMessage } from "./ChatMessage.vue"; -export { default as MessageInput } from "./MessageInput.vue"; diff --git a/packages/console/app/components/pages/studio/entities/StudioAuditPanel.vue b/packages/console/app/components/pages/studio/entities/StudioAuditPanel.vue index 927403bc..42736329 100644 --- a/packages/console/app/components/pages/studio/entities/StudioAuditPanel.vue +++ b/packages/console/app/components/pages/studio/entities/StudioAuditPanel.vue @@ -1,9 +1,7 @@ + + + + + + + + + + + {{ p.displayName }} + + + {{ t("studio.audit.noPipelines") }} + + + + + + + + + + + + + + + + {{ t("studio.audit.download") }} + + + + {{ t("studio.audit.export") }} + + + + + {{ + redactPhase === "redacting" + ? t("studio.audit.redacting") + : redactPhase === "failed" + ? t("studio.audit.retryRedaction") + : applyCount < count + ? t("studio.audit.applyCount", { n: applyCount, total: count }) + : t("studio.audit.apply") + }} + + + + diff --git a/packages/console/app/components/pages/studio/entities/StudioDetectionBar.vue b/packages/console/app/components/pages/studio/entities/StudioDetectionBar.vue index d6e30126..8d42747f 100644 --- a/packages/console/app/components/pages/studio/entities/StudioDetectionBar.vue +++ b/packages/console/app/components/pages/studio/entities/StudioDetectionBar.vue @@ -29,7 +29,7 @@ defineEmits<{ run: [] }>(); - + diff --git a/packages/console/app/components/pages/studio/entities/StudioRedactionFooter.vue b/packages/console/app/components/pages/studio/entities/StudioRedactionFooter.vue new file mode 100644 index 00000000..cafaf17f --- /dev/null +++ b/packages/console/app/components/pages/studio/entities/StudioRedactionFooter.vue @@ -0,0 +1,103 @@ + + + + + + + + {{ redactError }} + + + + + + + + {{ t("studio.audit.downloadRedacted") }} + + + + + + + {{ output.fileName }} + + + + + + + {{ + redactPhase === "redacting" + ? t("studio.audit.redacting") + : redactPhase === "failed" + ? t("studio.audit.retryRedaction") + : applyCount < count + ? t("studio.audit.applyCount", { n: applyCount, total: count }) + : t("studio.audit.apply") + }} + + + diff --git a/packages/console/app/components/pages/studio/entities/index.ts b/packages/console/app/components/pages/studio/entities/index.ts index 3291eff4..1ec37c7b 100644 --- a/packages/console/app/components/pages/studio/entities/index.ts +++ b/packages/console/app/components/pages/studio/entities/index.ts @@ -2,5 +2,7 @@ export { default as EntityDetailPopover } from "./EntityDetailPopover.vue"; export { default as EntityAuditModal } from "./EntityAuditModal.vue"; export { default as StudioAuditPanel } from "./StudioAuditPanel.vue"; export { default as StudioAuditTable } from "./StudioAuditTable.vue"; +export { default as StudioRedactionFooter } from "./StudioRedactionFooter.vue"; +export { default as StudioAuditToolbar } from "./StudioAuditToolbar.vue"; export { default as StudioDetectionBar } from "./StudioDetectionBar.vue"; // AddEntityPopover / AddEntityHint are composed by the preview views directly. diff --git a/packages/console/app/components/pages/studio/index.ts b/packages/console/app/components/pages/studio/index.ts index a757b390..a5be22a8 100644 --- a/packages/console/app/components/pages/studio/index.ts +++ b/packages/console/app/components/pages/studio/index.ts @@ -1,3 +1,2 @@ export * from "./preview"; export * from "./entities"; -export * from "./chat"; diff --git a/packages/console/app/components/ui/sidebar/SidebarInset.vue b/packages/console/app/components/ui/sidebar/SidebarInset.vue index 9c5ba78e..29f6cd5c 100644 --- a/packages/console/app/components/ui/sidebar/SidebarInset.vue +++ b/packages/console/app/components/ui/sidebar/SidebarInset.vue @@ -23,6 +23,8 @@ const props = defineProps<{ diff --git a/packages/console/app/components/ui/sidebar/SidebarProvider.vue b/packages/console/app/components/ui/sidebar/SidebarProvider.vue index f50dd86c..bc5cb53a 100644 --- a/packages/console/app/components/ui/sidebar/SidebarProvider.vue +++ b/packages/console/app/components/ui/sidebar/SidebarProvider.vue @@ -10,6 +10,7 @@ import { TooltipProvider } from "reka-ui"; import { computed, ref } from "vue"; import { cn } from "#console/utils/shadcn"; import { + CHAT_WIDTH, provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, @@ -86,6 +87,19 @@ provideSidebarContext({ setOpenMobile, toggleSidebar, }); + +// The chat rail is its own feature (see useChatPanel) — the provider only reads +// its open state to size the `[chat]` grid track, so the rail reflows the +// content card without owning the chat's state. On desktop only; on mobile the +// chat is an overlay sheet and takes no track. +const { isOpen: chatOpen } = useChatPanel(); +const chatVisible = computed(() => !isMobile.value && chatOpen.value); +const chatWidth = computed(() => (chatVisible.value ? CHAT_WIDTH : "0px")); +// The inset gap before the chat rail only exists when the rail is open, so a +// closed chat leaves no dead space on the content card's right edge. +const chatGap = computed(() => + chatVisible.value ? "var(--shell-inset)" : "0px", +); @@ -104,7 +118,11 @@ provideSidebarContext({ :data-state="state" :data-collapsible="state === 'collapsed' ? 'icon' : ''" :data-mobile="isMobile ? 'true' : undefined" - :style="{ '--sidebar-w': state === 'collapsed' ? SIDEBAR_WIDTH_ICON : SIDEBAR_WIDTH }" + :style="{ + '--sidebar-w': state === 'collapsed' ? SIDEBAR_WIDTH_ICON : SIDEBAR_WIDTH, + '--chat-w': chatWidth, + '--chat-gap': chatGap, + }" v-bind="$attrs" :class="cn(props.class)" > @@ -126,6 +144,13 @@ provideSidebarContext({ inherits: false; initial-value: 0px; } +/* The chat rail's track width, animated 0 ↔ open like the sidebar track (see the + chat-rail track below). Same registration rules apply. */ +@property --chat-w { + syntax: ""; + inherits: false; + initial-value: 0px; +} diff --git a/packages/console/app/components/ui/sidebar/utils.ts b/packages/console/app/components/ui/sidebar/utils.ts index 6a422547..a6fd995e 100644 --- a/packages/console/app/components/ui/sidebar/utils.ts +++ b/packages/console/app/components/ui/sidebar/utils.ts @@ -7,6 +7,8 @@ export const SIDEBAR_WIDTH = "14rem"; export const SIDEBAR_WIDTH_MOBILE = "18rem"; export const SIDEBAR_WIDTH_ICON = "3rem"; export const SIDEBAR_KEYBOARD_SHORTCUT = "b"; +/** Width of the global chat rail's grid track when open (see AppChat). */ +export const CHAT_WIDTH = "22rem"; export const [useSidebar, provideSidebarContext] = createContext<{ state: ComputedRef<"expanded" | "collapsed">; diff --git a/packages/console/app/composables/useApiTokens.ts b/packages/console/app/composables/useApiTokens.ts index 111b7230..98f23bbf 100644 --- a/packages/console/app/composables/useApiTokens.ts +++ b/packages/console/app/composables/useApiTokens.ts @@ -14,11 +14,12 @@ export function useApiTokens() { const tokensQuery = useQuery({ key: ["apiTokens"], - query: async () => { + query: () => { const client = $nvisyClient.value; if (!client) throw new Error("Not authenticated"); - const result = await client.apiTokens.listApiTokens(); - return result.items; + return fetchAllPages((after) => + client.apiTokens.listApiTokens({ after }), + ); }, enabled: () => isAuthenticated.value, }); diff --git a/packages/console/app/composables/useChat.ts b/packages/console/app/composables/useChat.ts new file mode 100644 index 00000000..e4143bdd --- /dev/null +++ b/packages/console/app/composables/useChat.ts @@ -0,0 +1,294 @@ +import type { Ref } from "vue"; +import { NvisyApiError } from "@nvisy/sdk"; +import type { ChatMessage, ChatSession } from "@nvisy/sdk/datatypes"; + +/** + * The workspace assistant chat, backed by the SDK's `client.chat.*` sessions and + * Server-Sent-Events message stream. + * + * State is a module-level singleton: there's one chat panel in the shell, so + * every trigger (the header toggle, the studio, a future launcher) drives the + * same sessions, current session, and message list. The assistant's reply + * streams token-by-token into the last message, which grows reactively as + * `ChatToken` deltas arrive. + * + * A workspace with no inference provider configured returns 409 on send; that's + * surfaced as `needsProvider` so the panel can prompt the user to connect one + * rather than showing an error. + */ + +// A message as the panel renders it: the SDK's fields plus the transient +// `streaming` flag on the assistant reply currently being received. +export interface ChatMessageView { + id: string; + role: "user" | "assistant"; + content: string; + createdAt: string; + parentId?: string; + streaming?: boolean; +} + +const sessions = ref([]); +const currentSessionId = ref(null); +const messages = ref([]); +const isLoadingSessions = ref(false); +const isLoadingMessages = ref(false); +const isStreaming = ref(false); +// Set when a send returns 409 — the workspace has no inference provider, so the +// assistant can't reply until one is connected. +const needsProvider = ref(false); +const error = ref(null); +let loadedWorkspace: string | null = null; +// Bumped on every workspace switch. A send captures the current value at start +// and abandons its stream/reconcile if the value has moved on — so a reply that +// was in flight when the user switched workspaces can't write into the new +// workspace's panel or leave `isStreaming` stuck on. +let generation = 0; + +// Reset all chat state when the workspace changes, so one workspace's +// conversations never bleed into another's. Registered once in a DETACHED effect +// scope (not the calling component's), so it lives for the app's lifetime rather +// than dying when the first `useChat` consumer unmounts. +let resetWatchBound = false; +function bindWorkspaceReset( + currentWorkspaceSlug: Ref, +) { + if (resetWatchBound) return; + resetWatchBound = true; + effectScope(true).run(() => { + watch(currentWorkspaceSlug, () => { + // Invalidate any in-flight send so its stream/reconcile can't touch the + // freshly-cleared state, and release the input. + generation++; + isStreaming.value = false; + loadedWorkspace = null; + sessions.value = []; + currentSessionId.value = null; + messages.value = []; + error.value = null; + needsProvider.value = false; + }); + }); +} + +/** Convert an SDK message into the view model. */ +function toView(message: ChatMessage): ChatMessageView { + return { + id: message.id, + role: message.role === "system" ? "assistant" : message.role, + content: message.content, + createdAt: message.createdAt, + parentId: message.parentId, + }; +} + +export function useChat() { + const { $nvisyClient } = useNuxtApp(); + const { currentWorkspaceSlug } = useWorkspaces(); + bindWorkspaceReset(currentWorkspaceSlug); + + function requireClient() { + const client = $nvisyClient.value; + if (!client) throw new Error("Not authenticated"); + return client; + } + + function requireWorkspace(): string { + const slug = currentWorkspaceSlug.value; + if (!slug) throw new Error("No active workspace"); + return slug; + } + + const currentSession = computed( + () => sessions.value.find((s) => s.id === currentSessionId.value) ?? null, + ); + + // The leaf to branch new messages from: the active session's current message, + // else the last message we have loaded. + const currentLeafId = computed( + () => currentSession.value?.currentMessageId ?? messages.value.at(-1)?.id, + ); + + // Load the workspace's sessions once per workspace (a switch reloads). Selects + // the most recently active session so the panel opens on a conversation. + async function loadSessions() { + const slug = requireWorkspace(); + if (loadedWorkspace === slug) return; + loadedWorkspace = slug; + isLoadingSessions.value = true; + error.value = null; + try { + const page = await requireClient().chat.listSessions(slug); + sessions.value = page.items; + if (page.items[0]) await selectSession(page.items[0].id); + } catch { + // A load failure leaves the panel empty; the next open retries. + loadedWorkspace = null; + } finally { + isLoadingSessions.value = false; + } + } + + // Switch to a session and load its message history in chronological order. + async function selectSession(sessionId: string) { + currentSessionId.value = sessionId; + messages.value = []; + isLoadingMessages.value = true; + try { + const history = await requireClient().chat.listMessages( + requireWorkspace(), + sessionId, + ); + messages.value = history.map(toView); + } catch { + messages.value = []; + } finally { + isLoadingMessages.value = false; + } + } + + // Start a fresh conversation: clears the current session so the next send + // creates one (titled from its first message). + function newSession() { + currentSessionId.value = null; + messages.value = []; + error.value = null; + needsProvider.value = false; + } + + async function deleteSession(sessionId: string) { + try { + await requireClient().chat.deleteSession(requireWorkspace(), sessionId); + } catch { + // The panel fires this without awaiting; surface a failure through the + // existing error banner rather than leaving an unhandled rejection. + error.value = "delete"; + return; + } + sessions.value = sessions.value.filter((s) => s.id !== sessionId); + if (currentSessionId.value === sessionId) newSession(); + } + + /** + * Send a message and stream the assistant's reply. Creates a session first + * when there isn't one, appends the user message and a live assistant message, + * then grows the assistant message as `ChatToken` deltas arrive. + */ + async function sendMessage(content: string) { + const text = content.trim(); + if (!text || isStreaming.value) return; + + const slug = requireWorkspace(); + // Capture the workspace generation: if it moves on mid-send (the user + // switched workspaces), this send's stream and reconcile must not write into + // the new workspace's freshly-reset state. + const sendGeneration = generation; + error.value = null; + needsProvider.value = false; + + // Ensure a session exists (seed its title from the first message). + if (!currentSessionId.value) { + try { + const session = await requireClient().chat.createSession(slug, { + title: text.slice(0, 80), + }); + sessions.value = [session, ...sessions.value]; + currentSessionId.value = session.id; + } catch { + error.value = "send"; + return; + } + } + const sessionId = currentSessionId.value; + if (!sessionId) return; + + const parentId = currentLeafId.value; + const now = new Date().toISOString(); + messages.value.push({ + id: `local-user-${crypto.randomUUID()}`, + role: "user", + content: text, + createdAt: now, + parentId, + }); + const assistant = reactive({ + id: `local-assistant-${crypto.randomUUID()}`, + role: "assistant", + content: "", + createdAt: new Date().toISOString(), + streaming: true, + }); + messages.value.push(assistant); + + isStreaming.value = true; + try { + for await (const token of requireClient().chat.streamMessage( + slug, + sessionId, + { content: text, ...(parentId ? { parentId } : {}) }, + )) { + // The workspace changed while streaming: stop writing tokens into the + // now-detached assistant bubble and abandon the rest of the send. + if (generation !== sendGeneration) return; + assistant.content += token.delta; + } + } catch (err) { + if (generation !== sendGeneration) return; + // 409: the workspace has no inference provider configured. + if (err instanceof NvisyApiError && err.statusCode === 409) { + needsProvider.value = true; + } else { + error.value = "stream"; + } + // Drop the empty/partial assistant bubble on failure. + if (!assistant.content) { + messages.value = messages.value.filter((m) => m.id !== assistant.id); + } + } finally { + // After a workspace switch the reset watcher already cleared isStreaming + // and the message list; don't reconcile the old session against the new + // workspace or stomp the new state. + if (generation === sendGeneration) { + assistant.streaming = false; + isStreaming.value = false; + // Reconcile ids/tree with the server (the optimistic ids are local), + // and refresh the session's updatedAt/currentMessageId for ordering. + await reconcile(sessionId); + } + } + } + + // Re-fetch the session's messages and list so local optimistic ids are + // replaced by the server's, keeping the message tree consistent. + async function reconcile(sessionId: string) { + try { + const [history, page] = await Promise.all([ + requireClient().chat.listMessages(requireWorkspace(), sessionId), + requireClient().chat.listSessions(requireWorkspace()), + ]); + if (currentSessionId.value === sessionId) { + messages.value = history.map(toView); + } + sessions.value = page.items; + } catch { + // Best-effort; the optimistic view still reflects the conversation. + } + } + + return { + sessions: readonly(sessions), + currentSession, + currentSessionId: readonly(currentSessionId), + messages: readonly(messages), + isLoadingSessions: readonly(isLoadingSessions), + isLoadingMessages: readonly(isLoadingMessages), + isStreaming: readonly(isStreaming), + needsProvider: readonly(needsProvider), + error: readonly(error), + loadSessions, + selectSession, + newSession, + deleteSession, + sendMessage, + }; +} diff --git a/packages/console/app/composables/useChatPanel.ts b/packages/console/app/composables/useChatPanel.ts new file mode 100644 index 00000000..47062cd7 --- /dev/null +++ b/packages/console/app/composables/useChatPanel.ts @@ -0,0 +1,43 @@ +/** + * Open state for the global chat rail, so any trigger in the shell (the header + * toggle, a keyboard shortcut) controls the single mounted `AppChat` instead of + * each caller poking a child ref. Independent of the left sidebar's state. + * + * A module-level singleton: one chat rail, one source of truth. The rail width + * animates from this via a CSS var on the shell (see `AppChat`). + */ +const isOpen = ref(false); + +/** Cmd/Ctrl+J toggles the chat rail (Cmd/Ctrl+B is the left sidebar). */ +const CHAT_SHORTCUT = "j"; +let shortcutBound = false; + +export function useChatPanel() { + function open() { + isOpen.value = true; + } + + function close() { + isOpen.value = false; + } + + function toggle() { + isOpen.value = !isOpen.value; + } + + // Bind the toggle shortcut once, on the client, for the app's lifetime. + if (import.meta.client && !shortcutBound) { + shortcutBound = true; + window.addEventListener("keydown", (event) => { + if ( + event.key.toLowerCase() === CHAT_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + toggle(); + } + }); + } + + return { isOpen: readonly(isOpen), open, close, toggle }; +} diff --git a/packages/console/app/composables/useConnectProvider.ts b/packages/console/app/composables/useConnectProvider.ts index 6dd888fd..e408e255 100644 --- a/packages/console/app/composables/useConnectProvider.ts +++ b/packages/console/app/composables/useConnectProvider.ts @@ -1,5 +1,6 @@ import type { CreateConnection, + CreateProvider, StartFileServiceOAuth, } from "@nvisy/sdk/datatypes"; import type { @@ -21,7 +22,8 @@ import { toast } from "vue-sonner"; * * Three connectable families connect three different ways: * - object stores (S3, Azure, GCS): a credential dialog -> `createConnection`. - * - LLMs (OpenAI, Anthropic, Ollama): a different credential dialog -> same. + * - LLMs (OpenAI, Anthropic, Ollama): a credential dialog -> `createProvider` + * (inference providers are a separate resource from connections). * - cloud file services (Drive, Dropbox, OneDrive, Box): OAuth - a short dialog * collects a name/folder, then we redirect to the provider's consent screen. * @@ -33,10 +35,17 @@ export function useConnectProvider() { const { wLink } = useWorkspaceLink(); const { createConnectionAsync, - isCreating, + isCreating: isCreatingConnection, startFileServiceOAuthAsync, isStartingOAuth, } = useConnections(); + const { createProviderAsync, isCreating: isCreatingProvider } = + useProviders(); + + // Either create-path may be in flight; the dialogs share one busy flag. + const isCreating = computed( + () => isCreatingConnection.value || isCreatingProvider.value, + ); // Shared header for whichever dialog opens (name + icon of the clicked card). const seed = ref({ name: "", icon: "" }); @@ -76,12 +85,11 @@ export function useConnectProvider() { } } - // Storage + LLM: create the connection from the dialog's payload. - async function submitCredentials(connection: CreateConnection) { + // Storage: create the object-store connection from the dialog's payload. + async function submitStorage(connection: CreateConnection) { try { await createConnectionAsync(connection); storageOpen.value = false; - llmOpen.value = false; toast.success(t("connections.dialogs.connect.success")); await navigateTo(wLink("/integrations")); } catch { @@ -89,6 +97,19 @@ export function useConnectProvider() { } } + // LLM: create the inference provider from the dialog's payload, and land on + // the providers tab where it now lives. + async function submitLlm(provider: CreateProvider) { + try { + await createProviderAsync(provider); + llmOpen.value = false; + toast.success(t("connections.dialogs.connect.success")); + await navigateTo(wLink("/integrations/providers")); + } catch { + toast.error(t("connections.dialogs.connect.error")); + } + } + // File service: ask the server for the authorize URL and hand the browser to // the consent screen. The connection is created by the server-side callback // once the user grants access, which then redirects back to /integrations @@ -121,7 +142,8 @@ export function useConnectProvider() { fileServiceOpen, fileServiceProvider, // Submits + loading - submitCredentials, + submitStorage, + submitLlm, submitOAuth, isCreating, isStartingOAuth, diff --git a/packages/console/app/composables/useConnections.ts b/packages/console/app/composables/useConnections.ts index 7658f0a0..f5f72986 100644 --- a/packages/console/app/composables/useConnections.ts +++ b/packages/console/app/composables/useConnections.ts @@ -12,10 +12,10 @@ import type { FileProvider } from "#console/utils/connections"; export function useConnections() { const connectionsQuery = workspaceQuery( "connections", - async ({ client, workspaceSlug }) => { - const result = await client.connections.listConnections(workspaceSlug); - return result.items; - }, + ({ client, workspaceSlug }) => + fetchAllPages((after) => + client.connections.listConnections(workspaceSlug, { after }), + ), ); // Reflect updates on a row immediately, reconciling once settled. @@ -118,10 +118,19 @@ export function useConnections() { // Mint a short-lived provider access token for a browser file picker (Google // Drive, OneDrive, Box - the pickers that take a server token; Dropbox's - // Chooser uses a client-side app key instead and rejects this). + // Chooser uses a client-side app key instead and rejects this). An optional + // `resource` scopes the token to what the picker asked for (OneDrive requests + // a token per resource); the server uses its default when omitted. const getPickerTokenMutation = workspaceMutation( - ({ client, workspaceSlug }, connectionId: string) => - client.connections.getPickerToken(workspaceSlug, connectionId), + ( + { client, workspaceSlug }, + { connectionId, resource }: { connectionId: string; resource?: string }, + ) => + client.connections.getPickerToken( + workspaceSlug, + connectionId, + resource ? { resource } : {}, + ), ); // Import the files a user picked in a provider's picker (id + name each); diff --git a/packages/console/app/composables/useFileImport.ts b/packages/console/app/composables/useFileImport.ts index 23e97b55..04f219e6 100644 --- a/packages/console/app/composables/useFileImport.ts +++ b/packages/console/app/composables/useFileImport.ts @@ -13,8 +13,12 @@ interface PickerAvailability { /** What a picker needs at open time: the availability config plus a token minter. */ interface PickerContext extends PickerAvailability { - /** Mint a short-lived provider access token for a token-based picker. */ - getToken: (connectionId: string) => Promise; + /** + * Mint a short-lived provider access token for a token-based picker, optionally + * scoped to the resource the picker asked for (its `authenticate` command's + * `resource`); the server uses its default resource when none is given. + */ + getToken: (connectionId: string, resource?: string) => Promise; } /** @@ -42,8 +46,8 @@ interface ImportPicker { const PICKERS: Record = { one_drive: { available: () => true, - open: async (connection, { getToken }) => - openOneDrivePicker(await getToken(connection.id)), + open: (connection, { getToken }) => + openOneDrivePicker((resource) => getToken(connection.id, resource)), }, dropbox: { available: (config) => !!config.dropboxAppKey, @@ -61,7 +65,7 @@ export function isImportableConnection( connection: Connection, config: PickerAvailability, ): boolean { - if (connection.providerType !== "file_service" || !connection.isActive) { + if (connection.connectionType !== "file_service" || !connection.isActive) { return false; } return PICKERS[connection.provider]?.available(config) ?? false; @@ -73,8 +77,8 @@ export function useFileImport() { const ctx: PickerContext = { dropboxAppKey, - getToken: async (connectionId) => - (await getPickerTokenAsync(connectionId)).accessToken, + getToken: async (connectionId, resource) => + (await getPickerTokenAsync({ connectionId, resource })).accessToken, }; /** diff --git a/packages/console/app/composables/useInvites.ts b/packages/console/app/composables/useInvites.ts index c309deec..074e2a65 100644 --- a/packages/console/app/composables/useInvites.ts +++ b/packages/console/app/composables/useInvites.ts @@ -12,13 +12,13 @@ export function useInvites(query?: MaybeRef) { const invitesQuery = workspaceQuery( "invites", - async ({ client, workspaceSlug }) => { - const result = await client.invites.listInvites( - workspaceSlug, - toValue(query) ?? { limit: 500 }, - ); - return result.items; - }, + ({ client, workspaceSlug }) => + fetchAllPages((after) => + client.invites.listInvites(workspaceSlug, { + ...toValue(query), + after, + }), + ), { key: () => [ "invites", diff --git a/packages/console/app/composables/usePipelines.ts b/packages/console/app/composables/usePipelines.ts index 08db125d..b41fda08 100644 --- a/packages/console/app/composables/usePipelines.ts +++ b/packages/console/app/composables/usePipelines.ts @@ -8,10 +8,10 @@ export function usePipelines() { const pipelinesQuery = workspaceQuery( "pipelines", - async ({ client, workspaceSlug }) => { - const result = await client.pipelines.listPipelines(workspaceSlug); - return result.items; - }, + ({ client, workspaceSlug }) => + fetchAllPages((after) => + client.pipelines.listPipelines(workspaceSlug, { after }), + ), ); // Pipelines are keyed by slug; a delete drops the row immediately. diff --git a/packages/console/app/composables/usePolicies.ts b/packages/console/app/composables/usePolicies.ts index 12201012..d05d9d5a 100644 --- a/packages/console/app/composables/usePolicies.ts +++ b/packages/console/app/composables/usePolicies.ts @@ -8,10 +8,10 @@ export function usePolicies() { const policiesQuery = workspaceQuery( "policies", - async ({ client, workspaceSlug }) => { - const result = await client.policies.listPolicies(workspaceSlug); - return result.items; - }, + ({ client, workspaceSlug }) => + fetchAllPages((after) => + client.policies.listPolicies(workspaceSlug, { after }), + ), ); // Policies are keyed by slug; a delete drops the row immediately. diff --git a/packages/console/app/composables/useProviders.ts b/packages/console/app/composables/useProviders.ts new file mode 100644 index 00000000..4dfdf06a --- /dev/null +++ b/packages/console/app/composables/useProviders.ts @@ -0,0 +1,103 @@ +import type { + CreateProvider, + Provider, + UpdateProvider, +} from "@nvisy/sdk/datatypes"; + +/** + * Composable for inference-provider operations — the LLM / NER services the + * platform calls. Providers are a separate resource from connections: they carry + * only inference config (an API key + model), never sync or file transfer. + */ +export function useProviders() { + const providersQuery = workspaceQuery( + "providers", + ({ client, workspaceSlug }) => + fetchAllPages((after) => + client.providers.listProviders(workspaceSlug, { after }), + ), + ); + + // Reflect updates on a row immediately, reconciling once settled. + const optimistic = useOptimisticList>( + providersQuery.data, + (p) => p.id, + ); + + const createProviderMutation = workspaceMutation( + ({ client, workspaceSlug }, provider: CreateProvider) => + client.providers.createProvider(workspaceSlug, provider), + { invalidates: "providers" }, + ); + + const updateProviderMutation = workspaceMutation( + ( + { client, workspaceSlug }, + { providerId, updates }: { providerId: string; updates: UpdateProvider }, + ) => client.providers.updateProvider(workspaceSlug, providerId, updates), + { + onMutate({ providerId, updates }) { + // Optimistically reflect only the fields that render on the row and are + // read-shape compatible. `config` uses a write-only input type, so it + // can't merge into a `Provider`; settle()/refresh reconciles it. + const patch: Partial = {}; + if (updates.displayName !== undefined) + patch.displayName = updates.displayName; + if (updates.isActive !== undefined) patch.isActive = updates.isActive; + optimistic.apply(providerId, patch); + }, + onSettled(data, _error, { providerId }) { + optimistic.settle(providerId, data as Partial | undefined); + providersQuery.refresh(); + }, + }, + ); + + const deleteProviderMutation = workspaceMutation( + ({ client, workspaceSlug }, providerId: string) => + client.providers.deleteProvider(workspaceSlug, providerId), + { + invalidates: "providers", + onMutate: (providerId) => optimistic.remove(providerId), + onError: (_error, providerId) => optimistic.restore(providerId), + }, + ); + + // Verify a provider is reachable with its stored credentials. + const verifyProviderMutation = workspaceMutation( + ({ client, workspaceSlug }, providerId: string) => + client.providers.verifyProvider(workspaceSlug, providerId), + ); + + return { + // Query state + providers: optimistic.items, + isLoading: providersQuery.isLoading, + error: providersQuery.error, + refresh: providersQuery.refresh, + + // Create + createProvider: createProviderMutation.mutate, + createProviderAsync: createProviderMutation.mutateAsync, + isCreating: createProviderMutation.isLoading, + createError: createProviderMutation.error, + + // Update + updateProvider: updateProviderMutation.mutate, + updateProviderAsync: updateProviderMutation.mutateAsync, + isUpdating: updateProviderMutation.isLoading, + updateError: updateProviderMutation.error, + + // Delete + deleteProvider: deleteProviderMutation.mutate, + deleteProviderAsync: deleteProviderMutation.mutateAsync, + isDeleting: deleteProviderMutation.isLoading, + deleteError: deleteProviderMutation.error, + + // Verify (test) provider + verifyProvider: verifyProviderMutation.mutate, + verifyProviderAsync: verifyProviderMutation.mutateAsync, + isVerifying: verifyProviderMutation.isLoading, + verifyError: verifyProviderMutation.error, + }; +} diff --git a/packages/console/app/composables/useSectionTabs.ts b/packages/console/app/composables/useSectionTabs.ts index de9485b9..afb3af79 100644 --- a/packages/console/app/composables/useSectionTabs.ts +++ b/packages/console/app/composables/useSectionTabs.ts @@ -1,5 +1,6 @@ import { Plug, + Bot, Webhook as WebhookIcon, Compass, History, @@ -64,10 +65,10 @@ export function useSectionTabs() { to: "/integrations", }, { - value: "webhooks", - label: t("header.tabs.connections.webhooks"), - icon: WebhookIcon, - to: "/integrations/webhooks", + value: "providers", + label: t("header.tabs.connections.providers"), + icon: Bot, + to: "/integrations/providers", }, { value: "explore", @@ -75,12 +76,6 @@ export function useSectionTabs() { icon: Compass, to: "/integrations/explore", }, - { - value: "runs", - label: t("header.tabs.connections.runs"), - icon: History, - to: "/integrations/runs", - }, ]); const settings = computed(() => [ @@ -105,6 +100,12 @@ export function useSectionTabs() { ]); const analytics = computed(() => [ + { + value: "webhooks", + label: t("header.tabs.analytics.webhooks"), + icon: WebhookIcon, + to: "/webhooks", + }, { value: "overview", label: t("header.tabs.analytics.overview"), diff --git a/packages/console/app/composables/useStudioFiles.ts b/packages/console/app/composables/useStudioFiles.ts index 9ffa20eb..c4924274 100644 --- a/packages/console/app/composables/useStudioFiles.ts +++ b/packages/console/app/composables/useStudioFiles.ts @@ -76,10 +76,13 @@ export function useStudioFiles() { const openFilesList = computed(() => Array.from(openFiles.value.values())); // Register a tab in loading state (synchronous, so tabs appear immediately). + // The name is left empty until metadata resolves — the UI shows the loading + // state from `isLoading`, so a placeholder string never stands in for the real + // name (and never leaks into derived names like the redacted-file download). function addLoadingTab(fileId: string, file?: NvisyFile) { openFiles.value.set(fileId, { fileId, - displayName: file?.displayName || "Loading...", + displayName: file?.displayName ?? "", fileExtension: file?.fileExtension ?? "", contentUrl: null, isLoading: true, diff --git a/packages/console/app/composables/useStudioRedaction.ts b/packages/console/app/composables/useStudioRedaction.ts index 949f09a4..2e591614 100644 --- a/packages/console/app/composables/useStudioRedaction.ts +++ b/packages/console/app/composables/useStudioRedaction.ts @@ -533,7 +533,8 @@ export function useStudioRedaction(target: RedactionTarget) { ); // The download name for a redacted output: the input's name tagged `.redacted` - // (keeping its extension so it still opens), falling back to the file id. + // (keeping its extension so it still opens), falling back to the file id while + // the name isn't known yet. function redactedName(fileId: string): string { const base = detectionFileName.value; if (!base) return fileId; diff --git a/packages/console/app/composables/useWebhooks.ts b/packages/console/app/composables/useWebhooks.ts index 74d4c26c..dd60fad2 100644 --- a/packages/console/app/composables/useWebhooks.ts +++ b/packages/console/app/composables/useWebhooks.ts @@ -10,10 +10,10 @@ import type { export function useWebhooks() { const webhooksQuery = workspaceQuery( "webhooks", - async ({ client, workspaceSlug }) => { - const result = await client.webhooks.listWebhooks(workspaceSlug); - return result.items; - }, + ({ client, workspaceSlug }) => + fetchAllPages((after) => + client.webhooks.listWebhooks(workspaceSlug, { after }), + ), ); // Reflect updates on a row immediately, reconciling once settled. diff --git a/packages/console/app/layouts/default.vue b/packages/console/app/layouts/default.vue index 53cc6d08..468c7b7f 100644 --- a/packages/console/app/layouts/default.vue +++ b/packages/console/app/layouts/default.vue @@ -1,6 +1,7 @@ + + + + + + + + + + + + + + {{ t("providers.description", { count: providers?.length ?? 0 }) }} + + + + + {{ t("connections.actions.explore") }} + + + + + + + + + + + + + {{ error.message || t("providers.errors.loadFailed") }} + + + {{ t("common.errors.tryAgain") }} + + + + + + + + + + + + + + + + + + + + + {{ selectedProvider.displayName }} + + + {{ providerLabel(selectedProvider.provider) }} + + + + + + + + diff --git a/packages/console/app/pages/w/[workspace]/integrations/runs.vue b/packages/console/app/pages/w/[workspace]/integrations/runs.vue deleted file mode 100644 index ab57f796..00000000 --- a/packages/console/app/pages/w/[workspace]/integrations/runs.vue +++ /dev/null @@ -1,305 +0,0 @@ - - - - - - - - - - - - - - - {{ t("connections.runs.runsFound", { count: syncs.length }) }} - - - - - - - - - - - {{ t("connections.runs.allStatus") }} - - - {{ t(`connections.runs.syncStatus.${s}`) }} - - - - - - - - - - - - - - - - - - - - - - {{ connectionName(row) }} - - - - - - - - - - {{ t(`connections.runs.syncStatus.${row.status}`) }} - - - - {{ row.errorMessage }} - - - - - - - - - - - - - - diff --git a/packages/console/app/pages/w/[workspace]/studio/index.vue b/packages/console/app/pages/w/[workspace]/studio/index.vue index c9082225..3bfb5088 100644 --- a/packages/console/app/pages/w/[workspace]/studio/index.vue +++ b/packages/console/app/pages/w/[workspace]/studio/index.vue @@ -4,8 +4,6 @@ import { SplitterPanel } from "reka-ui"; import { toast } from "vue-sonner"; import JSZip from "jszip"; import { - MessageSquare, - ScanSearch, Maximize2, Minimize2, PanelRightClose, @@ -13,10 +11,10 @@ import { } from "@lucide/vue"; import { StudioDocumentPreview, - StudioChatPanel, StudioAuditPanel, StudioAuditTable, StudioDetectionBar, + StudioAuditToolbar, EntityAuditModal, } from "#console/components/pages/studio"; import { ExportToConnectionDialog } from "#console/components/pages/integrations"; @@ -28,7 +26,6 @@ import { ResizablePanel, ResizableHandle, } from "#console/components/ui/resizable"; -import { Tabs, TabsList, TabsTrigger } from "#console/components/ui/tabs"; import { Button } from "#console/components/ui/button"; import { HeaderSocket, @@ -67,9 +64,6 @@ const detectionSource = computed( () => renderer.value?.detectionSource ?? "none", ); -// Right-panel tabs: Chat (existing) and Audit (detection results). -const panelTab = ref<"chat" | "audit">("audit"); - // Cross-focus between the audit panel (list) and the document preview (inline // highlights). The detected entities themselves come from the shared audit // state below. @@ -279,8 +273,8 @@ useEventListener(document, "keydown", (e: KeyboardEvent) => { else if (activeEntityId.value) clearEntity(); }); -// The right-hand inspector (Audit / Chat) is a resizable, collapsible split -// panel. The Splitter owns sizing (percent-based), keyboard resize, and — via +// The right-hand audit inspector is a resizable, collapsible split panel. The +// Splitter owns sizing (percent-based), keyboard resize, and — via // `auto-save-id` — persisting the layout across reloads; we only hold a ref to // drive collapse from the document toolbar's toggle and a flag to flip its icon. // Typed against reka-ui's SplitterPanel (our ResizablePanel wrapper forwards its @@ -296,9 +290,9 @@ function toggleInspector() { } // Which surface fills the main (left) area: the file preview, or the wide audit -// review. Only this pane swaps between modes — the sidebar (detection bar + tabs -// + chat) is one persistent panel throughout, so chat and its size are never -// rebuilt. Reviewing the audit big on the left is what "full-screen" means here. +// review. Only this pane swaps between modes — the inspector (detection bar + +// audit) is one persistent panel throughout. Reviewing the audit big on the left +// is what "full-screen" means here. const mainSurface = ref<"preview" | "audit">("preview"); // The audit review is only reachable once a detection has completed with findings // — otherwise there's nothing to review. This gates the swap control and reverts @@ -309,11 +303,15 @@ const canReview = computed( watch(canReview, (ok) => { if (!ok) mainSurface.value = "preview"; }); -// While the audit is the main surface, the sidebar hides its Audit tab (no point -// showing audit twice), so move an audit-focused sidebar off to Chat. + +// Full-screen audit review shows the wide table on the main pane and carries its +// own detection + redaction controls, so the inspector would only duplicate the +// list — collapse it on entering full-screen and restore it on exit. watch(mainSurface, (surface) => { - if (surface === "audit" && panelTab.value === "audit") - panelTab.value = "chat"; + const panel = inspectorPanel.value; + if (!panel) return; + if (surface === "audit") panel.collapse(); + else panel.expand(); }); // Revealing an entity from the audit review steps back to the preview, focused on @@ -365,18 +363,44 @@ const auditProps = computed(() => ({ - + + + + + + + + ({ - + ({ :can-run="detection.canRun.value" @run="detection.run" /> - - - - - - {{ t("studio.audit.tabAudit") }} - - {{ detection.count.value }} - - - - - {{ t("studio.audit.tabChat") }} - - - - - - - - + - + diff --git a/packages/console/app/utils/connections/fileservice.ts b/packages/console/app/utils/connections/fileservice.ts index 8fc5ebcb..236b6308 100644 --- a/packages/console/app/utils/connections/fileservice.ts +++ b/packages/console/app/utils/connections/fileservice.ts @@ -1,13 +1,13 @@ -import type { FileProviders, Provider } from "@nvisy/sdk/datatypes"; +import type { FileProviders, FileServiceProvider } from "@nvisy/sdk/datatypes"; /** * Cloud file-service providers connected over OAuth (Google Drive, Dropbox, * OneDrive, Box). These differ from object stores and LLMs: they carry no * user-entered credentials, so they are connected by redirecting the user to * the provider's `authorizeUrl` (see `startFileServiceOAuth`) rather than - * through a credential form. Derived from the SDK's `Provider` type. + * through a credential form. Derived from the SDK's `FileServiceProvider` type. */ -export type FileProvider = Provider; +export type FileProvider = FileServiceProvider; /** * Maps an OAuth file-service provider to its key on the catalog's diff --git a/packages/console/app/utils/connections/pickers/onedrive.ts b/packages/console/app/utils/connections/pickers/onedrive.ts index f49f3044..84e85e7f 100644 --- a/packages/console/app/utils/connections/pickers/onedrive.ts +++ b/packages/console/app/utils/connections/pickers/onedrive.ts @@ -12,22 +12,40 @@ import { ImportError } from "#console/utils/connections/pickers/types"; const ONEDRIVE_BASE = "https://onedrive.live.com/picker"; /** - * Open the OneDrive picker with a short-lived provider access token and resolve - * with the chosen files (or `null` if the user cancelled). The token is minted - * server-side (`getPickerToken`) from the connection's credentials. + * Open the OneDrive picker and resolve with the chosen files (or `null` if the + * user cancelled). + * + * The picker authenticates per resource: its `authenticate` command names the + * resource it needs a token for (consumer OneDrive asks for + * `my.microsoftpersonalcontent.com` and `api.onedrive.com`, which need + * differently-scoped tokens), so `getToken` mints a token server-side + * (`getPickerToken`) for each requested resource. It's also called once with no + * resource to seed the launch form (the server's default picker resource). */ -export function openOneDrivePicker( - accessToken: string, +export async function openOneDrivePicker( + getToken: (resource?: string) => Promise, ): Promise { - return new Promise((resolve, reject) => { - const opened = window.open("", "onedrive-picker", "width=1080,height=680"); - if (!opened) { - reject(new ImportError("files.errors.importPopupBlocked")); - return; - } - // Non-null alias so the closures below see a `Window`, not `Window | null`. - const popup: Window = opened; + // Open the popup FIRST, synchronously in the click handler, before any await. + // Minting the token is async, and awaiting it before `window.open` would spend + // the click's transient user activation — some browsers then block the popup + // even though the import began from a genuine gesture. + const opened = window.open("", "onedrive-picker", "width=1080,height=680"); + if (!opened) { + throw new ImportError("files.errors.importPopupBlocked"); + } + const popup: Window = opened; + // The launch form needs a token; mint the initial one now that the popup is + // already open. If it fails, close the popup so we don't leave a blank window. + let initialToken: string; + try { + initialToken = await getToken(); + } catch (err) { + popup.close(); + throw err; + } + + return new Promise((resolve, reject) => { // A per-instance channel id (a GUID, per the v8 contract) the picker echoes // back on `initialize`, so we only adopt the channel meant for us. const channelId = crypto.randomUUID(); @@ -56,7 +74,7 @@ export function openOneDrivePicker( const tokenInput = popup.document.createElement("input"); tokenInput.setAttribute("type", "hidden"); tokenInput.setAttribute("name", "access_token"); - tokenInput.setAttribute("value", accessToken); + tokenInput.setAttribute("value", initialToken); form.appendChild(tokenInput); popup.document.body.appendChild(form); form.submit(); @@ -83,13 +101,21 @@ export function openOneDrivePicker( port?.postMessage({ type: "acknowledge", id }); if (command === "authenticate") { - // The picker asks for a token for a specific resource - // (`msg.data.resource`); reply with the access token. - port?.postMessage({ - type: "result", - id, - data: { result: "token", token: accessToken }, - }); + // The picker asks for a token scoped to `msg.data.resource`; mint one + // for exactly that resource and reply with it. + const resource = msg.data?.resource as string | undefined; + getToken(resource) + .then((token) => { + port?.postMessage({ + type: "result", + id, + data: { result: "token", token }, + }); + }) + .catch(() => { + cleanup(); + reject(new ImportError("files.errors.importAuthFailed")); + }); } else if (command === "close") { cleanup(); resolve(null); diff --git a/packages/console/app/utils/notifications.ts b/packages/console/app/utils/notifications.ts index 1c09de48..e4938174 100644 --- a/packages/console/app/utils/notifications.ts +++ b/packages/console/app/utils/notifications.ts @@ -42,13 +42,13 @@ export function notificationContent( return content( "connectionSyncCompleted", payload.data, - "/integrations/runs", + "/integrations?runs=1", ); case "connection.sync.failed": return content( "connectionSyncFailed", payload.data, - "/integrations/runs", + "/integrations?runs=1", ); case "pipeline.detection.completed": return content( diff --git a/packages/console/app/utils/pagination.ts b/packages/console/app/utils/pagination.ts new file mode 100644 index 00000000..a833fe10 --- /dev/null +++ b/packages/console/app/utils/pagination.ts @@ -0,0 +1,36 @@ +/** A cursor-paginated response page: a batch of items plus the next cursor. */ +interface CursorPage { + items: T[]; + nextCursor?: string; +} + +/** + * Fetch every page of a cursor-paginated list and return the flattened items. + * + * `fetchPage(after)` requests one page starting after the given cursor (undefined + * for the first). We keep following `nextCursor` until it's absent, so a caller + * that must show the whole list (workspace-config tables — connections, webhooks, + * providers, policies, …) isn't silently truncated to the first page. + * + * A safety cap bounds the loop so a server that always returns a cursor can't + * spin forever; it's far above any realistic config-list size. Hitting the cap + * with a cursor still pending means the list is larger than we assumed, so we + * throw rather than return a silently-truncated list the caller would render as + * complete. + */ +export async function fetchAllPages( + fetchPage: (after?: string) => Promise>, + maxPages = 100, +): Promise { + const all: T[] = []; + let after: string | undefined; + for (let page = 0; page < maxPages; page++) { + const { items, nextCursor } = await fetchPage(after); + all.push(...items); + if (!nextCursor) return all; + after = nextCursor; + } + throw new Error( + `fetchAllPages exceeded ${maxPages} pages with more results pending`, + ); +} diff --git a/packages/console/app/utils/retention.ts b/packages/console/app/utils/retention.ts index bd67c00e..361bf51a 100644 --- a/packages/console/app/utils/retention.ts +++ b/packages/console/app/utils/retention.ts @@ -18,9 +18,10 @@ export interface RetentionField { /** Retention mode options, in display order. */ export const RETENTION_MODES: RetentionMode[] = ["forever", "days", "zeroDays"]; -/** The three retention scopes a workspace configures. */ +/** The retention scopes a workspace configures. */ export const RETENTION_TARGETS = [ "auditLogs", + "intermediates", "originalDocuments", "redactedDocuments", ] as const; @@ -38,6 +39,7 @@ export function newRetentionField(): RetentionField { export function defaultRetentionForm(): RetentionForm { return { auditLogs: newRetentionField(), + intermediates: newRetentionField(), originalDocuments: newRetentionField(), redactedDocuments: newRetentionField(), }; @@ -59,6 +61,7 @@ export function fieldToRetention(f: RetentionField): Retention { export function formToRetention(form: RetentionForm) { return { auditLogs: fieldToRetention(form.auditLogs), + intermediates: fieldToRetention(form.intermediates), originalDocuments: fieldToRetention(form.originalDocuments), redactedDocuments: fieldToRetention(form.redactedDocuments), }; @@ -73,6 +76,7 @@ const FOREVER: Retention = { mode: "forever" }; export function retentionToForm(r?: RetentionSettings): RetentionForm { return { auditLogs: retentionToField(r?.auditLogs ?? FOREVER), + intermediates: retentionToField(r?.intermediates ?? FOREVER), originalDocuments: retentionToField(r?.originalDocuments ?? FOREVER), redactedDocuments: retentionToField(r?.redactedDocuments ?? FOREVER), }; diff --git a/packages/console/i18n/locales/de.json b/packages/console/i18n/locales/de.json index ea34a93e..73619626 100644 --- a/packages/console/i18n/locales/de.json +++ b/packages/console/i18n/locales/de.json @@ -3,9 +3,8 @@ "tabs": { "connections": { "connections": "Verbindungen", - "webhooks": "Webhooks", "explore": "Erkunden", - "runs": "Ausführungen" + "providers": "Anbieter" }, "workflows": { "index": "Pipelines", @@ -22,7 +21,8 @@ }, "analytics": { "overview": "Übersicht", - "logs": "Protokolle" + "logs": "Protokolle", + "webhooks": "Webhooks" }, "policies": { "index": "Übersicht", @@ -39,7 +39,8 @@ "integrations": "Integrationen", "analytics": "Analysen", "billing": "Abrechnung", - "settings": "Einstellungen" + "settings": "Einstellungen", + "webhooks": "Webhooks" } }, "common": { @@ -146,6 +147,7 @@ "description": "Wie lange die jeweiligen Daten aufbewahrt werden.", "targets": { "auditLogs": "Audit-Protokolle", + "intermediates": "Anreicherungsdaten", "originalDocuments": "Originaldokumente", "redactedDocuments": "Geschwärzte Dokumente" }, @@ -534,7 +536,8 @@ "importFailed": "Import der Dateien fehlgeschlagen", "importUnavailable": "Import ist für diese Verbindung nicht verfügbar.", "importPopupBlocked": "Die Auswahl wurde blockiert. Erlauben Sie Pop-ups für diese Seite und versuchen Sie es erneut.", - "importPickerLoadFailed": "Die Dateiauswahl konnte nicht geladen werden. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut." + "importPickerLoadFailed": "Die Dateiauswahl konnte nicht geladen werden. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.", + "importAuthFailed": "Authentifizierung beim Dateidienst fehlgeschlagen. Bitte verbinden Sie ihn erneut und versuchen Sie es noch einmal." }, "kind": { "original": "Original", @@ -1214,6 +1217,8 @@ "importFailed": "Dateien konnten nicht importiert werden" }, "runs": { + "title": "Synchronisierungen", + "description": "Alle Synchronisierungsläufe Ihrer Verbindungen.", "allStatus": "Alle Status", "runsFound": "{count} Synchronisierung | {count} Synchronisierungen", "statusHeader": "Status", @@ -1250,6 +1255,63 @@ "failed": "Verbindung konnte nicht abgeschlossen werden. Bitte erneut versuchen." } }, + "providers": { + "description": "{count} Anbieter konfiguriert | {count} Anbieter konfiguriert", + "types": { + "llm": "Sprachmodell", + "ner": "Entitätserkennung" + }, + "empty": { + "title": "Keine Inferenz-Anbieter", + "description": "Verbinden Sie ein Sprachmodell (OpenAI, Anthropic, Ollama) für Erkennung und Schwärzung." + }, + "table": { + "headers": { + "name": "Name", + "enabled": "Aktiviert", + "type": "Typ", + "updated": "Aktualisiert" + }, + "actions": { + "menu": "Aktionen", + "configure": "Konfigurieren", + "test": "Testen", + "delete": "Entfernen" + } + }, + "dialogs": { + "configure": { + "title": "{name} konfigurieren", + "description": "Aktualisieren Sie die Einstellungen des Anbieters.", + "nameLabel": "Anzeigename", + "namePlaceholder": "z. B. Produktion OpenAI", + "updateCredentials": "Zugangsdaten aktualisieren", + "credentialsHint": "Geschlossen lassen, um die gespeicherten Zugangsdaten beizubehalten.", + "cancel": "Abbrechen", + "save": "Änderungen speichern" + }, + "delete": { + "title": "{name} entfernen?", + "description": "Dieser Anbieter steht dann nicht mehr für Erkennung und Schwärzung zur Verfügung. Dies kann nicht rückgängig gemacht werden.", + "confirm": "Entfernen", + "cancel": "Abbrechen" + } + }, + "toast": { + "updated": "Anbieter aktualisiert", + "updateFailed": "Anbieter konnte nicht aktualisiert werden", + "deleted": "Anbieter entfernt", + "deleteFailed": "Anbieter konnte nicht entfernt werden", + "enabled": "Anbieter aktiviert", + "disabled": "Anbieter deaktiviert", + "testReachable": "Anbieter ist erreichbar", + "testUnreachable": "Anbieter ist nicht erreichbar", + "testFailed": "Anbieter konnte nicht getestet werden" + }, + "errors": { + "loadFailed": "Anbieter konnten nicht geladen werden" + } + }, "sidebar": { "overview": "Übersicht", "workspace": "Arbeitsbereich", @@ -1266,7 +1328,8 @@ "logs": "Protokolle", "billing": "Abrechnung", "settings": "Einstellungen", - "support": "Support" + "support": "Support", + "webhooks": "Webhooks" }, "health": { "status": { @@ -1281,41 +1344,7 @@ } }, "studio": { - "chat": { - "placeholder": "Änderungen beschreiben...", - "inputPlaceholder": "Geben Sie hier Ihre Nachricht ein.", - "send": "Nachricht senden", - "saveEdit": "Änderung speichern", - "attach": "Anhängen", - "upload": "Hochladen", - "generate": "Generieren", - "summarize": "Zusammenfassen", - "greeting": "Hallo! Ich kann Ihnen helfen, dieses Dokument zu prüfen und zu redigieren. Fragen Sie mich etwas oder nutzen Sie die Werkzeuge unten.", - "processing": "Ich arbeite daran…", - "copy": "Kopieren", - "copied": "Kopiert", - "edit": "Bearbeiten", - "thumbsUp": "Gute Antwort", - "thumbsDown": "Schlechte Antwort", - "retry": "Wiederholen", - "you": "Sie", - "assistant": "Assistent", - "tools": { - "highlight": "Hervorheben", - "annotate": "Annotieren" - }, - "changes": { - "title": "Änderungen", - "accept": "Annehmen", - "reject": "Ablehnen", - "acceptAll": "Alle annehmen", - "rejectAll": "Alle ablehnen", - "empty": "Keine ausstehenden Änderungen" - } - }, "audit": { - "tabChat": "Chat", - "tabAudit": "Audit", "expand": "Auf Vollbild erweitern", "collapse": "Vollbild verlassen", "revealInDocument": "Im Dokument anzeigen", @@ -1403,7 +1432,9 @@ "organization": "Organisation", "contextual": "Kontextbezogen" }, - "exportRedacted": "Redigierte Datei exportieren" + "exportRedacted": "Redigierte Datei exportieren", + "download": "Herunterladen", + "export": "Exportieren" }, "preview": { "loading": "Dokument wird geladen …", @@ -1446,7 +1477,31 @@ "close": "Schließen", "closeOthers": "Andere schließen", "closeToRight": "Rechts schließen", - "closeAll": "Alle schließen" + "closeAll": "Alle schließen", + "loading": "Wird geladen…" + } + }, + "chat": { + "title": "Assistent", + "description": "Der KI-Assistent des Arbeitsbereichs.", + "toggle": "Assistent umschalten", + "close": "Schließen", + "newChat": "Neuer Chat", + "deleteSession": "Chat löschen", + "placeholder": "Den Assistenten fragen…", + "send": "Senden", + "copy": "Kopieren", + "copied": "Kopiert", + "retry": "Erneut versuchen", + "emptyTitle": "Fragen Sie alles", + "emptyDescription": "Der Assistent beantwortet Fragen zu Ihrem Arbeitsbereich und hilft Ihnen bei Ihren Aufgaben.", + "needsProviderTitle": "Kein KI-Anbieter verbunden", + "needsProviderDescription": "Verbinden Sie einen Inferenz-Anbieter (OpenAI, Anthropic, Ollama…), um mit dem Assistenten zu chatten.", + "needsProviderCta": "Anbieter verbinden", + "error": { + "send": "Nachricht konnte nicht gesendet werden. Bitte erneut versuchen.", + "stream": "Die Antwort des Assistenten wurde unterbrochen. Bitte erneut versuchen.", + "delete": "Diese Unterhaltung konnte nicht gelöscht werden. Bitte erneut versuchen." } }, "commandMenu": { @@ -1916,7 +1971,8 @@ "errorRate": "Fehlerrate", "avgDuration": "Ø Dauer", "errorRateTitle": "Fehlerrate pro Tag", - "avgDurationTitle": "Ø Erkennungszeit pro Tag" + "avgDurationTitle": "Ø Erkennungszeit pro Tag", + "noData": "Noch nicht genügend Daten" }, "breakdowns": { "detectionsByStatus": "Erkennungen nach Status", @@ -1925,6 +1981,11 @@ "noDetections": "Noch keine Erkennungen.", "noStorage": "Noch keine Dateien gespeichert.", "noUsage": "Noch keine KI-Nutzung." + }, + "sections": { + "activity": "Erkennungsaktivität", + "trends": "Trends", + "breakdowns": "Aufschlüsselungen" } }, "auth": { diff --git a/packages/console/i18n/locales/en.json b/packages/console/i18n/locales/en.json index 66d2a0cb..04667b77 100644 --- a/packages/console/i18n/locales/en.json +++ b/packages/console/i18n/locales/en.json @@ -3,9 +3,8 @@ "tabs": { "connections": { "connections": "Connections", - "webhooks": "Webhooks", "explore": "Explore", - "runs": "Runs" + "providers": "Providers" }, "workflows": { "index": "Pipelines", @@ -22,7 +21,8 @@ }, "analytics": { "overview": "Overview", - "logs": "Logs" + "logs": "Logs", + "webhooks": "Webhooks" }, "policies": { "index": "Overview", @@ -39,7 +39,8 @@ "integrations": "Integrations", "analytics": "Analytics", "billing": "Billing", - "settings": "Settings" + "settings": "Settings", + "webhooks": "Webhooks" } }, "common": { @@ -146,6 +147,7 @@ "description": "How long to keep each kind of data.", "targets": { "auditLogs": "Audit logs", + "intermediates": "Enrichment data", "originalDocuments": "Original documents", "redactedDocuments": "Redacted documents" }, @@ -534,7 +536,8 @@ "importFailed": "Failed to import files", "importUnavailable": "Import isn't available for this connection.", "importPopupBlocked": "The picker was blocked. Allow pop-ups for this site and try again.", - "importPickerLoadFailed": "Couldn't load the file picker. Check your connection and try again." + "importPickerLoadFailed": "Couldn't load the file picker. Check your connection and try again.", + "importAuthFailed": "Couldn't authenticate with the file service. Please reconnect it and try again." }, "kind": { "original": "Original", @@ -1214,6 +1217,8 @@ "importFailed": "Failed to import files" }, "runs": { + "title": "Sync runs", + "description": "Every sync run across your connections.", "allStatus": "All Status", "runsFound": "{count} sync | {count} syncs", "statusHeader": "Status", @@ -1250,6 +1255,63 @@ "failed": "Couldn't finish connecting. Please try again." } }, + "providers": { + "description": "{count} provider configured | {count} providers configured", + "types": { + "llm": "Language model", + "ner": "Entity recognition" + }, + "empty": { + "title": "No inference providers", + "description": "Connect a language model (OpenAI, Anthropic, Ollama) to power detection and redaction." + }, + "table": { + "headers": { + "name": "Name", + "enabled": "Enabled", + "type": "Type", + "updated": "Updated" + }, + "actions": { + "menu": "Actions", + "configure": "Configure", + "test": "Test", + "delete": "Remove" + } + }, + "dialogs": { + "configure": { + "title": "Configure {name}", + "description": "Update the provider's settings.", + "nameLabel": "Display name", + "namePlaceholder": "e.g. Production OpenAI", + "updateCredentials": "Update credentials", + "credentialsHint": "Leave closed to keep the stored credentials.", + "cancel": "Cancel", + "save": "Save changes" + }, + "delete": { + "title": "Remove {name}?", + "description": "This provider will no longer be available for detection and redaction. This can't be undone.", + "confirm": "Remove", + "cancel": "Cancel" + } + }, + "toast": { + "updated": "Provider updated", + "updateFailed": "Failed to update provider", + "deleted": "Provider removed", + "deleteFailed": "Failed to remove provider", + "enabled": "Provider enabled", + "disabled": "Provider disabled", + "testReachable": "Provider is reachable", + "testUnreachable": "Provider is not reachable", + "testFailed": "Failed to test provider" + }, + "errors": { + "loadFailed": "Failed to load providers" + } + }, "sidebar": { "overview": "Overview", "workspace": "Workspace", @@ -1266,7 +1328,8 @@ "logs": "Logs", "billing": "Billing", "settings": "Settings", - "support": "Support" + "support": "Support", + "webhooks": "Webhooks" }, "health": { "status": { @@ -1281,41 +1344,7 @@ } }, "studio": { - "chat": { - "placeholder": "Describe changes...", - "inputPlaceholder": "Type your message here.", - "send": "Send message", - "saveEdit": "Save edit", - "attach": "Attach", - "upload": "Upload", - "generate": "Generate", - "summarize": "Summarize", - "greeting": "Hi! I can help you review and redact this document. Ask me anything or use the tools below.", - "processing": "Working on it…", - "copy": "Copy", - "copied": "Copied", - "edit": "Edit", - "thumbsUp": "Good response", - "thumbsDown": "Bad response", - "retry": "Retry", - "you": "You", - "assistant": "Assistant", - "tools": { - "highlight": "Highlight", - "annotate": "Annotate" - }, - "changes": { - "title": "Changes", - "accept": "Accept", - "reject": "Reject", - "acceptAll": "Accept All", - "rejectAll": "Reject All", - "empty": "No pending changes" - } - }, "audit": { - "tabChat": "Chat", - "tabAudit": "Audit", "expand": "Expand to full screen", "collapse": "Exit full screen", "revealInDocument": "Reveal in document", @@ -1403,7 +1432,9 @@ "organization": "Organization", "contextual": "Contextual" }, - "exportRedacted": "Export redacted file" + "exportRedacted": "Export redacted file", + "download": "Download", + "export": "Export" }, "preview": { "loading": "Loading document...", @@ -1446,7 +1477,31 @@ "close": "Close", "closeOthers": "Close others", "closeToRight": "Close to the right", - "closeAll": "Close all" + "closeAll": "Close all", + "loading": "Loading…" + } + }, + "chat": { + "title": "Assistant", + "description": "The workspace AI assistant.", + "toggle": "Toggle assistant", + "close": "Close", + "newChat": "New chat", + "deleteSession": "Delete chat", + "placeholder": "Ask the assistant…", + "send": "Send", + "copy": "Copy", + "copied": "Copied", + "retry": "Retry", + "emptyTitle": "Ask anything", + "emptyDescription": "The assistant can answer questions about your workspace and help you get things done.", + "needsProviderTitle": "No AI provider connected", + "needsProviderDescription": "Connect an inference provider (OpenAI, Anthropic, Ollama…) to chat with the assistant.", + "needsProviderCta": "Connect a provider", + "error": { + "send": "Couldn't send your message. Please try again.", + "stream": "The assistant's reply was interrupted. Please try again.", + "delete": "Couldn't delete that conversation. Please try again." } }, "commandMenu": { @@ -1916,7 +1971,8 @@ "errorRate": "Error rate", "avgDuration": "Avg duration", "errorRateTitle": "Error rate per day", - "avgDurationTitle": "Avg detection time per day" + "avgDurationTitle": "Avg detection time per day", + "noData": "Not enough data yet" }, "breakdowns": { "detectionsByStatus": "Detections by status", @@ -1925,6 +1981,11 @@ "noDetections": "No detections yet.", "noStorage": "No files stored yet.", "noUsage": "No AI usage yet." + }, + "sections": { + "activity": "Detection activity", + "trends": "Trends", + "breakdowns": "Breakdowns" } }, "auth": { diff --git a/packages/console/package.json b/packages/console/package.json index 76c733bd..0d0ecbfb 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -15,7 +15,7 @@ "@lucide/vue": "^1.41.0", "@nuxtjs/color-mode": "^4.0.0", "@nuxtjs/i18n": "^10.6.0", - "@nvisy/sdk": "^0.48.0", + "@nvisy/sdk": "^0.49.0", "@pinia/colada": "^1.4.4", "@pinia/colada-nuxt": "^1.1.0", "@pinia/nuxt": "^1.0.2",
+ {{ t("chat.needsProviderTitle") }} +
+ {{ t("chat.needsProviderDescription") }} +
+ {{ t("chat.emptyTitle") }} +
+ {{ t("chat.emptyDescription") }} +
+ {{ errorMessage }} +
{{ message.content }}
+ {{ t("providers.dialogs.configure.credentialsHint") }} +
+ {{ t("connections.runs.runsFound", { count: syncs.length }) }} +
+ {{ row.errorMessage }} +
+ {{ row.displayName }} +
+ {{ providerLabel(row.provider) }} +
- {{ message.content }} -
- {{ message.timestamp.toLocaleTimeString(locale) }} -
{{ layer.name }}
- {{ layer.fileName }} · Page {{ layer.page }} -
- {{ t("studio.chat.changes.empty") }} -
+ {{ t("studio.audit.noPipelines") }} +
+ + {{ redactError }} +
+ {{ output.fileName }} +
+ {{ t("providers.description", { count: providers?.length ?? 0 }) }} +
+ {{ error.message || t("providers.errors.loadFailed") }} +
+ {{ selectedProvider.displayName }} +
+ {{ providerLabel(selectedProvider.provider) }} +
- {{ t("connections.runs.runsFound", { count: syncs.length }) }} -
- {{ row.errorMessage }} -