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: 1 addition & 1 deletion libs/chat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/chat",
"version": "0.0.10",
"version": "0.0.11",
"exports": {
".": {
"types": "./index.d.ts",
Expand Down
34 changes: 34 additions & 0 deletions libs/chat/src/lib/compositions/chat/chat.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { ChatGenerativeUiComponent } from '../../primitives/chat-generative-ui/c
import { ChatStreamingMdComponent } from '../../streaming/streaming-markdown.component';
import { ChatToolCallsComponent } from '../../primitives/chat-tool-calls/chat-tool-calls.component';
import { ChatSubagentsComponent } from '../../primitives/chat-subagents/chat-subagents.component';
import { ChatMessageActionsComponent } from '../../primitives/chat-message-actions/chat-message-actions.component';
import { A2uiSurfaceComponent } from '../../a2ui/surface.component';
import { createContentClassifier, type ContentClassifier } from '../../streaming/content-classifier';
import { messageContent } from '../shared/message-utils';
Expand All @@ -39,6 +40,7 @@ import type { ChatRenderEvent } from './chat-render-event';
ChatInputComponent, ChatTypingIndicatorComponent, ChatErrorComponent, ChatInterruptComponent,
ChatThreadListComponent, ChatGenerativeUiComponent,
ChatStreamingMdComponent, ChatToolCallsComponent, ChatSubagentsComponent, A2uiSurfaceComponent,
ChatMessageActionsComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [CHAT_HOST_TOKENS, `
Expand Down Expand Up @@ -146,6 +148,13 @@ import type { ChatRenderEvent } from './chat-render-event';
/>
}
}
<chat-message-actions
chatMessageControls
[content]="content"
(regenerate)="onRegenerate()"
(rate)="onRate(message, $event)"
(contentCopied)="onCopy(message, $event)"
/>
</chat-message>
</ng-template>

Expand Down Expand Up @@ -179,6 +188,12 @@ export class ChatComponent {
readonly activeThreadId = input<string>('');
readonly threadSelected = output<string>();
readonly renderEvent = output<ChatRenderEvent>();
/** Emitted when the user clicks the regenerate button on an assistant message. */
readonly regenerate = output<void>();
/** Emitted when the user rates an assistant message. */
readonly rate = output<{ messageIndex: number; rating: 'up' | 'down' }>();
/** Emitted when the user copies an assistant message. */
readonly messageCopy = output<{ messageIndex: number; content: string }>();

private readonly _internalStore = signalStateStore({});
readonly resolvedStore = computed(() => {
Expand Down Expand Up @@ -279,4 +294,23 @@ export class ChatComponent {
onA2uiEvent(event: RenderEvent, messageIndex: number, surfaceId: string): void {
this.renderEvent.emit({ messageIndex, surfaceId, event });
}

/** Regenerate the last assistant response by re-running the previous submit. */
onRegenerate(): void {
const a = this.agent();
if (typeof (a as { reload?: () => void }).reload === 'function') {
(a as unknown as { reload: () => void | Promise<void> }).reload();
}
this.regenerate.emit();
}

onRate(message: unknown, value: 'up' | 'down'): void {
const idx = this.agent().messages().indexOf(message as never);
this.rate.emit({ messageIndex: idx, rating: value });
}

onCopy(message: unknown, content: string): void {
const idx = this.agent().messages().indexOf(message as never);
this.messageCopy.emit({ messageIndex: idx, content });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// libs/chat/src/lib/primitives/chat-message-actions/chat-message-actions.component.ts
// SPDX-License-Identifier: MIT
import { Component, ChangeDetectionStrategy, input, output, signal, inject, DOCUMENT } from '@angular/core';
import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens';
import { CHAT_MESSAGE_ACTIONS_STYLES } from '../../styles/chat-message-actions.styles';

/**
* Default action buttons that appear under each assistant message:
* regenerate, copy-to-clipboard, thumbs up, thumbs down.
*
* Hidden by default, fades in on `:hover`/`:focus-within` of the parent
* `chat-message` (and is always visible on the current/last assistant
* message and on mobile). Mirrors copilotkit's AssistantMessage controls.
*/
@Component({
selector: 'chat-message-actions',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [CHAT_HOST_TOKENS, CHAT_MESSAGE_ACTIONS_STYLES],
host: {
'role': 'toolbar',
'[attr.aria-label]': '"Message actions"',
},
template: `
<button
type="button"
class="chat-message-actions__btn"
aria-label="Regenerate response"
title="Regenerate"
(click)="regenerate.emit()"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 0 1 15.5-6.36L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-15.5 6.36L3 16" />
<path d="M3 21v-5h5" />
</svg>
</button>
<button
type="button"
class="chat-message-actions__btn"
[class.is-active]="copied()"
[attr.aria-label]="copied() ? 'Copied' : 'Copy to clipboard'"
[title]="copied() ? 'Copied' : 'Copy'"
(click)="onCopy()"
>
@if (copied()) {
<span class="chat-message-actions__check" aria-hidden="true">✓</span>
} @else {
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
</svg>
}
</button>
<button
type="button"
class="chat-message-actions__btn"
[class.is-active]="rating() === 'up'"
aria-label="Thumbs up"
title="Good response"
(click)="onRate('up')"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7 11V20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V12a1 1 0 0 1 1-1h3z" />
<path d="M7 11l4-7a2 2 0 0 1 2-2h0a2 2 0 0 1 2 2v4h5a2 2 0 0 1 2 2l-2 7a2 2 0 0 1-2 1.5H7" />
</svg>
</button>
<button
type="button"
class="chat-message-actions__btn"
[class.is-active]="rating() === 'down'"
aria-label="Thumbs down"
title="Poor response"
(click)="onRate('down')"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M17 13V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-3z" />
<path d="M17 13l-4 7a2 2 0 0 1-2 2h0a2 2 0 0 1-2-2v-4H4a2 2 0 0 1-2-2l2-7A2 2 0 0 1 6 5h11" />
</svg>
</button>
`,
})
export class ChatMessageActionsComponent {
/** Plain text content to copy. Required for the copy button to function. */
readonly content = input<string>('');

/** Emitted when the user clicks regenerate. Wire this to `agent.reload()`. */
readonly regenerate = output<void>();
/** Emitted with 'up' or 'down' when the user rates the response. */
readonly rate = output<'up' | 'down'>();
/** Emitted with the copied content after a successful clipboard write. */
readonly contentCopied = output<string>();

protected readonly copied = signal(false);
protected readonly rating = signal<'up' | 'down' | null>(null);
private readonly document = inject(DOCUMENT);

protected async onCopy(): Promise<void> {
const text = this.content();
if (!text) return;
try {
const win = this.document.defaultView;
if (win?.navigator?.clipboard?.writeText) {
await win.navigator.clipboard.writeText(text);
} else {
const ta = this.document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
this.document.body.appendChild(ta);
ta.select();
this.document.execCommand?.('copy');
ta.remove();
}
this.copied.set(true);
this.contentCopied.emit(text);
setTimeout(() => this.copied.set(false), 2000);
} catch {
// Silent fail — clipboard may be blocked by permissions.
}
}

protected onRate(value: 'up' | 'down'): void {
// Toggle off when clicking the same rating.
this.rating.set(this.rating() === value ? null : value);
this.rate.emit(value);
}
}
72 changes: 72 additions & 0 deletions libs/chat/src/lib/styles/chat-message-actions.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// libs/chat/src/lib/styles/chat-message-actions.styles.ts
// SPDX-License-Identifier: MIT
//
// Action-button row underneath assistant messages. Mirrors copilotkit's
// AssistantMessage controls — hidden by default, fades in on hover/focus
// of the parent chat-message, always visible on mobile.
export const CHAT_MESSAGE_ACTIONS_STYLES = `
:host {
display: flex;
gap: 0.5rem;
padding: 4px 0 0 0;
opacity: 0;
transition: opacity 200ms ease;
pointer-events: none;
}
:host-context(chat-message[data-role="assistant"]:hover),
:host-context(chat-message[data-role="assistant"]:focus-within),
:host-context(chat-message[data-role="assistant"][data-current="true"]) {
opacity: 1;
pointer-events: auto;
}
:host-context(chat-message[data-streaming="true"]) {
/* Hide while the message is actively streaming — copilotkit pattern. */
opacity: 0 !important;
pointer-events: none !important;
}
@media (max-width: 768px) {
:host-context(chat-message[data-role="assistant"]) {
opacity: 1;
pointer-events: auto;
}
}
.chat-message-actions__btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: 0;
padding: 0;
margin: 0;
border-radius: 6px;
background: transparent;
color: var(--ngaf-chat-text-muted);
cursor: pointer;
transition: color 150ms ease, transform 150ms ease, background 150ms ease;
}
.chat-message-actions__btn:hover {
color: var(--ngaf-chat-text);
transform: scale(1.05);
background: var(--ngaf-chat-surface-alt);
}
.chat-message-actions__btn:focus-visible {
outline: 2px solid var(--ngaf-chat-text-muted);
outline-offset: 2px;
}
.chat-message-actions__btn.is-active {
color: var(--ngaf-chat-text);
background: var(--ngaf-chat-surface-alt);
}
.chat-message-actions__btn svg {
width: 16px;
height: 16px;
pointer-events: none;
}
.chat-message-actions__check {
font-size: 14px;
font-weight: 700;
line-height: 1;
color: var(--ngaf-chat-success, #16a34a);
}
`;
7 changes: 5 additions & 2 deletions libs/chat/src/lib/styles/chat-message.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,12 @@ export const CHAT_MESSAGE_STYLES = `
.chat-message__caret {
display: none;
margin-left: 2px;
width: 0.6ch;
margin-top: 0.25rem;
color: var(--ngaf-chat-text-muted);
animation: ngaf-chat-caret-blink 1.2s step-end infinite;
/* Smooth pulse curve (copilotkit-style) — easier on the eyes than a
hard step-end blink, especially during long streams. */
animation: ngaf-chat-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
vertical-align: text-bottom;
}
:host([data-role="assistant"][data-current="true"][data-streaming="true"]) .chat-message__caret {
display: inline-block;
Expand Down
1 change: 1 addition & 0 deletions libs/chat/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export { ChatMessageListComponent, getMessageType } from './lib/primitives/chat-
export { MessageTemplateDirective } from './lib/primitives/chat-message-list/message-template.directive';
export { ChatMessageComponent } from './lib/primitives/chat-message/chat-message.component';
export type { ChatMessageRole } from './lib/primitives/chat-message/chat-message.component';
export { ChatMessageActionsComponent } from './lib/primitives/chat-message-actions/chat-message-actions.component';
export { ChatWindowComponent } from './lib/primitives/chat-window/chat-window.component';
export { ChatTraceComponent } from './lib/primitives/chat-trace/chat-trace.component';
export type { TraceState } from './lib/primitives/chat-trace/chat-trace.component';
Expand Down
2 changes: 1 addition & 1 deletion libs/langgraph/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngaf/langgraph",
"version": "0.0.6",
"version": "0.0.9",
"peerDependencies": {
"@ngaf/chat": "*",
"@ngaf/licensing": "*",
Expand Down
7 changes: 6 additions & 1 deletion libs/langgraph/src/lib/agent.fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,12 @@ function buildSubmitPayload(input: AgentSubmitInput | null | undefined): unknown
const content = typeof input.message === 'string'
? input.message
: input.message.map((b: ContentBlock) => (b.type === 'text' ? b.text : JSON.stringify(b))).join('');
return { messages: [{ role: 'human', content }], ...(input.state ?? {}) };
// `type: 'human'` is what `toMessage()` reads via `_getType` || raw['type'];
// `role: 'human'` is what the LangGraph server expects in submit payloads.
// Include both so the optimistic local copy projects as a 'user' bubble
// (otherwise toMessage falls through to the 'ai' default and renders the
// user's question as an assistant message).
return { messages: [{ type: 'human', role: 'human', content }], ...(input.state ?? {}) };
}
return input.state ?? {};
}
Expand Down
17 changes: 10 additions & 7 deletions libs/langgraph/src/lib/internals/stream-manager.bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,13 +368,16 @@ export function createStreamManagerBridge<T, ResolvedBag extends BagTemplate = B
const projected = options.toMessage
? stateMessages.map(options.toMessage)
: (stateMessages as BaseMessage[]);
// Preserve existing message ids when content matches. Server-
// echoed human messages and final AI messages often arrive with
// different ids than the optimistic / partial we already have —
// letting that id swap reach chat-message-list's track-by-id
// tears down DOM mid-stream. preserveIds maps new messages to
// existing-id-by-content where possible.
subjects.messages$.next(preserveIds(subjects.messages$.value, projected));
// Preserve existing ids by content match (server echo / final-id swap).
const remapped = preserveIds(subjects.messages$.value, projected);
// ALWAYS merge values-derived messages into existing rather
// than replacing. LangGraph emits intermediate values events
// during streaming where state.messages can lag behind what
// we've already seen via messages-tuple — replacing would
// drop the partial AI (or even the optimistic human) and
// tear down their DOM mid-stream. Merge by id keeps both,
// updates content where ids match, preserves the rest.
subjects.messages$.next(mergeMessages(subjects.messages$.value, remapped));
syncSubagentsFromMessages(stateMessages as BaseMessage[]);
subagentManager.reconstructFromMessages(
stateMessages as BaseMessage[],
Expand Down
Loading