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
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@
# Keep LF line endings in webroot assets files. Otherwise, building them under Windows would change the line endings to CLRF and cause changes without actually editing the source files.
**/wwwroot/**/*.js text eol=lf
**/wwwroot/**/*.css text eol=lf
**/wwwroot/**/*.map text eol=lf

# Keep LF line endings in source asset files so that generated source maps embed
# LF content regardless of the platform and CI builds stay deterministic.
**/Assets/**/*.js text eol=lf
**/Assets/**/*.css text eol=lf
2 changes: 1 addition & 1 deletion .github/workflows/publish_npm_preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,6 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
PREVIEW_VERSION="$(node -p "require('./package.json').version")-preview.${{ github.run_number }}"
PREVIEW_VERSION="$(node -p "require('./package.json').version").${{ github.run_number }}"
npm version "${PREVIEW_VERSION}" --no-git-tag-version --allow-same-version
npm publish --access public --tag preview
13 changes: 4 additions & 9 deletions gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,6 @@ function buildCssPipeline(assetGroup, doConcat, doRebuild) {
var ext = path.extname(inputPath).toLowerCase();
return ext === ".less" || ext === ".scss";
});
// Source maps are useless if neither concatenating nor transforming.
if ((!doConcat || assetGroup.inputPaths.length < 2) && !containsLessOrScss)
generateSourceMaps = false;

var minifiedStream = gulp.src(assetGroup.inputPaths) // Minified output, source mapping completely disabled.
.pipe(gulpif(!doRebuild,
Expand Down Expand Up @@ -271,7 +268,7 @@ function buildCssPipeline(assetGroup, doConcat, doRebuild) {
})))
.pipe(gulpif(doConcat, concat(assetGroup.outputFileName)))
.pipe(gulpif(generateRTL, postcss([rtl()])))
.pipe(gulpif(generateSourceMaps, sourcemaps.write()))
.pipe(gulpif(generateSourceMaps, sourcemaps.write('.')))
.pipe(eol('\n'))
.pipe(gulp.dest(assetGroup.outputDir));
// Uncomment to copy assets to wwwroot
Expand All @@ -286,9 +283,6 @@ function buildJsPipeline(assetGroup, doConcat, doRebuild) {
throw "Input file '" + inputPath + "' is not of a valid type for output file '" + assetGroup.outputPath + "'.";
});
var generateSourceMaps = assetGroup.hasOwnProperty("generateSourceMaps") ? assetGroup.generateSourceMaps : false;
// Source maps are useless if neither concatenating nor transforming.
if ((!doConcat || assetGroup.inputPaths.length < 2) && !assetGroup.inputPaths.some(function (inputPath) { return path.extname(inputPath).toLowerCase() === ".ts"; }))
generateSourceMaps = false;

