2026-04-19 06:39:06 +00:00
|
|
|
|
import i18n, { currentLocale } from "@/i18n";
|
|
|
|
|
|
|
2026-05-17 23:52:50 +08:00
|
|
|
|
const LOW_INFORMATION_TITLE_PREVIEWS = new Set([
|
|
|
|
|
|
"hi",
|
|
|
|
|
|
"hello",
|
|
|
|
|
|
"hey",
|
|
|
|
|
|
"hello nano",
|
|
|
|
|
|
"hello nanobot",
|
|
|
|
|
|
"hi nano",
|
|
|
|
|
|
"hi nanobot",
|
|
|
|
|
|
"你好",
|
|
|
|
|
|
"您好",
|
|
|
|
|
|
"嗨",
|
|
|
|
|
|
"哈喽",
|
|
|
|
|
|
"哈啰",
|
|
|
|
|
|
"在吗",
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
function isLowInformationTitlePreview(text: string): boolean {
|
|
|
|
|
|
const normalized = text.toLowerCase().replace(/[.!?。!?~~\s]+$/g, "").trim();
|
|
|
|
|
|
return (
|
|
|
|
|
|
normalized.startsWith("/") ||
|
|
|
|
|
|
LOW_INFORMATION_TITLE_PREVIEWS.has(normalized)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 18:51:53 +00:00
|
|
|
|
/** Truncate the first user message into a chat title. */
|
|
|
|
|
|
export function deriveTitle(preview: string | undefined, fallback: string): string {
|
|
|
|
|
|
if (!preview) return fallback;
|
|
|
|
|
|
const oneLine = preview.replace(/\s+/g, " ").trim();
|
|
|
|
|
|
if (!oneLine) return fallback;
|
2026-05-17 23:52:50 +08:00
|
|
|
|
if (isLowInformationTitlePreview(oneLine)) return fallback;
|
2026-04-18 18:51:53 +00:00
|
|
|
|
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Loose ISO-or-epoch parser; returns ``null`` for missing/invalid input. */
|
|
|
|
|
|
function parseDate(value: string | number | null | undefined): Date | null {
|
|
|
|
|
|
if (value === null || value === undefined || value === "") return null;
|
|
|
|
|
|
const d = new Date(value);
|
|
|
|
|
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
|
|
|
|
|
|
[60, "second"],
|
|
|
|
|
|
[60, "minute"],
|
|
|
|
|
|
[24, "hour"],
|
|
|
|
|
|
[7, "day"],
|
|
|
|
|
|
[4.345, "week"],
|
|
|
|
|
|
[12, "month"],
|
|
|
|
|
|
[Number.POSITIVE_INFINITY, "year"],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-04-19 06:39:06 +00:00
|
|
|
|
const relativeTimeFormatters = new Map<string, Intl.RelativeTimeFormat>();
|
|
|
|
|
|
const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>();
|
2026-04-18 18:51:53 +00:00
|
|
|
|
|
2026-04-19 06:39:06 +00:00
|
|
|
|
function activeLocale(locale?: string): string {
|
|
|
|
|
|
return locale || i18n.resolvedLanguage || i18n.language || currentLocale();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function relativeTimeFormatter(locale: string): Intl.RelativeTimeFormat {
|
|
|
|
|
|
const existing = relativeTimeFormatters.get(locale);
|
|
|
|
|
|
if (existing) return existing;
|
|
|
|
|
|
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
|
|
|
|
|
relativeTimeFormatters.set(locale, formatter);
|
|
|
|
|
|
return formatter;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function dateTimeFormatter(locale: string): Intl.DateTimeFormat {
|
|
|
|
|
|
const existing = dateTimeFormatters.get(locale);
|
|
|
|
|
|
if (existing) return existing;
|
|
|
|
|
|
const formatter = new Intl.DateTimeFormat(locale, {
|
|
|
|
|
|
dateStyle: "medium",
|
|
|
|
|
|
timeStyle: "short",
|
|
|
|
|
|
});
|
|
|
|
|
|
dateTimeFormatters.set(locale, formatter);
|
|
|
|
|
|
return formatter;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function relativeTime(
|
|
|
|
|
|
value: string | number | null | undefined,
|
|
|
|
|
|
locale?: string,
|
|
|
|
|
|
): string {
|
2026-04-18 18:51:53 +00:00
|
|
|
|
const date = parseDate(value);
|
|
|
|
|
|
if (!date) return "";
|
|
|
|
|
|
let delta = (date.getTime() - Date.now()) / 1000;
|
2026-04-19 06:39:06 +00:00
|
|
|
|
const formatter = relativeTimeFormatter(activeLocale(locale));
|
2026-04-18 18:51:53 +00:00
|
|
|
|
for (const [step, unit] of RELATIVE_THRESHOLDS) {
|
|
|
|
|
|
if (Math.abs(delta) < step) {
|
2026-04-19 06:39:06 +00:00
|
|
|
|
return formatter.format(Math.round(delta), unit);
|
2026-04-18 18:51:53 +00:00
|
|
|
|
}
|
|
|
|
|
|
delta /= step;
|
|
|
|
|
|
}
|
2026-04-19 06:39:06 +00:00
|
|
|
|
return formatter.format(Math.round(delta), "year");
|
2026-04-18 18:51:53 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 06:39:06 +00:00
|
|
|
|
export function fmtDateTime(
|
|
|
|
|
|
value: string | number | null | undefined,
|
|
|
|
|
|
locale?: string,
|
|
|
|
|
|
): string {
|
2026-04-18 18:51:53 +00:00
|
|
|
|
const date = parseDate(value);
|
2026-04-19 06:39:06 +00:00
|
|
|
|
return date ? dateTimeFormatter(activeLocale(locale)).format(date) : "";
|
2026-04-18 18:51:53 +00:00
|
|
|
|
}
|
2026-05-16 01:14:11 +08:00
|
|
|
|
|
|
|
|
|
|
/** Human-readable turn duration (wall-clock), locale-aware via ``Intl`` (seconds/minutes). */
|
|
|
|
|
|
export function formatTurnLatency(ms: number, locale?: string): string {
|
|
|
|
|
|
const loc = activeLocale(locale);
|
|
|
|
|
|
const msClamped = Math.max(0, ms);
|
|
|
|
|
|
const secTotal = msClamped / 1000;
|
|
|
|
|
|
if (secTotal < 60) {
|
|
|
|
|
|
return new Intl.NumberFormat(loc, {
|
|
|
|
|
|
style: "unit",
|
|
|
|
|
|
unit: "second",
|
|
|
|
|
|
unitDisplay: "narrow",
|
|
|
|
|
|
maximumFractionDigits: secTotal < 10 ? 1 : 0,
|
|
|
|
|
|
minimumFractionDigits: 0,
|
|
|
|
|
|
}).format(secTotal);
|
|
|
|
|
|
}
|
|
|
|
|
|
const wholeMin = Math.floor(secTotal / 60);
|
|
|
|
|
|
const remSec = Math.max(0, Math.round(secTotal - wholeMin * 60));
|
|
|
|
|
|
const minStr = new Intl.NumberFormat(loc, {
|
|
|
|
|
|
style: "unit",
|
|
|
|
|
|
unit: "minute",
|
|
|
|
|
|
unitDisplay: "narrow",
|
|
|
|
|
|
}).format(wholeMin);
|
|
|
|
|
|
const secStr = new Intl.NumberFormat(loc, {
|
|
|
|
|
|
style: "unit",
|
|
|
|
|
|
unit: "second",
|
|
|
|
|
|
unitDisplay: "narrow",
|
|
|
|
|
|
maximumFractionDigits: 0,
|
|
|
|
|
|
}).format(remSec);
|
|
|
|
|
|
return `${minStr}\u00a0${secStr}`;
|
|
|
|
|
|
}
|