mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix: hydrate chat files from artifact ledger (#4232)
Co-authored-by: Sami Rusani <sr@samirusani> Co-authored-by: M3gA-Mind <megamind@mahadao.com>
This commit is contained in:
co-authored by
Sami Rusani
M3gA-Mind
parent
06220ada5d
commit
57bac2a07a
@@ -1,7 +1,9 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { useAppSelector } from '../../store/hooks';
|
||||
import { listArtifactsForThread } from '../../services/artifactDownloadService';
|
||||
import { upsertArtifactReadyForThread } from '../../store/chatRuntimeSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||
import ChatFilesPanel from './ChatFilesPanel';
|
||||
|
||||
/**
|
||||
@@ -22,17 +24,53 @@ export interface ChatFilesChipProps {
|
||||
|
||||
export default function ChatFilesChip({ threadId }: ChatFilesChipProps) {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const [open, setOpen] = useState(false);
|
||||
const normalizedThreadId = threadId.trim();
|
||||
const artifactsByThread = useAppSelector(state => state.chatRuntime.artifactsByThread);
|
||||
const allForThread = artifactsByThread[threadId] ?? [];
|
||||
// Only ready artifacts are listable — in_progress / failed live above the
|
||||
// composer until they resolve.
|
||||
const readyArtifacts = useMemo(
|
||||
() => allForThread.filter(a => a.status === 'ready'),
|
||||
[allForThread]
|
||||
() => (artifactsByThread[normalizedThreadId] ?? []).filter(a => a.status === 'ready'),
|
||||
[artifactsByThread, normalizedThreadId]
|
||||
);
|
||||
const count = readyArtifacts.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedThreadId) return;
|
||||
let cancelled = false;
|
||||
void listArtifactsForThread(normalizedThreadId).then(outcome => {
|
||||
if (cancelled) return;
|
||||
if (!outcome.ok) {
|
||||
console.warn('[artifact] ChatFilesChip failed to hydrate thread artifacts', {
|
||||
threadId: normalizedThreadId,
|
||||
error: outcome.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (outcome.artifacts.length === 0) return;
|
||||
console.debug('[artifact] ChatFilesChip hydrating thread artifacts', {
|
||||
threadId: normalizedThreadId,
|
||||
count: outcome.artifacts.length,
|
||||
});
|
||||
for (const artifact of outcome.artifacts) {
|
||||
dispatch(
|
||||
upsertArtifactReadyForThread({
|
||||
threadId: normalizedThreadId,
|
||||
artifactId: artifact.artifactId,
|
||||
kind: artifact.kind,
|
||||
title: artifact.title,
|
||||
path: artifact.path,
|
||||
sizeBytes: artifact.sizeBytes,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [dispatch, normalizedThreadId]);
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -67,7 +105,7 @@ export default function ChatFilesChip({ threadId }: ChatFilesChipProps) {
|
||||
</button>
|
||||
{open && (
|
||||
<ChatFilesPanel
|
||||
threadId={threadId}
|
||||
threadId={normalizedThreadId}
|
||||
artifacts={readyArtifacts}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { listArtifactsForThread } from '../../../services/artifactDownloadService';
|
||||
import chatRuntimeReducer, {
|
||||
type ArtifactSnapshot,
|
||||
upsertArtifactInProgressForThread,
|
||||
@@ -10,6 +11,13 @@ import chatRuntimeReducer, {
|
||||
} from '../../../store/chatRuntimeSlice';
|
||||
import ChatFilesChip from '../ChatFilesChip';
|
||||
|
||||
vi.mock('../../../services/artifactDownloadService', () => ({
|
||||
listArtifactsForThread: vi.fn(),
|
||||
downloadArtifact: vi.fn(),
|
||||
deleteArtifact: vi.fn(),
|
||||
revealArtifactInFileManager: vi.fn(),
|
||||
}));
|
||||
|
||||
const THREAD = 't-chip-1';
|
||||
|
||||
function mkStore() {
|
||||
@@ -29,6 +37,11 @@ function readyArtifact(
|
||||
}
|
||||
|
||||
describe('ChatFilesChip', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(listArtifactsForThread).mockResolvedValue({ ok: true, artifacts: [] });
|
||||
});
|
||||
|
||||
it('renders nothing when the thread has zero ready artifacts', () => {
|
||||
const store = mkStore();
|
||||
const { container } = render(
|
||||
@@ -41,6 +54,94 @@ describe('ChatFilesChip', () => {
|
||||
expect(screen.queryByTestId('chat-files-chip')).toBeNull();
|
||||
});
|
||||
|
||||
it('hydrates thread artifacts from disk when redux starts cold', async () => {
|
||||
vi.mocked(listArtifactsForThread).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
artifacts: [
|
||||
{
|
||||
artifactId: 'art-cold',
|
||||
kind: 'document',
|
||||
title: 'Cold Redux Report',
|
||||
path: 'artifacts/art-cold/report.pdf',
|
||||
sizeBytes: 2048,
|
||||
},
|
||||
],
|
||||
});
|
||||
const store = mkStore();
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<ChatFilesChip threadId={THREAD} />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-files-chip-count')).toHaveTextContent('1');
|
||||
});
|
||||
expect(listArtifactsForThread).toHaveBeenCalledWith(THREAD);
|
||||
expect(store.getState().chatRuntime.artifactsByThread[THREAD]).toMatchObject([
|
||||
{
|
||||
artifactId: 'art-cold',
|
||||
kind: 'document',
|
||||
title: 'Cold Redux Report',
|
||||
status: 'ready',
|
||||
path: 'artifacts/art-cold/report.pdf',
|
||||
sizeBytes: 2048,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the normalized thread id for hydration and redux lookup', async () => {
|
||||
vi.mocked(listArtifactsForThread).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
artifacts: [
|
||||
{
|
||||
artifactId: 'art-trimmed',
|
||||
kind: 'document',
|
||||
title: 'Trimmed Thread Report',
|
||||
path: 'artifacts/art-trimmed/report.pdf',
|
||||
sizeBytes: 4096,
|
||||
},
|
||||
],
|
||||
});
|
||||
const store = mkStore();
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<ChatFilesChip threadId={` ${THREAD} `} />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-files-chip-count')).toHaveTextContent('1');
|
||||
});
|
||||
expect(listArtifactsForThread).toHaveBeenCalledWith(THREAD);
|
||||
expect(store.getState().chatRuntime.artifactsByThread[THREAD]).toMatchObject([
|
||||
{ artifactId: 'art-trimmed', status: 'ready', path: 'artifacts/art-trimmed/report.pdf' },
|
||||
]);
|
||||
expect(store.getState().chatRuntime.artifactsByThread[` ${THREAD} `]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the chip hidden when disk hydration fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.mocked(listArtifactsForThread).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
artifacts: [],
|
||||
error: 'core offline',
|
||||
});
|
||||
const store = mkStore();
|
||||
const { container } = render(
|
||||
<Provider store={store}>
|
||||
<ChatFilesChip threadId={THREAD} />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listArtifactsForThread).toHaveBeenCalledWith(THREAD);
|
||||
});
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(store.getState().chatRuntime.artifactsByThread[THREAD]).toBeUndefined();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('hides itself when only in_progress artifacts exist (those live above composer)', () => {
|
||||
const store = mkStore();
|
||||
store.dispatch(
|
||||
|
||||
@@ -15,6 +15,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
deleteArtifact,
|
||||
downloadArtifact,
|
||||
listArtifactsForThread,
|
||||
revealArtifactInFileManager,
|
||||
saveArtifactViaDialog,
|
||||
} from '../artifactDownloadService';
|
||||
@@ -37,6 +38,143 @@ vi.mock('@tauri-apps/plugin-opener', () => ({
|
||||
revealItemInDir: (...args: unknown[]) => hoisted.revealItemInDir(...args),
|
||||
}));
|
||||
|
||||
describe('listArtifactsForThread', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns an error for empty / whitespace thread ids without calling the RPC', async () => {
|
||||
const outcome = await listArtifactsForThread(' ');
|
||||
expect(outcome).toEqual({ ok: false, artifacts: [], error: 'thread id missing' });
|
||||
expect(callCoreRpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls ai_list_artifacts with a thread filter and maps ready artifacts', async () => {
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
artifacts: [
|
||||
{
|
||||
id: 'art-1',
|
||||
kind: 'document',
|
||||
title: 'Report',
|
||||
path: 'artifacts/art-1/report.pdf',
|
||||
size_bytes: 1234,
|
||||
status: 'ready',
|
||||
},
|
||||
{
|
||||
id: 'art-pending',
|
||||
kind: 'presentation',
|
||||
title: 'Pending Deck',
|
||||
path: 'artifacts/art-pending/deck.pptx',
|
||||
size_bytes: 55,
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'art-bad-kind',
|
||||
kind: 'video',
|
||||
title: 'Unsupported',
|
||||
path: 'artifacts/art-bad-kind/video.mp4',
|
||||
size_bytes: 99,
|
||||
status: 'ready',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const outcome = await listArtifactsForThread(' thread-1 ');
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.ai_list_artifacts',
|
||||
params: { thread_id: 'thread-1', offset: 0, limit: 200 },
|
||||
});
|
||||
expect(outcome).toEqual({
|
||||
ok: true,
|
||||
artifacts: [
|
||||
{
|
||||
artifactId: 'art-1',
|
||||
kind: 'document',
|
||||
title: 'Report',
|
||||
path: 'artifacts/art-1/report.pdf',
|
||||
sizeBytes: 1234,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches subsequent artifact pages before returning mapped artifacts', async () => {
|
||||
const firstPage = Array.from({ length: 200 }, (_, idx) => ({
|
||||
id: `pending-${idx}`,
|
||||
kind: 'document',
|
||||
title: `Pending ${idx}`,
|
||||
path: `artifacts/pending-${idx}/pending.pdf`,
|
||||
size_bytes: idx + 1,
|
||||
status: 'pending',
|
||||
}));
|
||||
firstPage[0] = {
|
||||
id: 'art-page-1',
|
||||
kind: 'document',
|
||||
title: 'First Page Report',
|
||||
path: 'artifacts/art-page-1/report.pdf',
|
||||
size_bytes: 100,
|
||||
status: 'ready',
|
||||
};
|
||||
vi.mocked(callCoreRpc)
|
||||
.mockResolvedValueOnce({ artifacts: firstPage })
|
||||
.mockResolvedValueOnce({
|
||||
artifacts: [
|
||||
{
|
||||
id: 'art-page-2',
|
||||
kind: 'image',
|
||||
title: 'Second Page Image',
|
||||
path: 'artifacts/art-page-2/image.png',
|
||||
size_bytes: 200,
|
||||
status: 'ready',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const outcome = await listArtifactsForThread('thread-1');
|
||||
|
||||
expect(callCoreRpc).toHaveBeenNthCalledWith(1, {
|
||||
method: 'openhuman.ai_list_artifacts',
|
||||
params: { thread_id: 'thread-1', offset: 0, limit: 200 },
|
||||
});
|
||||
expect(callCoreRpc).toHaveBeenNthCalledWith(2, {
|
||||
method: 'openhuman.ai_list_artifacts',
|
||||
params: { thread_id: 'thread-1', offset: 200, limit: 200 },
|
||||
});
|
||||
expect(outcome).toEqual({
|
||||
ok: true,
|
||||
artifacts: [
|
||||
{
|
||||
artifactId: 'art-page-1',
|
||||
kind: 'document',
|
||||
title: 'First Page Report',
|
||||
path: 'artifacts/art-page-1/report.pdf',
|
||||
sizeBytes: 100,
|
||||
},
|
||||
{
|
||||
artifactId: 'art-page-2',
|
||||
kind: 'image',
|
||||
title: 'Second Page Image',
|
||||
path: 'artifacts/art-page-2/image.png',
|
||||
sizeBytes: 200,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty list for nullish core payloads', async () => {
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce(null);
|
||||
const outcome = await listArtifactsForThread('thread-1');
|
||||
expect(outcome).toEqual({ ok: true, artifacts: [] });
|
||||
});
|
||||
|
||||
it('returns a failed outcome when the core RPC throws', async () => {
|
||||
vi.mocked(callCoreRpc).mockRejectedValueOnce(new Error('rpc down'));
|
||||
const outcome = await listArtifactsForThread('thread-1');
|
||||
expect(outcome).toEqual({ ok: false, artifacts: [], error: 'rpc down' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadArtifact', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -70,6 +70,22 @@ export interface DeleteArtifactOutcome {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ListedArtifactKind = 'presentation' | 'document' | 'image' | 'other';
|
||||
|
||||
export interface ListedThreadArtifact {
|
||||
artifactId: string;
|
||||
kind: ListedArtifactKind;
|
||||
title: string;
|
||||
path: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
export interface ListThreadArtifactsOutcome {
|
||||
ok: boolean;
|
||||
artifacts: ListedThreadArtifact[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the `data` field returned by the
|
||||
* `openhuman.ai_get_artifact` JSON-RPC method. We pull only the
|
||||
@@ -89,6 +105,78 @@ interface ResolvedExport {
|
||||
}
|
||||
type ResolveExportResult = ResolvedExport | { ok: false; code: ArtifactErrorCode; error: string };
|
||||
|
||||
interface AiListArtifactsData {
|
||||
artifacts?: AiListArtifactMeta[];
|
||||
}
|
||||
|
||||
interface AiListArtifactMeta {
|
||||
id?: string;
|
||||
kind?: string;
|
||||
title?: string;
|
||||
path?: string;
|
||||
size_bytes?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
function listedArtifactKind(raw: string | undefined): ListedArtifactKind | null {
|
||||
switch (raw) {
|
||||
case 'presentation':
|
||||
case 'document':
|
||||
case 'image':
|
||||
case 'other':
|
||||
return raw;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readyListedArtifact(meta: AiListArtifactMeta): ListedThreadArtifact | null {
|
||||
if (meta.status !== 'ready') return null;
|
||||
const kind = listedArtifactKind(meta.kind);
|
||||
if (!kind) return null;
|
||||
if (!meta.id || !meta.title || !meta.path) return null;
|
||||
if (typeof meta.size_bytes !== 'number' || !Number.isFinite(meta.size_bytes)) return null;
|
||||
return {
|
||||
artifactId: meta.id,
|
||||
kind,
|
||||
title: meta.title,
|
||||
path: meta.path,
|
||||
sizeBytes: meta.size_bytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listArtifactsForThread(
|
||||
threadId: string
|
||||
): Promise<ListThreadArtifactsOutcome> {
|
||||
const trimmedThreadId = threadId.trim();
|
||||
if (!trimmedThreadId) {
|
||||
return { ok: false, artifacts: [], error: 'thread id missing' };
|
||||
}
|
||||
try {
|
||||
const limit = 200;
|
||||
const artifacts: ListedThreadArtifact[] = [];
|
||||
|
||||
for (let offset = 0; ; offset += limit) {
|
||||
const raw = await callCoreRpc<AiListArtifactsData>({
|
||||
method: 'openhuman.ai_list_artifacts',
|
||||
params: { thread_id: trimmedThreadId, offset, limit },
|
||||
});
|
||||
const page = raw?.artifacts ?? [];
|
||||
artifacts.push(
|
||||
...page
|
||||
.map(readyListedArtifact)
|
||||
.filter((artifact): artifact is ListedThreadArtifact => artifact !== null)
|
||||
);
|
||||
if (page.length < limit) break;
|
||||
}
|
||||
|
||||
return { ok: true, artifacts };
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, artifacts: [], error: reason };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an artifact's absolute on-disk path (via `ai_get_artifact`)
|
||||
* and build the suggested filename. Shared by the Save-As and Downloads
|
||||
|
||||
Reference in New Issue
Block a user