var tsCompilerOptions = assetGroup.hasOwnProperty("tsCompilerOptions") ? assetGroup.tsCompilerOptions : {
declaration: false,
Expand Down Expand Up @@ -329,16 +323,17 @@ function buildJsPipeline(assetGroup, doConcat, doRebuild) {
}

var devStream = createJsStream(generateSourceMaps)
.pipe(gulpif(generateSourceMaps, sourcemaps.write()))
.pipe(gulpif(generateSourceMaps, sourcemaps.write('.')))
.pipe(gulp.dest(assetGroup.outputDir));
// Uncomment to copy assets to wwwroot
//.pipe(gulp.dest(assetGroup.webroot));

var minifiedStream = createJsStream(false)
var minifiedStream = createJsStream(generateSourceMaps)
.pipe(terser())
.pipe(rename({
suffix: ".min"
}))
.pipe(gulpif(generateSourceMaps, sourcemaps.write('.')))
.pipe(eol('\n'))
.pipe(gulp.dest(assetGroup.outputDir));
// Uncomment to copy assets to wwwroot
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,12 @@ public class ChatModeProfileSettings
/// When <c>null</c> or empty, the provider's default voice is used.
/// </summary>
public string VoiceName { get; set; }

/// <summary>
/// Gets or sets whether to show text-to-speech playback controls on
/// assistant messages. Disabled by default. When enabled the UI displays
/// a play button on each assistant message, allowing the user to listen
/// to the response via the configured TTS deployment.
/// </summary>
public bool EnableTextToSpeechPlayback { get; set; }
}
5 changes: 5 additions & 0 deletions src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,8 @@ description: Initial standalone release notes for the CrestApps.Core repository.
- clarifies deployment-store registration by introducing `IAIDeploymentStore` for persisted deployments, moves Chat Interactions ahead of AI Profiles / AI Chat in the MVC sample onboarding flow, and adds dedicated AI Profile documentation that explains how profiles power reusable chat, agents, orchestration, retrieval, and session processing
- centralizes reusable MCP runtime registration in `AddCoreAIMcpServices()`, moves the shared MCP metadata, capability-resolution, tool-registry, SSE settings-handler, and invoke-function services into `CrestApps.Core.AI.Mcp`, and splits optional StdIO transport registration so hosts can enable it only where needed
- treats aborted and canceled request-stream failures in the Aspire AppHost as observed task exceptions so local development no longer floods the console with benign unobserved-task noise
- generates external `.map` source map files for all JS and CSS assets in the gulp build pipeline, copies them into `dist/` during npm package preparation, and includes them in the `@crestapps/ai-chat-ui` package exports
- adds per-message text-to-speech play/pause button on assistant messages in the AI Chat and Chat Interaction UIs when a TTS deployment is configured, allowing users to hear any message read aloud regardless of whether the profile uses Conversation mode
- adds Chat History page listing previous sessions per AI Profile sorted by creation date, with resume, delete, delete-all, and new-chat actions
- adds Test page for Utility and Agent AI Profiles providing a single-prompt/single-response streamed UI
- renames the "Chat" button to "New Chat" on the AI Profile list and adds "Chat History" and "Test" buttons for applicable profile types
18 changes: 18 additions & 0 deletions src/CrestApps.Core.Docs/docs/core/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,8 @@ Pin a specific version in production by appending `@<version>` after the package
| `chat-widget.css` / `.min.css` | Styles for the floating AI Chat widget |
| `document-drop-zone.css` / `.min.css` | Styles for the document drop-zone component |

Each JavaScript and CSS file also ships with a companion `.map` source map file (e.g. `ai-chat.js.map`, `chat-widget.css.map`). Source maps are generated for both the dev and minified builds and are included in the `dist/` folder and npm package exports.

### Required Client-Side Dependencies

