Preserve and display the generating model for each assistant reply
This commit is contained in:
+1
-1
@@ -43,7 +43,7 @@ Default dev URL: `http://localhost:5173`
|
||||
prefixes, so `plan` also finds `planning`.
|
||||
- Right panel:
|
||||
- Chat mode: transcript + provider/model controls.
|
||||
- Saved assistant replies show their received date/time in your local timezone, with Copy and Fork actions aligned beneath the reply on mobile and desktop. Copy preserves the message's Markdown; Fork starts a conversation through that reply.
|
||||
- Saved assistant replies show their received date/time in your local timezone and the provider/model used for that response, with Copy and Fork actions aligned beneath the reply on mobile and desktop. The model label stays with the response when you change models; older replies without a reliable record show “Model not recorded.” Copy preserves the message's Markdown; Fork starts a conversation through that reply.
|
||||
- Search mode: top AI answer block + Google-style Exa results view.
|
||||
- Composer adapts to the active item:
|
||||
- Chat sends `POST /v1/chat-completions/stream` (SSE).
|
||||
|
||||
+1
-9
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import type { TargetedTouchEvent } from "preact";
|
||||
import { getProviderLabel } from "@/lib/message-generation";
|
||||
import {
|
||||
Check,
|
||||
Bot,
|
||||
@@ -551,15 +552,6 @@ function pickProviderModel(options: string[], preferred: string | null) {
|
||||
return options[0] ?? "";
|
||||
}
|
||||
|
||||
function getProviderLabel(provider: Provider | null | undefined) {
|
||||
if (provider === "openai") return "OpenAI";
|
||||
if (provider === "anthropic") return "Anthropic";
|
||||
if (provider === "xai") return "xAI";
|
||||
if (provider === "gemini") return "Gemini";
|
||||
if (provider === "hermes-agent") return "Hermes Agent";
|
||||
return "";
|
||||
}
|
||||
|
||||
function getToolLabel(name: string) {
|
||||
if (name === "web_search") return "Web search";
|
||||
if (name === "fetch_url") return "Fetch URL";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { Check, Copy, GitFork } from "lucide-preact";
|
||||
import type { Message } from "@/lib/api";
|
||||
import { getMessageGenerationLabel } from "@/lib/message-generation";
|
||||
|
||||
const receivedFormat = new Intl.DateTimeFormat(undefined, {
|
||||
year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
|
||||
@@ -28,12 +29,16 @@ export function AssistantMessageFooter({ message, onFork, isForking = false }: {
|
||||
};
|
||||
const received = new Date(message.createdAt);
|
||||
const hasDate = Number.isFinite(received.getTime());
|
||||
const generation = getMessageGenerationLabel(message);
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground" data-assistant-footer={message.id} data-mobile-swipe-ignore>
|
||||
{hasDate ? <time className="block text-xs leading-5" dateTime={message.createdAt} title={`Received ${received.toLocaleString()}`}>
|
||||
{receivedFormat.format(received)}
|
||||
</time> : null}
|
||||
<div className="text-xs leading-5 [overflow-wrap:anywhere]">
|
||||
{hasDate ? <><time className="whitespace-nowrap" dateTime={message.createdAt} title={`Received ${received.toLocaleString()}`}>
|
||||
{receivedFormat.format(received)}
|
||||
</time><span aria-hidden="true"> · </span></> : null}
|
||||
<span data-message-generation>{generation ?? "Model not recorded"}</span>
|
||||
</div>
|
||||
<div className="-ml-2 flex flex-wrap items-center gap-1" role="group" aria-label="Message actions">
|
||||
{message.content.trim() ? <button type="button" className={actionClass} onClick={() => void copy()} title="Copy message" aria-label="Copy message">
|
||||
{copyState === "copied" ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : <Copy className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Message } from "./api";
|
||||
|
||||
export function getProviderLabel(provider: unknown) {
|
||||
if (provider === "openai") return "OpenAI";
|
||||
if (provider === "anthropic") return "Anthropic";
|
||||
if (provider === "xai") return "xAI";
|
||||
if (provider === "gemini") return "Gemini";
|
||||
if (provider === "hermes-agent") return "Hermes Agent";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function getMessageGenerationLabel(message: Pick<Message, "role" | "metadata">): string | null {
|
||||
if (message.role !== "assistant" || !message.metadata || typeof message.metadata !== "object" || Array.isArray(message.metadata)) return null;
|
||||
const metadata = message.metadata as Record<string, unknown>;
|
||||
if (metadata.agentAside === true || !metadata.generation || typeof metadata.generation !== "object" || Array.isArray(metadata.generation)) return null;
|
||||
const generation = metadata.generation as Record<string, unknown>;
|
||||
const provider = getProviderLabel(generation.provider);
|
||||
return provider && typeof generation.model === "string" && generation.model.trim() ? `${provider} · ${generation.model.trim()}` : null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { getMessageGenerationLabel } from "../src/lib/message-generation.ts";
|
||||
|
||||
test("reply labels use the recorded provider and model", () => {
|
||||
for (const [provider, label] of [["openai", "OpenAI"], ["anthropic", "Anthropic"], ["xai", "xAI"], ["gemini", "Gemini"], ["hermes-agent", "Hermes Agent"]]) {
|
||||
assert.equal(getMessageGenerationLabel({ role: "assistant", metadata: { generation: { provider, model: "original-model" } } }), `${label} · original-model`);
|
||||
}
|
||||
});
|
||||
|
||||
test("missing or malformed attribution stays unknown and Agent asides keep their identity", () => {
|
||||
for (const metadata of [null, [], {}, { provider: "openai", model: "unrelated" }, { generation: { provider: "unknown", model: "model" } }, { generation: { provider: "openai", model: "" } }, { generation: { provider: "openai", model: [] } }, { generation: { provider: "openai", model: "model" }, agentAside: true }]) {
|
||||
assert.equal(getMessageGenerationLabel({ role: "assistant", metadata }), null);
|
||||
}
|
||||
});
|
||||
@@ -26,7 +26,7 @@ const markdown = [
|
||||
const chat = { id: "mobile-chat", title: "Mobile QA conversation with a long title", parentChatId: null, titleGenerationPending: false,
|
||||
createdAt: date, updatedAt: date, starred: false, starredAt: null, initiatedProvider: "openai", initiatedModel: "mobile-model-one",
|
||||
lastUsedProvider: "openai", lastUsedModel: "mobile-model-one", additionalSystemPrompt: null, enabledTools: [],
|
||||
messages: [{ id: "mobile-assistant", role: "assistant", content: markdown, createdAt: date, name: null, metadata: null }] };
|
||||
messages: [{ id: "mobile-assistant", role: "assistant", content: markdown, createdAt: date, name: null, metadata: { generation: { provider: "openai", model: "mobile-model-one" } } }] };
|
||||
const idle = { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] };
|
||||
const errors = [], calls = [], forkRequests = [];
|
||||
let forkedChat;
|
||||
@@ -119,6 +119,7 @@ async function checkFooterAlignment() {
|
||||
assert.notEqual(layout.dateColor, layout.paragraphColor, "timestamp uses secondary color");
|
||||
assert.equal(await footer.locator("time").getAttribute("datetime"), date);
|
||||
assert.match(await footer.locator("time").textContent(), /2026/);
|
||||
assert.equal(await footer.locator("[data-message-generation]").textContent(), "OpenAI · mobile-model-one");
|
||||
}
|
||||
try {
|
||||
await page.goto(target.href, { waitUntil: "networkidle" });
|
||||
@@ -261,6 +262,23 @@ try {
|
||||
const viewport = await page.locator('meta[name="viewport"]').getAttribute("content");
|
||||
assert.match(viewport, /user-scalable=no/);
|
||||
assert.match(viewport, /maximum-scale=1\.0(?:,|$)/);
|
||||
// A mixed-model history keeps each attribution after reload, including unknown old replies.
|
||||
const longModel = "claude-" + "long-model-identifier-".repeat(8);
|
||||
chat.messages.push(
|
||||
{ ...chat.messages[0], id: "second-assistant", content: "A reply from a different provider.", metadata: { generation: { provider: "anthropic", model: longModel } } },
|
||||
{ ...chat.messages[0], id: "legacy-assistant", content: "A reply with no generation record.", metadata: null },
|
||||
);
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await page.getByRole("heading", { name: chat.title, exact: true }).waitFor();
|
||||
assert.deepEqual(await page.locator("main [data-message-generation]").allTextContents(), ["OpenAI · mobile-model-one", `Anthropic · ${longModel}`, "Model not recorded"]);
|
||||
for (const width of [320, 390, 1280]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
if (width < 768) await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1);
|
||||
await page.locator('[data-assistant-footer="second-assistant"]').evaluate(el => el.scrollIntoView({ block: "center" }));
|
||||
assert.ok(await page.locator('[data-assistant-footer="second-assistant"]').evaluate(el => el.scrollWidth <= el.clientWidth), "long model identifier wraps within the footer");
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, `${width}px model-label overflow`);
|
||||
if (artifactDir) await page.screenshot({ path: `${artifactDir}/mixed-models-${width}.png` });
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForFunction(() => document.querySelector(".workspace-sidebar").getBoundingClientRect().right <= 1);
|
||||
await drag(300, 8, -160);
|
||||
|
||||
Reference in New Issue
Block a user