Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config/constants/development.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ module.exports = {
ENGAGEMENTS_ROOT_API_URL: `${DEV_API_HOSTNAME}/v6/engagements`,
APPLICATIONS_API_URL: `${DEV_API_HOSTNAME}/v6/engagements/applications`,
TC_FINANCE_API_URL: process.env.TC_FINANCE_API_URL || `${API_V6}/finance`,
TC_AI_API_BASE_URL: process.env.TC_AI_API_BASE_URL || `${API_V6}/ai`,
TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID: process.env.TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID || 'skillExtractionWorkflow',
CHALLENGE_DEFAULT_REVIEWERS_URL: `${DEV_API_HOSTNAME}/v6/challenge/default-reviewers`,
CHALLENGE_API_VERSION: '1.1.0',
CHALLENGE_TIMELINE_TEMPLATES_URL: `${DEV_API_HOSTNAME}/v6/timeline-templates`,
Expand Down
2 changes: 2 additions & 0 deletions config/constants/production.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ module.exports = {
ENGAGEMENTS_ROOT_API_URL: `${PROD_API_HOSTNAME}/v6/engagements`,
APPLICATIONS_API_URL: `${PROD_API_HOSTNAME}/v6/engagements/applications`,
TC_FINANCE_API_URL: process.env.TC_FINANCE_API_URL || `${API_V6}/finance`,
TC_AI_API_BASE_URL: process.env.TC_AI_API_BASE_URL || `${API_V6}/ai`,
Comment thread
vas3a marked this conversation as resolved.
TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID: process.env.TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID || 'skillExtractionWorkflow',
Comment thread
vas3a marked this conversation as resolved.
CHALLENGE_DEFAULT_REVIEWERS_URL: `${PROD_API_HOSTNAME}/v6/challenge/default-reviewers`,
CHALLENGE_API_VERSION: '1.1.0',
CHALLENGE_TIMELINE_TEMPLATES_URL: `${PROD_API_HOSTNAME}/v6/timeline-templates`,
Expand Down
1 change: 0 additions & 1 deletion docs/dev.env

This file was deleted.

160 changes: 121 additions & 39 deletions src/components/ChallengeEditor/SkillsField/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import React, { useMemo } from 'react'
import React, { useMemo, useState } from 'react'
import PropTypes from 'prop-types'
import Select from '../../Select'
import { searchSkills } from '../../../services/skills'
import cn from 'classnames'
import styles from './styles.module.scss'
import { AUTOCOMPLETE_DEBOUNCE_TIME_MS, SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS } from '../../../config/constants'
import _ from 'lodash'
import { extractSkillsFromText } from '../../../services/workflowAI'
import { toastSuccess, toastFailure } from '../../../util/toaster'
import { OutlineButton } from '../../Buttons'
import Loader from '../../Loader'

const fetchSkills = _.debounce((inputValue, callback) => {
searchSkills(inputValue).then(
Expand All @@ -22,6 +26,8 @@ const fetchSkills = _.debounce((inputValue, callback) => {
}, AUTOCOMPLETE_DEBOUNCE_TIME_MS)

const SkillsField = ({ readOnly, challenge, onUpdateSkills, embedded }) => {
const [isLoadingAI, setIsLoadingAI] = useState(false)

const selectedSkills = useMemo(() => (challenge.skills || []).map(skill => ({
label: skill.name,
value: skill.id
Expand All @@ -31,30 +37,86 @@ const SkillsField = ({ readOnly, challenge, onUpdateSkills, embedded }) => {
const normalizedBillingAccountId = _.isNil(billingAccountId) ? null : String(billingAccountId)
const skillsRequired = normalizedBillingAccountId ? !SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS.includes(normalizedBillingAccountId) : true
const showRequiredError = !readOnly && skillsRequired && challenge.submitTriggered && (!selectedSkills || !selectedSkills.length)

// Check if description exists to show AI button
const hasDescription = challenge.description && challenge.description.trim().length > 0
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to up this to logical value. Can't find skills from a desc with 1 character, right!?


const handleAISuggest = async () => {
if (!hasDescription || isLoadingAI) {
return
}

setIsLoadingAI(true)
try {
const result = await extractSkillsFromText(challenge.description)
Comment thread
vas3a marked this conversation as resolved.
const matches = result.matches || []

if (matches.length === 0) {
toastFailure('No Skills Found', 'No matching standardized skills found based on the description.')
} else {
// Merge with existing skills, avoiding duplicates
const existingSkillIds = new Set((challenge.skills || []).map(s => s.id))
const newSkills = matches.filter(skill => !existingSkillIds.has(skill.id))

if (newSkills.length === 0) {
toastSuccess('Skills Already Added', 'All suggested skills are already in your selection.')
} else {
const updatedSkills = [...(challenge.skills || []), ...newSkills]
onUpdateSkills(updatedSkills)
toastSuccess('Skills Added', `${newSkills.length} skill(s) were added from AI suggestions.`)
}
}
} catch (error) {
console.error('AI skill extraction error:', error)
Comment thread
vas3a marked this conversation as resolved.
toastFailure('Error', 'Failed to extract skills. Please try again or add skills manually.')
} finally {
setIsLoadingAI(false)
}
}

if (embedded) {
return (
<div className={styles.embeddedWrapper}>
<input type='hidden' />
{readOnly ? (
<div className={styles.embeddedReadOnly}>{existingSkills || '-'}</div>
) : (
<Select
id='skill-select'
isMulti
simpleValue
isAsync
value={selectedSkills}
onChange={(values) => {
onUpdateSkills((values || []).map(value => ({
name: value.label,
id: value.value
})))
}}
cacheOptions
loadOptions={fetchSkills}
/>
)}
<div className={styles.embeddedContent}>
{isLoadingAI && (
<div className={styles.loadingOverlay}>
<Loader />
<span className={styles.loadingText}>Generating skill suggestions...</span>
</div>
)}
{readOnly ? (
<div className={styles.embeddedReadOnly}>{existingSkills || '-'}</div>
) : (
<>
<Select
id='skill-select'
isMulti
simpleValue
isAsync
value={selectedSkills}
onChange={(values) => {
onUpdateSkills((values || []).map(value => ({
name: value.label,
id: value.value
})))
}}
cacheOptions
loadOptions={fetchSkills}
isDisabled={isLoadingAI}
/>
{hasDescription && (
<OutlineButton
type='info'
text='AI Suggest'
onClick={handleAISuggest}
disabled={isLoadingAI}
className={styles.aiSuggestButton}
/>
)}
</>
)}
</div>
{showRequiredError && (
<div className={styles.embeddedError}>Select at least one skill</div>
)}
Expand All @@ -70,25 +132,45 @@ const SkillsField = ({ readOnly, challenge, onUpdateSkills, embedded }) => {
</div>
<div className={cn(styles.field, styles.col2)}>
<input type='hidden' />
{readOnly ? (
<span>{existingSkills}</span>
) : (
<Select
id='skill-select'
isMulti
simpleValue
isAsync
value={selectedSkills}
onChange={(values) => {
onUpdateSkills((values || []).map(value => ({
name: value.label,
id: value.value
})))
}}
cacheOptions
loadOptions={fetchSkills}
/>
)}
<div className={styles.skillsFieldWrapper}>
{isLoadingAI && (
<div className={styles.loadingOverlay}>
<Loader />
<span className={styles.loadingText}>Generating skill suggestions...</span>
</div>
)}
{readOnly ? (
<span>{existingSkills}</span>
) : (
<>
<Select
id='skill-select'
isMulti
simpleValue
isAsync
value={selectedSkills}
onChange={(values) => {
onUpdateSkills((values || []).map(value => ({
name: value.label,
id: value.value
})))
}}
cacheOptions
loadOptions={fetchSkills}
isDisabled={isLoadingAI}
/>
{hasDescription && (
<OutlineButton
type='info'
text='AI Suggest'
onClick={handleAISuggest}
disabled={isLoadingAI}
className={styles.aiSuggestButton}
/>
)}
</>
)}
</div>
</div>
</div>

Expand Down
42 changes: 42 additions & 0 deletions src/components/ChallengeEditor/SkillsField/styles.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,54 @@
}
}

.skillsFieldWrapper {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}

.aiSuggestButton {
align-self: flex-start;
min-width: 120px;
}

.loadingOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
border-radius: 4px;
gap: 12px;
}

.loadingText {
font-size: 14px;
color: $tc-gray-80;
font-weight: 500;
}

.embeddedWrapper {
display: flex;
flex-direction: column;
width: 100%;
}

.embeddedContent {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
}

.embeddedError {
color: $tc-red;
font-size: 12px;
Expand Down
8 changes: 8 additions & 0 deletions src/config/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,21 @@ export const {
SALESFORCE_BILLING_ACCOUNT_LINK,
PROFILE_URL,
TC_FINANCE_API_URL,
TC_AI_API_BASE_URL,
TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID,
ENGAGEMENTS_APP_URL
} = process.env

export const CREATE_FORUM_TYPE_IDS = typeof process.env.CREATE_FORUM_TYPE_IDS === 'string' ? process.env.CREATE_FORUM_TYPE_IDS.split(',') : process.env.CREATE_FORUM_TYPE_IDS
export const PROJECTS_API_URL = process.env.PROJECTS_API_URL || process.env.PROJECT_API_URL
export const SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS = ['80000062']

/**
* AI Workflow Service config
*/
export const AI_WORKFLOW_POLL_INTERVAL = 1000 // 1 second in milliseconds
Comment thread
vas3a marked this conversation as resolved.
export const AI_WORKFLOW_POLL_TIMEOUT = 5 * 60000 // 5 * 60 seconds in milliseconds

/**
* Filepicker config
*/
Expand Down
Loading
Loading