Both widgets require the following libraries to be loaded on the page **before** the widget script:
Expand All @@ -761,3 +763,19 @@ Both widgets require the following libraries to be loaded on the page **before**
| [Chart.js](https://www.chartjs.org/) | Interactive chart rendering | `https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js` |
| [highlight.js](https://highlightjs.org/) | Code syntax highlighting | `https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js` |

### Text-to-Speech Play Icon

When a text-to-speech (TTS) deployment is configured, each assistant message shows a play icon that reads the message aloud via server-side speech synthesis. Clicking the icon calls `SynthesizeSpeech` on the SignalR hub, which streams audio chunks back to the client and plays them as MP3. Clicking the icon again pauses playback.

To enable this feature, pass `textToSpeechEnabled: true` in the widget initialization config:

```js
window.openAIChatManager.initialize({
// ... other config ...
textToSpeechEnabled: true,
ttsVoiceName: 'alloy' // optional voice name override
});
```

The play icon appears on all completed assistant messages regardless of whether the profile uses Conversation mode. The only requirement is that a TTS deployment is available on the server.

8 changes: 2 additions & 6 deletions src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -745,11 +745,7 @@ await ExecuteInScopeAsync(async services =>
return;
}

if (!profile.TryGetSettings<ChatModeProfileSettings>(out var chatModeSettings) || chatModeSettings.ChatMode != ChatMode.Conversation)
{
await Clients.Caller.ReceiveError(GetTtsNotEnabledMessage());
return;
}
profile.TryGetSettings<ChatModeProfileSettings>(out var chatModeSettings);

var deploymentSettings = await GetDeploymentSettingsAsync(services);
if (string.IsNullOrEmpty(deploymentSettings.DefaultTextToSpeechDeploymentName))
Expand All @@ -766,7 +762,7 @@ await ExecuteInScopeAsync(async services =>
}

using var textToSpeechClient = await clientFactory.CreateTextToSpeechClientAsync(deployment);
var effectiveVoiceName = !string.IsNullOrWhiteSpace(voiceName) ? voiceName : !string.IsNullOrWhiteSpace(chatModeSettings.VoiceName) ? chatModeSettings.VoiceName : deploymentSettings.DefaultTextToSpeechVoiceId;
var effectiveVoiceName = !string.IsNullOrWhiteSpace(voiceName) ? voiceName : !string.IsNullOrWhiteSpace(chatModeSettings?.VoiceName) ? chatModeSettings.VoiceName : deploymentSettings.DefaultTextToSpeechVoiceId;
await StreamSpeechAsync(textToSpeechClient, sessionId ?? string.Empty, text, effectiveVoiceName, cancellationToken);
});
}
Expand Down
18 changes: 12 additions & 6 deletions src/Resources/CrestApps.AI.Resources/Assets.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,42 @@
"inputs": [
"Assets/js/ai-chat.js"
],
"output": "wwwroot/scripts/ai-chat.js"
"output": "wwwroot/scripts/ai-chat.js",
"generateSourceMaps": true
},
{
"inputs": [
"Assets/js/chat-interaction.js"
],
"output": "wwwroot/scripts/chat-interaction.js"
"output": "wwwroot/scripts/chat-interaction.js",
"generateSourceMaps": true
},
{
"inputs": [
"Assets/js/document-drop-zone.js"
],
"output": "wwwroot/scripts/document-drop-zone.js"
"output": "wwwroot/scripts/document-drop-zone.js",
"generateSourceMaps": true
},
{
"inputs": [
"Assets/js/technical-name-generator.js"
],
"output": "wwwroot/scripts/technical-name-generator.js"
"output": "wwwroot/scripts/technical-name-generator.js",
"generateSourceMaps": true
},
{
"inputs": [
"Assets/css/chat-widget.css"
],
"output": "wwwroot/styles/chat-widget.css"
"output": "wwwroot/styles/chat-widget.css",
"generateSourceMaps": true
},
{
"inputs": [
"Assets/css/document-drop-zone.css"
],
"output": "wwwroot/styles/document-drop-zone.css"
"output": "wwwroot/styles/document-drop-zone.css",
"generateSourceMaps": true
}
]
13 changes: 10 additions & 3 deletions src/Resources/CrestApps.AI.Resources/Assets/css/chat-widget.css
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@

