feat(frontend): viewer humain fil-par-paire en lecture seule (LS7)
Frontend pur, lecture seule, consomme read_conversation_page (LS6) ; aucun changement backend. - adapters : conversation.ts (gateway) + conversationNormalization.ts. - features/conversations : useConversationThread, ConversationViewer, index. - domain (types LS7), ports (port + Gateways.conversation), adapters/index, mock (+ clé inventaire). - features/workstate : ProjectWorkStatePanel prop onOpenConversation. - features/projects : ProjectsView swap viewer. - tests (QA, verts) : conversationNormalization, mock/conversationGateway, useConversationThread, ConversationViewer, ProjectsView.ls7. tsc 0, vitest 443/443 (+36). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
67
frontend/src/adapters/conversationNormalization.ts
Normal file
67
frontend/src/adapters/conversationNormalization.ts
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Wire → domain normalization for the conversation transcript read (LS7).
|
||||
*
|
||||
* Mirrors the LS6 backend DTO (`crates/app-tauri/src/dto.rs`): `TurnPageDto`
|
||||
* (camelCase `turns` / `hasMore` / `nextAnchor`), `TurnViewDto`
|
||||
* (`id` / `atMs` / `role` / `source` / `text` / `textLen`) and the serde-tagged
|
||||
* `TurnSourceDto` (`{ kind: "human" }` | `{ kind: "agent", agentId }`). The
|
||||
* `source` mapping is calqued on `workStateNormalization.ts`.
|
||||
*/
|
||||
|
||||
import type { TurnPage, TurnRole, TurnSource, TurnView } from "@/domain";
|
||||
|
||||
type RecordLike = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is RecordLike {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
/** Mirror of LS6 `TurnSourceDto` (serde tag `kind`, camelCase `agentId`). */
|
||||
function normalizeSource(value: unknown): TurnSource {
|
||||
if (!isRecord(value)) return { kind: "human" };
|
||||
if (value.kind === "agent") {
|
||||
return { kind: "agent", agentId: stringValue(value.agentId, "unknown") };
|
||||
}
|
||||
return { kind: "human" };
|
||||
}
|
||||
|
||||
/** Mirror of the camelCase-serialized `TurnRole` (`prompt`/`response`/`toolActivity`). */
|
||||
function normalizeRole(value: unknown): TurnRole {
|
||||
return value === "response" || value === "toolActivity" ? value : "prompt";
|
||||
}
|
||||
|
||||
function normalizeTurn(value: unknown, index: number): TurnView {
|
||||
const turn = isRecord(value) ? value : {};
|
||||
const text = stringValue(turn.text);
|
||||
return {
|
||||
id: stringValue(turn.id, `turn-${index}`),
|
||||
atMs: numberValue(turn.atMs, index),
|
||||
role: normalizeRole(turn.role),
|
||||
source: normalizeSource(turn.source),
|
||||
text,
|
||||
textLen: numberValue(turn.textLen, text.length),
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps a raw `TurnPageDto` payload to the domain {@link TurnPage}. */
|
||||
export function normalizeTurnPage(value: unknown): TurnPage {
|
||||
const page = isRecord(value) ? value : {};
|
||||
const nextAnchor = page.nextAnchor;
|
||||
return {
|
||||
turns: arrayValue(page.turns).map(normalizeTurn),
|
||||
hasMore: page.hasMore === true,
|
||||
...(typeof nextAnchor === "string" ? { nextAnchor } : {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user