Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
72eb5204ff
45 files changed
+2355
-847
No files matched your search
@@ -96,6 +96,26 @@ export async function getAgentSelector(): Promise<AgentSelectorResponse> {
|
||||
return res.json() as Promise<AgentSelectorResponse>;
|
||||
}
|
||||
|
||||
export async function favoriteAgent(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to favorite agent: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function unfavoriteAgent(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to unfavorite agent: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents/featured/order`,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
adminSyncAgentSkills,
|
||||
createAgent,
|
||||
deleteAgent,
|
||||
favoriteAgent,
|
||||
getAgent,
|
||||
getAgentSelector,
|
||||
listAgents,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
reorderFeaturedAgents,
|
||||
setAgentFeatured,
|
||||
setAgentPrivate,
|
||||
unfavoriteAgent,
|
||||
updateAgent,
|
||||
} from "./api";
|
||||
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
@@ -112,6 +114,34 @@ export function useDeleteAgent() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of
|
||||
* ``is_favorite`` across every cached agent listing + detail so the star
|
||||
* reacts instantly before the round-trip completes. */
|
||||
export function useToggleAgentFavorite() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
|
||||
favorite ? favoriteAgent(id) : unfavoriteAgent(id),
|
||||
onMutate: ({ id, favorite }) => {
|
||||
queryClient.setQueriesData<Agent[] | undefined>(
|
||||
{ queryKey: ["agents"] },
|
||||
(old) =>
|
||||
Array.isArray(old)
|
||||
? old.map((a) =>
|
||||
a.id === id ? { ...a, is_favorite: favorite } : a,
|
||||
)
|
||||
: old,
|
||||
);
|
||||
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
|
||||
old ? { ...old, is_favorite: favorite } : old,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentSelector() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", "selector"],
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface Agent {
|
||||
skills?: string[] | null;
|
||||
tags?: Tag[];
|
||||
featured_order?: number | null;
|
||||
/** Whether the current user has favorited (收藏) this agent. */
|
||||
is_favorite?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentSelectorResponse {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* Durable, browser-local record of when each chat question was asked, keyed by
|
||||
* the message id. Captured at send time and reconciled to the server-assigned
|
||||
* message id (see ``useThreadStream``), so the time survives reloads and does
|
||||
* not depend on the backend round-tripping a timestamp on the message.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "deerflow.chat-ask-times";
|
||||
|
||||
type TimeMap = Record<string, string>;
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
let cache: TimeMap | null = null;
|
||||
const EMPTY: TimeMap = {};
|
||||
const listeners = new Set<() => void>();
|
||||
let storageBound = false;
|
||||
|
||||
function readFromStorage(): TimeMap {
|
||||
if (!isBrowser()) return EMPTY;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return parsed && typeof parsed === "object" ? (parsed as TimeMap) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function getSnapshot(): TimeMap {
|
||||
if (cache === null) cache = readFromStorage();
|
||||
return cache;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): TimeMap {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
function commit(next: TimeMap): void {
|
||||
cache = next;
|
||||
if (isBrowser()) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// Keep the in-memory cache even if persistence fails.
|
||||
}
|
||||
}
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
if (isBrowser() && !storageBound) {
|
||||
storageBound = true;
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === STORAGE_KEY || event.key === null) {
|
||||
cache = readFromStorage();
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Record the ask time for a message id. First write wins (the true ask time). */
|
||||
export function rememberAskTime(messageId: string, iso: string): void {
|
||||
if (!messageId) return;
|
||||
const current = getSnapshot();
|
||||
if (current[messageId]) return;
|
||||
commit({ ...current, [messageId]: iso });
|
||||
}
|
||||
|
||||
export function getAskTime(messageId: string): string | undefined {
|
||||
return getSnapshot()[messageId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record several message times at once (first write wins per id). Used to stamp
|
||||
* a freshly loaded/streamed batch of messages in a single commit so a long
|
||||
* conversation doesn't trigger one storage write + re-render per message.
|
||||
*/
|
||||
export function rememberAskTimes(entries: Record<string, string>): void {
|
||||
const current = getSnapshot();
|
||||
let changed = false;
|
||||
const next: TimeMap = { ...current };
|
||||
for (const [id, iso] of Object.entries(entries)) {
|
||||
if (id && !next[id]) {
|
||||
next[id] = iso;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) commit(next);
|
||||
}
|
||||
|
||||
/** Reactive map of messageId → ISO ask time. */
|
||||
export function useChatAskTimes(): TimeMap {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
/** A user-defined reusable prompt, bound to the account (stored server-side). */
|
||||
export interface CustomPrompt {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
sort_order?: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
const QUERY_KEY = ["user-prompts"] as const;
|
||||
const EMPTY_PROMPTS: CustomPrompt[] = [];
|
||||
const baseUrl = () => `${getBackendBaseURL()}/api/user-prompts`;
|
||||
|
||||
async function listUserPrompts(): Promise<CustomPrompt[]> {
|
||||
const res = await apiFetch(baseUrl());
|
||||
if (!res.ok) throw new Error(`Failed to load prompts: ${res.statusText}`);
|
||||
const data = (await res.json()) as { prompts: CustomPrompt[] };
|
||||
return data.prompts;
|
||||
}
|
||||
|
||||
async function createUserPrompt(input: {
|
||||
title: string;
|
||||
content: string;
|
||||
}): Promise<CustomPrompt> {
|
||||
const res = await apiFetch(baseUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to create prompt: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<CustomPrompt>;
|
||||
}
|
||||
|
||||
async function updateUserPrompt(
|
||||
id: string,
|
||||
input: { title?: string; content?: string },
|
||||
): Promise<CustomPrompt> {
|
||||
const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to update prompt: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<CustomPrompt>;
|
||||
}
|
||||
|
||||
async function deleteUserPrompt(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to delete prompt: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reactive list of the current user's saved prompts (server-backed). */
|
||||
export function useCustomPrompts(): CustomPrompt[] {
|
||||
const { data } = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: listUserPrompts,
|
||||
});
|
||||
return data ?? EMPTY_PROMPTS;
|
||||
}
|
||||
|
||||
export function useCreateCustomPrompt() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createUserPrompt,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCustomPrompt() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
input,
|
||||
}: {
|
||||
id: string;
|
||||
input: { title?: string; content?: string };
|
||||
}) => updateUserPrompt(id, input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCustomPrompt() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteUserPrompt,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export type SearchResultItem = {
|
||||
snippet?: string;
|
||||
content?: string;
|
||||
source?: string;
|
||||
author?: string;
|
||||
published_date?: string;
|
||||
publishedDate?: string;
|
||||
date?: string;
|
||||
@@ -111,6 +112,15 @@ const SOURCE_ALIASES = [
|
||||
"kb",
|
||||
] as const;
|
||||
|
||||
const AUTHOR_ALIASES = [
|
||||
"author",
|
||||
"authors",
|
||||
"creator",
|
||||
"writer",
|
||||
"byline",
|
||||
"owner",
|
||||
] as const;
|
||||
|
||||
const DATE_ALIASES = [
|
||||
"published_date",
|
||||
"publishedDate",
|
||||
@@ -131,6 +141,8 @@ const CONTENT_KEY_PATTERN =
|
||||
const URL_KEY_PATTERN = /(?:^|_)(?:url|href|link|uri)(?:$|_)/i;
|
||||
const SOURCE_KEY_PATTERN =
|
||||
/(?:^|_)(?:source|origin|site|publisher|domain|from|channel|database|kb)(?:$|_)/i;
|
||||
const AUTHOR_KEY_PATTERN =
|
||||
/(?:^|_)(?:author|authors|creator|writer|byline|owner)(?:$|_)/i;
|
||||
const DATE_KEY_PATTERN =
|
||||
/(?:^|_)(?:date|time|published|publish|created|updated)(?:$|_)/i;
|
||||
|
||||
@@ -301,6 +313,12 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
excludeUrl: true,
|
||||
maxLength: 120,
|
||||
});
|
||||
const author =
|
||||
pickFromLayers(layers, AUTHOR_ALIASES) ??
|
||||
pickByKeyPattern(layers, AUTHOR_KEY_PATTERN, {
|
||||
excludeUrl: true,
|
||||
maxLength: 120,
|
||||
});
|
||||
const date =
|
||||
pickFromLayers(layers, DATE_ALIASES) ??
|
||||
pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 });
|
||||
@@ -310,7 +328,7 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]);
|
||||
|
||||
const reserved = new Set<string>();
|
||||
for (const value of [title, content, url, source, date]) {
|
||||
for (const value of [title, content, url, source, author, date]) {
|
||||
if (value) {
|
||||
reserved.add(value);
|
||||
}
|
||||
@@ -341,6 +359,7 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
snippet: content,
|
||||
content,
|
||||
source,
|
||||
author,
|
||||
published_date: date,
|
||||
publishedDate: date,
|
||||
date,
|
||||
|
||||
@@ -51,6 +51,7 @@ export async function createScheduledTask(data: {
|
||||
schedule: string;
|
||||
timezone?: string | null;
|
||||
execution_agent_name?: string | null;
|
||||
require_markdown?: boolean;
|
||||
}) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
await apiFetch(baseUrl(), {
|
||||
@@ -210,6 +211,7 @@ export async function updateScheduledTask(
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
execution_agent_name: string | null;
|
||||
require_markdown: boolean;
|
||||
}>,
|
||||
) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
@@ -337,6 +339,7 @@ export async function adminUpdateScheduledTask(
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
execution_agent_name: string | null;
|
||||
require_markdown: boolean;
|
||||
}>,
|
||||
) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
|
||||
@@ -20,6 +20,12 @@ export type ScheduledTask = {
|
||||
cron_expr: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Free-form execution context persisted with the task. ``require_markdown``
|
||||
* lives here (rather than a dedicated column) so the feature needs no schema
|
||||
* migration.
|
||||
*/
|
||||
execution_context?: { require_markdown?: boolean; [key: string]: unknown } | null;
|
||||
next_run_at?: string | null;
|
||||
last_run_at?: string | null;
|
||||
last_status?: string | null;
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface DisplaySourceItem {
|
||||
type: string;
|
||||
snippet: string;
|
||||
source?: string;
|
||||
author?: string;
|
||||
time?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
@@ -25,6 +26,7 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
|
||||
summaryField: null,
|
||||
contentField: null,
|
||||
sourceField: null,
|
||||
authorField: null,
|
||||
typeField: null,
|
||||
timeField: null,
|
||||
scoreField: null,
|
||||
@@ -43,6 +45,7 @@ const candidateFields: Record<string, string[]> = {
|
||||
summaryField: ["summary", "snippet", "content_preview", "description", "abstract"],
|
||||
contentField: ["content", "page_content", "text", "content_preview", "body"],
|
||||
sourceField: ["source", "source_name", "site", "from", "origin"],
|
||||
authorField: ["author", "authors", "creator", "writer", "byline", "publisher", "owner"],
|
||||
typeField: ["type", "category", "source_category", "source1"],
|
||||
timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"],
|
||||
scoreField: ["relevance_score", "rerank_score", "score", "similarity"],
|
||||
@@ -107,6 +110,7 @@ export function inferDisplayConfig(samplePayload: unknown, skillName?: string):
|
||||
inferred.summaryField = inferred.summaryField || "content_preview";
|
||||
inferred.contentField = inferred.contentField || "content_preview";
|
||||
inferred.sourceField = inferred.sourceField || "source";
|
||||
inferred.authorField = inferred.authorField || pickField(sample, candidateFields.authorField);
|
||||
inferred.typeField = inferred.typeField || "source_category";
|
||||
inferred.timeField = inferred.timeField || "publish_time";
|
||||
inferred.scoreField = inferred.scoreField || "relevance_score";
|
||||
@@ -127,6 +131,7 @@ function buildDefaultTableColumns(
|
||||
config.titleField,
|
||||
config.typeField,
|
||||
config.sourceField,
|
||||
config.authorField,
|
||||
config.timeField,
|
||||
config.scoreField,
|
||||
].filter((field): field is string => !!field);
|
||||
@@ -141,6 +146,16 @@ function buildDefaultTableColumns(
|
||||
|
||||
function stringify(value: unknown, fallback = ""): string {
|
||||
if (value === null || value === undefined) return fallback;
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map((item) =>
|
||||
typeof item === "string" || typeof item === "number" || typeof item === "boolean"
|
||||
? String(item).trim()
|
||||
: "",
|
||||
)
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? parts.join("、") : fallback;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
@@ -174,6 +189,7 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
|
||||
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
|
||||
content: config.contentField ? stringify(item[config.contentField]) : undefined,
|
||||
source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
|
||||
author: config.authorField ? stringify(item[config.authorField]) : undefined,
|
||||
time: config.timeField ? stringify(item[config.timeField]) : undefined,
|
||||
score:
|
||||
config.scoreField && typeof item[config.scoreField] === "number"
|
||||
|
||||
@@ -499,6 +499,7 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
|
||||
snippet: item.snippet ?? item.content ?? "",
|
||||
content: item.content ?? item.snippet,
|
||||
source: item.source,
|
||||
author: item.author,
|
||||
time: item.published_date ?? item.publishedDate ?? item.date,
|
||||
url: item.url,
|
||||
raw: item as unknown as Record<string, unknown>,
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface SkillDisplayConfig {
|
||||
summaryField?: string | null;
|
||||
contentField?: string | null;
|
||||
sourceField?: string | null;
|
||||
authorField?: string | null;
|
||||
typeField?: string | null;
|
||||
timeField?: string | null;
|
||||
scoreField?: string | null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { getAPIClient } from "../api";
|
||||
import { apiFetch } from "../api";
|
||||
import { getStoredAuth } from "../auth";
|
||||
import { rememberAskTime } from "../chat-timestamps";
|
||||
import { getBackendBaseURL } from "../config";
|
||||
import { useI18n } from "../i18n/hooks";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
@@ -378,6 +379,13 @@ export function useThreadStream({
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const sendInFlightRef = useRef(false);
|
||||
// Carries the ask time of an in-flight send until the server-assigned human
|
||||
// message id shows up, so we can bind the timestamp to the real id (see the
|
||||
// reconciliation effect below). ``known`` is the set of human ids that
|
||||
// already existed at send time, used to spot the newly-arrived one.
|
||||
const pendingAskTimeRef = useRef<{ ts: string; known: Set<string> } | null>(
|
||||
null,
|
||||
);
|
||||
// Track message count before sending so we know when server has responded
|
||||
const prevMsgCountRef = useRef(thread.messages.length);
|
||||
|
||||
@@ -429,6 +437,26 @@ export function useThreadStream({
|
||||
}
|
||||
}, [thread.messages.length, optimisticMessages.length]);
|
||||
|
||||
// Bind a just-sent question's ask time to the server-assigned human message
|
||||
// id once it appears. Persists to localStorage so the time shows on every
|
||||
// question and survives reloads, independent of the backend echoing it back.
|
||||
useEffect(() => {
|
||||
const pending = pendingAskTimeRef.current;
|
||||
if (!pending) return;
|
||||
for (const message of thread.messages) {
|
||||
if (
|
||||
message.type === "human" &&
|
||||
message.id &&
|
||||
!message.id.startsWith("opt-") &&
|
||||
!pending.known.has(message.id)
|
||||
) {
|
||||
rememberAskTime(message.id, pending.ts);
|
||||
pendingAskTimeRef.current = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [thread.messages]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
threadId: string,
|
||||
@@ -442,6 +470,20 @@ export function useThreadStream({
|
||||
sendInFlightRef.current = true;
|
||||
|
||||
const text = message.text.trim();
|
||||
// Wall-clock time the question was asked. Stored on the human message so
|
||||
// the chat can show "几点提问" and have it survive a reload (it round-trips
|
||||
// through the checkpoint inside additional_kwargs, same as `files`).
|
||||
const clientTs = new Date().toISOString();
|
||||
// Remember which human messages already exist so the reconciliation
|
||||
// effect can bind ``clientTs`` to the new one once the server returns it.
|
||||
pendingAskTimeRef.current = {
|
||||
ts: clientTs,
|
||||
known: new Set(
|
||||
thread.messages
|
||||
.filter((m) => m.type === "human" && m.id)
|
||||
.map((m) => m.id as string),
|
||||
),
|
||||
};
|
||||
|
||||
// Capture current count before showing optimistic messages
|
||||
prevMsgCountRef.current = thread.messages.length;
|
||||
@@ -458,6 +500,7 @@ export function useThreadStream({
|
||||
const hideFromUI = options?.additionalKwargs?.hide_from_ui === true;
|
||||
const optimisticAdditionalKwargs = {
|
||||
...options?.additionalKwargs,
|
||||
client_ts: clientTs,
|
||||
...(optimisticFiles.length > 0 ? { files: optimisticFiles } : {}),
|
||||
};
|
||||
|
||||
@@ -535,7 +578,10 @@ export function useThreadStream({
|
||||
return [
|
||||
{
|
||||
...humanMessage,
|
||||
additional_kwargs: { files: uploadedFiles },
|
||||
additional_kwargs: {
|
||||
...humanMessage.additional_kwargs,
|
||||
files: uploadedFiles,
|
||||
},
|
||||
},
|
||||
...messages.slice(1),
|
||||
];
|
||||
@@ -585,6 +631,7 @@ export function useThreadStream({
|
||||
],
|
||||
additional_kwargs: {
|
||||
...options?.additionalKwargs,
|
||||
client_ts: clientTs,
|
||||
...(filesForSubmit.length > 0
|
||||
? { files: filesForSubmit }
|
||||
: {}),
|
||||
|
||||
Reference in new issue
Block a user