.ai-chat-widget-messages .ai-chat-message-body {
position: relative;
padding: 0 0 1.75rem 0;
padding: 0 0 0.25rem 0;
overflow-wrap: break-word;
word-break: break-word;
min-width: 0;
Expand All @@ -154,16 +154,17 @@
.ai-chat-widget-messages .message-buttons-container {
position: absolute;
right: 0;
bottom: 0.25rem;
top: 0;
display: inline-flex;
gap: 0.35rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease-in-out;
background: rgba(255, 255, 255, 0.92);
background: #fff;
padding: 0.15rem 0.35rem;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
z-index: 1;
}

.ai-chat-widget-messages .ai-chat-message-item:hover .message-buttons-container,
Expand All @@ -182,6 +183,12 @@
color: var(--bs-body-color, #212529) !important;
}

.ai-chat-widget-messages .message-buttons-container > .button-message-toolbox.tts-playing {
color: var(--bs-primary, #0d6efd) !important;
opacity: 1;
pointer-events: auto;
}

/* ── Notifications ── */
.ai-chat-widget-messages .ai-chat-notification {
margin: 0.75rem 0.5rem 0;
Expand Down
41 changes: 39 additions & 2 deletions src/Resources/CrestApps.AI.Resources/Assets/js/ai-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ window.openAIChatManager = function () {
</button>
</span>
</template>
<button v-if="textToSpeechEnabled && message.role === 'assistant' && !message.isStreaming" class="btn btn-sm btn-link text-secondary p-0 me-1 button-message-toolbox" :class="{ 'tts-playing': ttsPlayingMessageIndex === index }" @click="toggleMessageTts(message, index)" :title="ttsPlayingMessageIndex === index ? 'Pause audio' : 'Read aloud'">
<i :class="ttsPlayingMessageIndex === index ? 'fa-solid fa-circle-pause' : 'fa-solid fa-circle-play'"></i>
</button>
<button class="btn btn-sm btn-link text-secondary p-0 button-message-toolbox" @click="copyResponse(message.content)" :title="copyTitle">
<i class="fa-solid fa-copy"></i>
</button>
Expand Down Expand Up @@ -444,13 +447,16 @@ window.openAIChatManager = function () {
preRecordingPrompt: '',
micButton: null,
speechToTextEnabled: config.chatMode === 'AudioInput' || config.chatMode === 'Conversation',
textToSpeechEnabled: config.chatMode === 'Conversation',
textToSpeechEnabled: config.chatMode === 'Conversation' || !!config.textToSpeechEnabled,
ttsVoiceName: config.ttsVoiceName || null,
audioChunks: [],
audioPlayQueue: [],
isPlayingAudio: false,
currentAudioElement: null,
ttsButton: null,
ttsPlayingMessageIndex: -1,
ttsAudioCache: {},
singleResponseMode: !!config.singleResponseMode,
conversationModeEnabled: config.chatMode === 'Conversation',
conversationButton: null,
isConversationMode: false,
Expand Down Expand Up @@ -1171,6 +1177,11 @@ window.openAIChatManager = function () {
// Prevent stale ReceiveTranscript events from repopulating the prompt.
this._audioInputSent = true;

// In single-response mode, clear all previous messages.
if (this.singleResponseMode) {
this.messages.splice(0, this.messages.length);
}

this.addMessage({
role: 'user',
content: trimmedPrompt
Expand Down Expand Up @@ -1507,20 +1518,39 @@ window.openAIChatManager = function () {
}
}
},
synthesizeSpeech(text) {
synthesizeSpeech(text, cacheIndex) {
if (!this.textToSpeechEnabled || !text || !this.connection) {
return;
}

this.audioChunks = [];
this.isPlayingAudio = true;
this._ttsCacheIndex = cacheIndex !== undefined ? cacheIndex : -1;

this.connection.invoke("SynthesizeSpeech", this.getProfileId(), this.getSessionId(), text, this.ttsVoiceName)
.catch(err => {
console.error("TTS synthesis error:", err);
this.isPlayingAudio = false;
this.ttsPlayingMessageIndex = -1;
this._ttsCacheIndex = -1;
});
},
toggleMessageTts(message, index) {
if (this.ttsPlayingMessageIndex === index) {
this.stopAudio();
return;
}

this.stopAudio();
this.ttsPlayingMessageIndex = index;

if (this.ttsAudioCache[index]) {
this.playAudioBlob(this.ttsAudioCache[index]);
return;
}

this.synthesizeSpeech(message.content, index);
},
playCollectedAudio() {
if (this.audioChunks.length === 0) {
if (!this.currentAudioElement && this.audioPlayQueue.length === 0) {
Expand All @@ -1540,6 +1570,11 @@ window.openAIChatManager = function () {

const blob = new Blob([combined], { type: 'audio/mp3' });

if (this._ttsCacheIndex >= 0) {
this.ttsAudioCache[this._ttsCacheIndex] = blob;
this._ttsCacheIndex = -1;
}

// If audio is already playing, queue this blob for sequential playback.
if (this.currentAudioElement) {
this.audioPlayQueue.push(blob);
Expand Down Expand Up @@ -1580,6 +1615,7 @@ window.openAIChatManager = function () {
this.playAudioBlob(nextBlob);
} else {
this.isPlayingAudio = false;
this.ttsPlayingMessageIndex = -1;
this.conversationModeOnAudioEnded();
}
},
Expand All @@ -1592,6 +1628,7 @@ window.openAIChatManager = function () {
this.audioChunks = [];
this.audioPlayQueue = [];
this.isPlayingAudio = false;
this.ttsPlayingMessageIndex = -1;
},
toggleConversationMode() {
if (this.isConversationMode) {
Expand Down
Loading
Loading