diff --git a/ci/ci-values.yaml b/ci/ci-values.yaml index 05eda6c164..fa78a9dc74 100644 --- a/ci/ci-values.yaml +++ b/ci/ci-values.yaml @@ -54,4 +54,4 @@ postgresql: - "management" ingress: - className: traefik \ No newline at end of file + className: traefik diff --git a/forge/db/views/ProjectTemplate.js b/forge/db/views/ProjectTemplate.js index 3d9fef4c24..dd2912a897 100644 --- a/forge/db/views/ProjectTemplate.js +++ b/forge/db/views/ProjectTemplate.js @@ -16,7 +16,7 @@ module.exports = function (app) { name: result.name, description: result.description, active: result.active, - instanceCount: result.projectCount, + instanceCount: parseInt(result.projectCount) || 0, settings: result.settings || {}, policy: result.policy || {}, createdAt: result.createdAt, @@ -56,7 +56,7 @@ module.exports = function (app) { name: result.name, description: result.description, active: result.active, - instanceCount: result.projectCount, + instanceCount: parseInt(result.projectCount) || 0, createdAt: result.createdAt, links: result.links } diff --git a/frontend/src/api/user.js b/frontend/src/api/user.js index 21bf7c906a..3f1c21c37d 100644 --- a/frontend/src/api/user.js +++ b/frontend/src/api/user.js @@ -239,6 +239,9 @@ const disableMFA = async () => { const verifyMFA = async (token) => { return client.put('/api/v1/user/mfa/verify', { token }).then(res => res.data) } +const initiateExpertChat = async ({ sessionId }) => { + return client.post('/api/v1/user/expert-creds', { sessionId }).then(res => res.data) +} export default { registerUser, @@ -267,5 +270,6 @@ export default { updatePersonalAccessToken, enableMFA, verifyMFA, - disableMFA + disableMFA, + initiateExpertChat } diff --git a/frontend/src/components/expert/Expert.vue b/frontend/src/components/expert/Expert.vue index f40c17ab36..406248a9ea 100644 --- a/frontend/src/components/expert/Expert.vue +++ b/frontend/src/components/expert/Expert.vue @@ -148,7 +148,8 @@ export default { 'setAgentMode', 'setAbortController', 'resetSessionTimer', - 'addWelcomeMessageIfNeeded' + 'addWelcomeMessageIfNeeded', + 'stopInflightChat' ]), ...mapActions(useProductExpertInsightsAgentStore, ['getCapabilities']), ...mapActions(useProductAssistantStore, ['reset']), @@ -157,6 +158,7 @@ export default { this.abortController.abort() this.setAbortController(null) } + this.stopInflightChat() }, handleScroll () { // Debounce scroll detection diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index 9a0a61ce61..9238db99f2 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -33,7 +33,7 @@ />
-
+
@@ -64,13 +64,13 @@ + + diff --git a/frontend/src/components/expert/components/messages/components/ToolCalls.vue b/frontend/src/components/expert/components/messages/components/ToolCalls.vue index 3943d8106a..565d2b7dd7 100644 --- a/frontend/src/components/expert/components/messages/components/ToolCalls.vue +++ b/frontend/src/components/expert/components/messages/components/ToolCalls.vue @@ -9,70 +9,27 @@ {{ formattedDuration }} ms
-
-
+ -
{{ tool.title || tool.name }}
-
- {{ formatKindBadge(tool.kind) }} - {{ tool.name }} -
-
- -
-
- - -
-
- -
-
-
- -
-
- - - {{ tool.durationMs || 0 }} ms -
-
- -
-
-
-
-
+ :tool="tool" + :expanded="expanded" + />
diff --git a/frontend/src/components/expert/components/messages/components/resources/StreamableContent.vue b/frontend/src/components/expert/components/messages/components/resources/StreamableContent.vue index 436250a8cc..43bdb2ab8f 100644 --- a/frontend/src/components/expert/components/messages/components/resources/StreamableContent.vue +++ b/frontend/src/components/expert/components/messages/components/resources/StreamableContent.vue @@ -222,5 +222,24 @@ export default { background-color: $ff-color--highlight; } } + + :deep(code) { + padding: 0; + border: none; + border-radius: 0; + } + + :deep(table) { + border-collapse: collapse; /* removes double borders */ + + tr + tr td { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + border-top: 1px solid $ff-grey-200; + } + + td, th { + padding: 5px 10px; + } + } } diff --git a/frontend/src/composables/services/MqttExpertTopicHelper.ts b/frontend/src/composables/services/MqttExpertTopicHelper.ts new file mode 100644 index 0000000000..cb5a71b6ad --- /dev/null +++ b/frontend/src/composables/services/MqttExpertTopicHelper.ts @@ -0,0 +1,138 @@ +import { useAccountAuthStore, useContextStore, useProductExpertStore } from '@/stores' + +interface EntityWithId { + id: string + [key: string]: unknown +} + +interface EntityTopicPathsOptions { + application?: EntityWithId | null + instance?: EntityWithId | null + device?: EntityWithId | null + team?: EntityWithId | null +} + +interface EntityTopicPaths { + entityType: 'a' | 'p' | 'd' | 't' + entityId: string | undefined +} + +type AgentChannel = 'support' | 'insights' +type TopicType = 'chat' | 'inflight' +type TopicAction = 'response' | 'request' + +interface ParsedTopic { + topic: string + isReply: boolean + isInflightRequest: boolean + entityType: string + entityId: string + agentChannel: 'support' | 'insights' | string + topicType: string | 'chat' | 'inflight' | null + topicAction: string | 'request' | 'response' | null + inflightType: string | null +} + +interface BuildTopicOptions { + entityType?: EntityTopicPaths['entityType'] | null + entityId?: string | null + agentChannel?: AgentChannel + topicType?: TopicType + topicAction?: TopicAction, + inflightType?: string | null +} + +export function useMqttExpertTopicHelper () { + function getEntityTopicPaths (options?: EntityTopicPathsOptions): EntityTopicPaths { + const { application, instance, device, team } = options ?? {} + const contextStore = useContextStore() + + switch (true) { + case !!application || !!contextStore.application: + return { + entityType: 'a', + entityId: application?.id ?? contextStore.application?.id + } + case !!instance || !!contextStore.instance: + return { + entityType: 'p', + entityId: instance?.id ?? contextStore.instance.id + } + case !!device || !!contextStore.device: + return { + entityType: 'd', + entityId: device?.id ?? contextStore.device?.id + } + default: + return { + entityType: 't', + entityId: team?.id ?? contextStore.team?.id + } + } + } + + function buildTopic (options?: BuildTopicOptions): string { + const { entityType, entityId, agentChannel, topicType, topicAction, inflightType } = options ?? {} + + if (!entityType) throw new Error('Topic "entityType" is mandatory') + if (!entityId) throw new Error('Topic "entityId" is mandatory') + if (!agentChannel || !['support', 'insights'].includes(agentChannel)) { + throw new Error(`"agentChannel" must be either "support" or "insights", "${agentChannel}" given`) + } + if (!topicType || !['chat', 'inflight'].includes(topicType)) { + throw new Error(`"topicType" must be either "chat" or "inflight", "${topicType}" given`) + } + if (!topicAction || !['response', 'request'].includes(topicAction)) { + throw new Error(`"topicAction" must be either "response" or "request", "${topicAction}" given`) + } + + const authStore = useAccountAuthStore() + const expertStore = useProductExpertStore() + + const sessionId = expertStore.sessionId + + return [ + 'ff', + 'v1', + 'expert', + authStore.user.id, + sessionId, + entityType, + entityId, + agentChannel, + topicType, + inflightType, + topicAction + ] + .filter(str => str).join('/') + } + + function parseTopic (topic: string): ParsedTopic { + // topic examples + // ff/v1/expert/////// + // ff/v1/expert//////// + + if (!topic || topic.length === 0) throw new Error(`Invalid topic received: "${topic}"`) + + const split = topic.split('/') + + const inflightRequest = topic.includes('/inflight/') && topic.endsWith('/request') + return { + topic, + isReply: topic.endsWith('/response'), + isInflightRequest: inflightRequest, + entityType: split[5], + entityId: split[6], + agentChannel: split[7], + topicType: split[8], + topicAction: split.at(-1), + inflightType: inflightRequest ? split.at(-2) ?? null : null + } + } + + return { + getEntityTopicPaths, + buildTopic, + parseTopic + } +} diff --git a/frontend/src/mixins/Application.js b/frontend/src/mixins/Application.js index e744829d7e..7812450172 100644 --- a/frontend/src/mixins/Application.js +++ b/frontend/src/mixins/Application.js @@ -1,3 +1,5 @@ +import { mapActions } from 'pinia' + import ApplicationApi from '../api/application.js' import alerts from '../services/alerts.js' @@ -26,8 +28,14 @@ export default { } }, watch: { + application (application) { + if (application) { + this.setContextualApplication(application) + } + } }, methods: { + ...mapActions(useContextStore, { setContextualApplication: 'setApplication' }), async updateApplication () { const applicationId = this.$route.params.id @@ -96,5 +104,8 @@ export default { }, async created () { + }, + beforeUnmount () { + this.setContextualApplication(null) } } diff --git a/frontend/src/mixins/Instance.js b/frontend/src/mixins/Instance.js index 99e2db4bbf..7a2d00b903 100644 --- a/frontend/src/mixins/Instance.js +++ b/frontend/src/mixins/Instance.js @@ -1,4 +1,4 @@ -import { mapState } from 'pinia' +import { mapActions, mapState } from 'pinia' import InstanceApi from '../api/instances.js' import SnapshotApi from '../api/projectSnapshots.js' @@ -34,9 +34,13 @@ export default { } }, watch: { - instance: 'instanceChanged' + instance (instance) { + this.instanceChanged() + this.setContextualInstance(instance) + } }, methods: { + ...mapActions(useContextStore, { setContextualInstance: 'setInstance' }), showConfirmDeleteDialog () { this.$refs.confirmInstanceDeleteDialog.show(this.instance) }, @@ -106,5 +110,8 @@ export default { await this.loadInstance() } ) + }, + beforeUnmount () { + this.setContextualInstance(null) } } diff --git a/frontend/src/pages/admin/Settings/General.vue b/frontend/src/pages/admin/Settings/General.vue index f99877b019..928c75d1ec 100644 --- a/frontend/src/pages/admin/Settings/General.vue +++ b/frontend/src/pages/admin/Settings/General.vue @@ -157,7 +157,7 @@ Disable -