feat(reward-signals): emit content-free router-training feedback to gateway (v0.9.23)
Emit CONTENT-FREE reward signals to `POST {VITE_HANZO_API_URL}/v1/feedback` so
the router training loop gets production feedback. Payload carries ONLY
{request_id, signal, rating?} — never any prompt/response/tag/text/filename/code.
Fire-and-forget: never blocks UX, silent no-op on any failure.
The routing ledger keys each RoutingEvent on the upstream gateway response id
(chatcmpl-…/msg_…). The chat client previously held only locally-minted UUIDs,
so capture the REAL id server-side and thread it to the client as one field:
- server: ModelEndHandler captures `data.output.response_metadata.id ?? .id`
(the last CHAT_MODEL_END = the visible answer) into a shared `runMetadata`;
AgentClient surfaces it via `this.metadata` → `feedbackRequestId` on the saved
response message, riding the SSE final event + DB save to the client.
- schema: add optional `feedbackRequestId` to tMessageSchema (zod), IMessage
(mongo type) and the mongoose message schema (persists; SQLite keeps it in its
JSON doc blob automatically). Absent id ⇒ signal no-ops (never fabricated).
Client signals wired via new `client/src/utils/rewardSignal.ts`:
- thumbs up/down → up/down; regenerate → regenerate; copy → up (weak positive);
model-switch right after a response → switch (last assistant msg's id).
Dedicated cross-origin credentialed fetch (keepalive) to the Hanzo API — NOT the
same-origin chat backend — forwarding the end-user bearer for per-org attribution.
Honors local opt-out `VITE_HANZO_FEEDBACK=0|false|off` (server-side org/user
training opt-in is the preferred enforcement). 13 unit tests, all green.
Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
This commit is contained in:
@@ -16,12 +16,15 @@ const { saveBase64Image } = require('~/server/services/Files/process');
|
||||
class ModelEndHandler {
|
||||
/**
|
||||
* @param {Array<UsageMetadata>} collectedUsage
|
||||
* @param {{ requestId?: string }} [runMetadata] - Content-free run metadata. Captures the
|
||||
* upstream gateway response id (the routing-ledger join key) for reward-signal feedback.
|
||||
*/
|
||||
constructor(collectedUsage) {
|
||||
constructor(collectedUsage, runMetadata) {
|
||||
if (!Array.isArray(collectedUsage)) {
|
||||
throw new Error('collectedUsage must be an array');
|
||||
}
|
||||
this.collectedUsage = collectedUsage;
|
||||
this.runMetadata = runMetadata;
|
||||
}
|
||||
|
||||
finalize(errorMessage) {
|
||||
@@ -48,6 +51,17 @@ class ModelEndHandler {
|
||||
let errorMessage;
|
||||
try {
|
||||
const agentContext = graph.getAgentContext(metadata);
|
||||
/**
|
||||
* Capture the upstream gateway response id (e.g. `chatcmpl-…`/`msg_…`) — the
|
||||
* routing-ledger join key for CONTENT-FREE reward signals. The final model turn
|
||||
* (last CHAT_MODEL_END) wins, matching the visible assistant answer the user rates.
|
||||
*/
|
||||
if (this.runMetadata) {
|
||||
const upstreamId = data?.output?.response_metadata?.id ?? data?.output?.id;
|
||||
if (typeof upstreamId === 'string' && upstreamId) {
|
||||
this.runMetadata.requestId = upstreamId;
|
||||
}
|
||||
}
|
||||
if (data?.output?.additional_kwargs?.stop_reason === 'refusal') {
|
||||
const info = { ...data.output.additional_kwargs };
|
||||
errorMessage = JSON.stringify({
|
||||
@@ -121,6 +135,7 @@ async function emitEvent(res, streamId, eventData) {
|
||||
* @param {ContentAggregator} options.aggregateContent - Content aggregator function.
|
||||
* @param {ToolEndCallback} options.toolEndCallback - Callback to use when tool ends.
|
||||
* @param {Array<UsageMetadata>} options.collectedUsage - The list of collected usage metadata.
|
||||
* @param {{ requestId?: string }} [options.runMetadata] - Content-free run metadata (upstream response id).
|
||||
* @param {string | null} [options.streamId] - The stream ID for resumable mode, or null for standard mode.
|
||||
* @param {ToolExecuteOptions} [options.toolExecuteOptions] - Options for event-driven tool execution.
|
||||
* @returns {Record<string, t.EventHandler>} The default handlers.
|
||||
@@ -131,6 +146,7 @@ function getDefaultHandlers({
|
||||
aggregateContent,
|
||||
toolEndCallback,
|
||||
collectedUsage,
|
||||
runMetadata,
|
||||
streamId = null,
|
||||
toolExecuteOptions = null,
|
||||
}) {
|
||||
@@ -140,7 +156,7 @@ function getDefaultHandlers({
|
||||
);
|
||||
}
|
||||
const handlers = {
|
||||
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage),
|
||||
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage, runMetadata),
|
||||
[GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback, logger),
|
||||
[GraphEvents.ON_RUN_STEP]: {
|
||||
/**
|
||||
|
||||
@@ -74,6 +74,7 @@ class AgentClient extends BaseClient {
|
||||
agentConfigs,
|
||||
contentParts,
|
||||
collectedUsage,
|
||||
runMetadata,
|
||||
artifactPromises,
|
||||
maxContextTokens,
|
||||
...clientOptions
|
||||
@@ -85,6 +86,13 @@ class AgentClient extends BaseClient {
|
||||
this.contentParts = contentParts;
|
||||
/** @type {Array<UsageMetadata>} */
|
||||
this.collectedUsage = collectedUsage;
|
||||
/**
|
||||
* Content-free run metadata populated by the model-end handler. Holds ONLY the
|
||||
* upstream gateway response id (`requestId`) — surfaced on the saved response
|
||||
* message as `feedbackRequestId` (the routing-ledger join key for reward signals).
|
||||
* @type {{ requestId?: string } | undefined}
|
||||
*/
|
||||
this.runMetadata = runMetadata;
|
||||
/** @type {ArtifactPromises} */
|
||||
this.artifactPromises = artifactPromises;
|
||||
/** @type {AgentClientOptions} */
|
||||
@@ -934,6 +942,18 @@ class AgentClient extends BaseClient {
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
/**
|
||||
* Surface the captured upstream gateway response id on the response message as
|
||||
* `feedbackRequestId`. `BaseClient.sendMessage` spreads `this.metadata` onto the
|
||||
* response message, so the id rides the SSE final event + DB save to the client,
|
||||
* where it becomes the CONTENT-FREE reward-signal join key (routing-ledger).
|
||||
*/
|
||||
if (this.runMetadata?.requestId) {
|
||||
this.metadata = {
|
||||
...(this.metadata ?? {}),
|
||||
feedbackRequestId: this.runMetadata.requestId,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const attachments = await this.awaitMemoryWithTimeout(memoryPromise);
|
||||
if (attachments && attachments.length > 0) {
|
||||
|
||||
@@ -91,6 +91,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
||||
|
||||
/** @type {Array<UsageMetadata>} */
|
||||
const collectedUsage = [];
|
||||
/**
|
||||
* Content-free run metadata shared with the model-end handler and the client.
|
||||
* Holds ONLY the upstream gateway response id (`requestId`) — the routing-ledger
|
||||
* join key threaded to the client for reward-signal feedback.
|
||||
* @type {{ requestId?: string }}
|
||||
*/
|
||||
const runMetadata = {};
|
||||
/** @type {ArtifactPromises} */
|
||||
const artifactPromises = [];
|
||||
const { contentParts, aggregateContent } = createContentAggregator();
|
||||
@@ -139,6 +146,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
||||
aggregateContent,
|
||||
toolEndCallback,
|
||||
collectedUsage,
|
||||
runMetadata,
|
||||
streamId,
|
||||
});
|
||||
|
||||
@@ -394,6 +402,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
||||
agentConfigs,
|
||||
eventHandlers,
|
||||
collectedUsage,
|
||||
runMetadata,
|
||||
aggregateContent,
|
||||
artifactPromises,
|
||||
agent: primaryConfig,
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import React, { createContext, useContext, useState, useMemo, useCallback } from 'react';
|
||||
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from '@hanzochat/data-provider';
|
||||
import {
|
||||
QueryKeys,
|
||||
EModelEndpoint,
|
||||
isAgentsEndpoint,
|
||||
isAssistantsEndpoint,
|
||||
} from '@hanzochat/data-provider';
|
||||
import type * as t from '@hanzochat/data-provider';
|
||||
import type { Endpoint, SelectedValues } from '~/common';
|
||||
import { sendRewardSignal } from '~/utils/rewardSignal';
|
||||
import {
|
||||
useAgentDefaultPermissionLevel,
|
||||
useSelectorEffects,
|
||||
@@ -55,6 +62,7 @@ interface ModelSelectorProviderProps {
|
||||
}
|
||||
|
||||
export function ModelSelectorProvider({ children, startupConfig }: ModelSelectorProviderProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const assistantsMap = useAssistantsMapContext();
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
@@ -207,6 +215,25 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
|
||||
};
|
||||
|
||||
const handleSelectModel = (endpoint: Endpoint, model: string) => {
|
||||
/**
|
||||
* Model-switch reward signal: when the user switches model/endpoint right after a
|
||||
* response, credit the LAST assistant message's upstream id (the routing-ledger join
|
||||
* key). No-op when nothing actually changed or no last-response id exists.
|
||||
*/
|
||||
const conversationId = conversation?.conversationId;
|
||||
const isActualChange =
|
||||
endpoint.value !== conversation?.endpoint || model !== conversation?.model;
|
||||
if (isActualChange && conversationId) {
|
||||
const messages = queryClient.getQueryData<t.TMessage[]>([QueryKeys.messages, conversationId]);
|
||||
const lastAssistant = messages
|
||||
?.slice()
|
||||
.reverse()
|
||||
.find((m) => m && m.isCreatedByUser === false && m.feedbackRequestId);
|
||||
if (lastAssistant?.feedbackRequestId) {
|
||||
sendRewardSignal(lastAssistant.feedbackRequestId, 'switch');
|
||||
}
|
||||
}
|
||||
|
||||
if (isAgentsEndpoint(endpoint.value)) {
|
||||
onSelectEndpoint?.(endpoint.value, {
|
||||
agent_id: model,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useChatContext, useAssistantsMapContext, useAgentsMapContext } from '~/
|
||||
import useCopyToClipboard from './useCopyToClipboard';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import { useGetAddedConvo } from '~/hooks/Chat';
|
||||
import { sendRewardSignal } from '~/utils/rewardSignal';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
@@ -39,7 +40,7 @@ export default function useMessageActions(props: TMessageActions) {
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
|
||||
const { text, content, messageId = null, isCreatedByUser } = message ?? {};
|
||||
const { text, content, messageId = null, isCreatedByUser, feedbackRequestId } = message ?? {};
|
||||
const edit = useMemo(() => messageId === currentEditId, [messageId, currentEditId]);
|
||||
|
||||
const [feedback, setFeedback] = useState<TFeedback | undefined>(() => {
|
||||
@@ -95,10 +96,19 @@ export default function useMessageActions(props: TMessageActions) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendRewardSignal(feedbackRequestId, 'regenerate');
|
||||
regenerate(message, { addedConvo: getAddedConvo() });
|
||||
}, [isSubmitting, isCreatedByUser, message, regenerate, getAddedConvo]);
|
||||
}, [isSubmitting, isCreatedByUser, message, regenerate, getAddedConvo, feedbackRequestId]);
|
||||
|
||||
const copyToClipboard = useCopyToClipboard({ text, content, searchResults });
|
||||
const copyMessage = useCopyToClipboard({ text, content, searchResults });
|
||||
const copyToClipboard = useCallback(
|
||||
(setIsCopied: Parameters<typeof copyMessage>[0]) => {
|
||||
// Copy is a weak positive signal; the contract has no weights, so send "up".
|
||||
sendRewardSignal(feedbackRequestId, 'up');
|
||||
return copyMessage(setIsCopied);
|
||||
},
|
||||
[copyMessage, feedbackRequestId],
|
||||
);
|
||||
|
||||
const messageLabel = useMemo(() => {
|
||||
if (message?.isCreatedByUser === true) {
|
||||
@@ -123,6 +133,13 @@ export default function useMessageActions(props: TMessageActions) {
|
||||
feedback: newFeedback ? toMinimalFeedback(newFeedback) : undefined,
|
||||
};
|
||||
|
||||
// Content-free reward signal: thumbs up/down → "up"/"down" (join key = feedbackRequestId).
|
||||
if (newFeedback?.rating === 'thumbsUp') {
|
||||
sendRewardSignal(feedbackRequestId, 'up');
|
||||
} else if (newFeedback?.rating === 'thumbsDown') {
|
||||
sendRewardSignal(feedbackRequestId, 'down');
|
||||
}
|
||||
|
||||
feedbackMutation.mutate(payload, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.feedback) {
|
||||
@@ -141,7 +158,7 @@ export default function useMessageActions(props: TMessageActions) {
|
||||
},
|
||||
});
|
||||
},
|
||||
[feedbackMutation],
|
||||
[feedbackMutation, feedbackRequestId],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import axios from 'axios';
|
||||
import { sendRewardSignal } from './rewardSignal';
|
||||
|
||||
/**
|
||||
* `import.meta.env.VITE_*` is rewritten to `process.env.VITE_*` by
|
||||
* `babel-plugin-transform-vite-meta-env` (runtime read), so tests toggle env via `process.env`.
|
||||
*/
|
||||
describe('sendRewardSignal', () => {
|
||||
const BASE = 'https://api.hanzo.ai';
|
||||
let fetchMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = jest.fn(() => Promise.resolve({ ok: true } as Response));
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
process.env.VITE_HANZO_API_URL = BASE;
|
||||
delete process.env.VITE_HANZO_FEEDBACK;
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.VITE_HANZO_API_URL;
|
||||
delete process.env.VITE_HANZO_FEEDBACK;
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
});
|
||||
|
||||
const initOf = (call = 0) => fetchMock.mock.calls[call][1] as RequestInit;
|
||||
const urlOf = (call = 0) => fetchMock.mock.calls[call][0] as string;
|
||||
const bodyOf = (call = 0) => JSON.parse(initOf(call).body as string);
|
||||
|
||||
it('POSTs to {base}/v1/feedback with exactly {request_id, signal} for "up"', () => {
|
||||
sendRewardSignal('chatcmpl-up', 'up');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(urlOf()).toBe('https://api.hanzo.ai/v1/feedback');
|
||||
const init = initOf();
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(init.keepalive).toBe(true);
|
||||
expect(bodyOf()).toEqual({ request_id: 'chatcmpl-up', signal: 'up' });
|
||||
});
|
||||
|
||||
it('serializes each signal string exactly and never adds extra fields', () => {
|
||||
const signals = ['up', 'down', 'regenerate', 'switch', 'abandon', 'accept', 'revert'] as const;
|
||||
signals.forEach((signal, i) => sendRewardSignal(`id-${signal}-${i}`, signal));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(signals.length);
|
||||
signals.forEach((signal, i) => {
|
||||
expect(bodyOf(i)).toEqual({ request_id: `id-${signal}-${i}`, signal });
|
||||
expect(bodyOf(i)).not.toHaveProperty('rating');
|
||||
});
|
||||
});
|
||||
|
||||
it('includes rating ONLY when signal is "rating"', () => {
|
||||
sendRewardSignal('rate-1', 'rating', 3);
|
||||
expect(bodyOf()).toEqual({ request_id: 'rate-1', signal: 'rating', rating: 3 });
|
||||
expect(Object.keys(bodyOf()).sort()).toEqual(['rating', 'request_id', 'signal']);
|
||||
});
|
||||
|
||||
it('omits rating for non-rating signals even when a rating arg is passed', () => {
|
||||
sendRewardSignal('up-with-rating', 'up', 2);
|
||||
expect(bodyOf()).toEqual({ request_id: 'up-with-rating', signal: 'up' });
|
||||
expect(bodyOf()).not.toHaveProperty('rating');
|
||||
});
|
||||
|
||||
it('makes NO network call when requestId is missing', () => {
|
||||
sendRewardSignal(undefined, 'up');
|
||||
sendRewardSignal(null, 'down');
|
||||
sendRewardSignal('', 'regenerate');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('makes NO network call when the base URL is unset', () => {
|
||||
delete process.env.VITE_HANZO_API_URL;
|
||||
sendRewardSignal('no-base', 'up');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('makes NO network call when opted out via VITE_HANZO_FEEDBACK (0/false/off)', () => {
|
||||
['0', 'false', 'off'].forEach((val) => {
|
||||
process.env.VITE_HANZO_FEEDBACK = val;
|
||||
sendRewardSignal(`opt-${val}`, 'up');
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mirrors the Authorization bearer from axios defaults; always sends JSON content-type', () => {
|
||||
axios.defaults.headers.common['Authorization'] = 'Bearer end-user-token';
|
||||
sendRewardSignal('auth-1', 'up');
|
||||
const headers = initOf().headers as Record<string, string>;
|
||||
expect(headers['Authorization']).toBe('Bearer end-user-token');
|
||||
expect(headers['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('sends no Authorization header when no bearer is set', () => {
|
||||
sendRewardSignal('auth-2', 'up');
|
||||
const headers = initOf().headers as Record<string, string>;
|
||||
expect(headers).not.toHaveProperty('Authorization');
|
||||
});
|
||||
|
||||
it('strips a trailing slash from the base URL', () => {
|
||||
process.env.VITE_HANZO_API_URL = 'https://api.hanzo.ai/';
|
||||
sendRewardSignal('slash-1', 'up');
|
||||
expect(urlOf()).toBe('https://api.hanzo.ai/v1/feedback');
|
||||
});
|
||||
|
||||
it('dedupes the same signal for the same request id', () => {
|
||||
sendRewardSignal('dupe-1', 'up');
|
||||
sendRewardSignal('dupe-1', 'up');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does NOT dedupe different ratings for the same request id', () => {
|
||||
sendRewardSignal('rate-multi', 'rating', 1);
|
||||
sendRewardSignal('rate-multi', 'rating', 2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('never throws and swallows a rejected fetch (fire-and-forget)', () => {
|
||||
fetchMock.mockImplementationOnce(() => Promise.reject(new Error('network down')));
|
||||
expect(() => sendRewardSignal('reject-1', 'up')).not.toThrow();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* CONTENT-FREE reward signals emitted to the Hanzo gateway (`POST {base}/v1/feedback`)
|
||||
* so router training gets production feedback.
|
||||
*
|
||||
* The payload carries ONLY `{ request_id, signal, rating? }` — never any prompt, response,
|
||||
* tag, free-text, filename, or code. `request_id` is the upstream gateway response id
|
||||
* (`chatcmpl-…`/`msg_…`) that the routing ledger keys each RoutingEvent on; the chat client
|
||||
* receives it as a message's `feedbackRequestId`. When it is missing (e.g. a direct,
|
||||
* non-gateway provider path) the signal simply no-ops — we never fabricate an id.
|
||||
*
|
||||
* Transport is fire-and-forget: it NEVER blocks UX and is a SILENT no-op on any failure.
|
||||
*/
|
||||
export type RewardSignal =
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'regenerate'
|
||||
| 'switch'
|
||||
| 'abandon'
|
||||
| 'accept'
|
||||
| 'revert'
|
||||
| 'rating';
|
||||
|
||||
type RewardSignalBody = {
|
||||
request_id: string;
|
||||
signal: RewardSignal;
|
||||
rating?: number;
|
||||
};
|
||||
|
||||
/** Dedupe key set — avoids double-sending the same signal for the same message id. */
|
||||
const sent = new Set<string>();
|
||||
|
||||
/**
|
||||
* Whether reward-signal emission is disabled via the local opt-out flag.
|
||||
*
|
||||
* Server-side org/user training opt-in is the PREFERRED enforcement and, when present,
|
||||
* makes this client gating redundant — but the client still honors the local opt-out.
|
||||
*/
|
||||
function isOptedOut(): boolean {
|
||||
const flag = import.meta.env.VITE_HANZO_FEEDBACK;
|
||||
return flag === '0' || flag === 'false' || flag === 'off';
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a content-free reward signal for the given upstream request id.
|
||||
*
|
||||
* @param requestId - The message's `feedbackRequestId` (upstream gateway response id). No-op if absent.
|
||||
* @param signal - The reward signal.
|
||||
* @param rating - Only sent when `signal === 'rating'` (0-3).
|
||||
*/
|
||||
export function sendRewardSignal(
|
||||
requestId: string | null | undefined,
|
||||
signal: RewardSignal,
|
||||
rating?: number,
|
||||
): void {
|
||||
if (!requestId || !signal) {
|
||||
return;
|
||||
}
|
||||
if (isOptedOut()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const base = import.meta.env.VITE_HANZO_API_URL;
|
||||
if (!base) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body: RewardSignalBody = { request_id: requestId, signal };
|
||||
if (signal === 'rating' && typeof rating === 'number') {
|
||||
body.rating = rating;
|
||||
}
|
||||
|
||||
const dedupeKey =
|
||||
signal === 'rating' ? `${requestId}:${signal}:${body.rating}` : `${requestId}:${signal}`;
|
||||
if (sent.has(dedupeKey)) {
|
||||
return;
|
||||
}
|
||||
sent.add(dedupeKey);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
/** Mirror `setTokenHeader`: forward the end-user bearer for per-org attribution. */
|
||||
const authorization = axios.defaults.headers.common?.['Authorization'];
|
||||
if (typeof authorization === 'string' && authorization) {
|
||||
headers['Authorization'] = authorization;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated cross-origin call to the Hanzo API — NOT the same-origin chat backend.
|
||||
* `credentials: 'include'` reuses the IAM cross-origin session; `keepalive` lets late
|
||||
* signals (switch/abandon) survive navigation.
|
||||
*/
|
||||
void fetch(`${String(base).replace(/\/+$/, '')}/v1/feedback`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => {
|
||||
/* silent no-op */
|
||||
});
|
||||
} catch {
|
||||
/* silent no-op */
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzochat/chat",
|
||||
"version": "0.9.22",
|
||||
"version": "0.9.23",
|
||||
"description": "Chat - Unified interface to Agents, LLMs, MCPs and any AI model or OpenAPI documented API with full RAG stack",
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"workspaces": [
|
||||
|
||||
@@ -611,6 +611,12 @@ export const tMessageSchema = z.object({
|
||||
/* frontend components */
|
||||
iconURL: z.string().nullable().optional(),
|
||||
feedback: feedbackSchema.optional(),
|
||||
/**
|
||||
* Upstream gateway response id (e.g. `chatcmpl-…`/`msg_…`) for the assistant message.
|
||||
* The CONTENT-FREE reward-signal join key (routing ledger). Absent for direct/non-gateway
|
||||
* responses — signals simply no-op when it is missing.
|
||||
*/
|
||||
feedbackRequestId: z.string().nullable().optional(),
|
||||
/** metadata */
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
Vendored
+4
@@ -2667,6 +2667,10 @@ const messageSchema = new mongoose.Schema({
|
||||
iconURL: {
|
||||
type: String,
|
||||
},
|
||||
/** Upstream gateway response id — the content-free reward-signal join key (routing ledger). */
|
||||
feedbackRequestId: {
|
||||
type: String,
|
||||
},
|
||||
metadata: { type: mongoose.Schema.Types.Mixed },
|
||||
attachments: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined },
|
||||
/*
|
||||
|
||||
+4
@@ -2665,6 +2665,10 @@ const messageSchema = new Schema({
|
||||
iconURL: {
|
||||
type: String,
|
||||
},
|
||||
/** Upstream gateway response id — the content-free reward-signal join key (routing ledger). */
|
||||
feedbackRequestId: {
|
||||
type: String,
|
||||
},
|
||||
metadata: { type: mongoose.Schema.Types.Mixed },
|
||||
attachments: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined },
|
||||
/*
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface IMessage extends Document {
|
||||
thread_id?: string;
|
||||
iconURL?: string;
|
||||
addedConvo?: boolean;
|
||||
/** Upstream gateway response id — the content-free reward-signal join key (routing ledger). */
|
||||
feedbackRequestId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
attachments?: unknown[];
|
||||
expiredAt?: Date;
|
||||
|
||||
@@ -118,6 +118,10 @@ const messageSchema: Schema<IMessage> = new Schema(
|
||||
iconURL: {
|
||||
type: String,
|
||||
},
|
||||
/** Upstream gateway response id — the content-free reward-signal join key (routing ledger). */
|
||||
feedbackRequestId: {
|
||||
type: String,
|
||||
},
|
||||
metadata: { type: mongoose.Schema.Types.Mixed },
|
||||
attachments: { type: [{ type: mongoose.Schema.Types.Mixed }], default: undefined },
|
||||
/*
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface IMessage extends Document {
|
||||
thread_id?: string;
|
||||
iconURL?: string;
|
||||
addedConvo?: boolean;
|
||||
/** Upstream gateway response id — the content-free reward-signal join key (routing ledger). */
|
||||
feedbackRequestId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
attachments?: unknown[];
|
||||
expiredAt?: Date;
|
||||
|
||||
Reference in New Issue
Block a user