114 lines
4.5 KiB
TypeScript
114 lines
4.5 KiB
TypeScript
import type { ConsultationDomain } from "./consultation-domain-registry.ts";
|
||
import { consultationDomainDefinition } from "./consultation-domain-registry.ts";
|
||
import type { ConsultationEntrypoint } from "./consultation-entrypoint.ts";
|
||
import {
|
||
isGeneralDailyFortuneQuestion,
|
||
isRectificationHandoffQuestion,
|
||
} from "./consultation-entrypoint.ts";
|
||
import { shanghaiDateParts } from "./session-shanghai-clock.ts";
|
||
|
||
export type ReplyTheme = ConsultationDomain;
|
||
|
||
import type { ConsultationReplyMetadata } from "./consultation-reply-metadata.ts";
|
||
|
||
const GENERIC_SESSION_TITLES = new Set([
|
||
"新对话",
|
||
"深入看今日",
|
||
"从今日问起",
|
||
"查看今日运势",
|
||
"生时校正",
|
||
"再次校正",
|
||
"一般占星咨询",
|
||
"深入看今日主题咨询",
|
||
]);
|
||
|
||
function readTitle(value: string): string | undefined {
|
||
const title = value.replace(/\s+/g, " ").trim();
|
||
if (!title || /[\d\p{P}\p{S}]/u.test(title)) return undefined;
|
||
if (/\p{Script=Han}/u.test(title)) {
|
||
const length = Array.from(title.replace(/\s/g, "")).length;
|
||
return length >= 6 && length <= 14 ? title : undefined;
|
||
}
|
||
const words = title.split(" ").filter(Boolean);
|
||
return words.length >= 3 && words.length <= 7 && title.length <= 64 ? title : undefined;
|
||
}
|
||
|
||
// AYANAM_SUGGESTIONS blocks are no longer produced anywhere, but stored answers from
|
||
// before the follow-up chips were removed still carry them, so they stay strippable
|
||
// rather than leaking a raw HTML comment into a replayed transcript.
|
||
function stripAgentReplyMetadata(value: string) {
|
||
let title: string | undefined;
|
||
const text = value
|
||
.replace(/<!--AYANAM_SUGGESTIONS:\[[\s\S]*?\]-->/g, "")
|
||
.replace(/<!--AYANAM_TITLE:([\s\S]*?)-->/g, (_, rawTitle: string) => {
|
||
title = readTitle(rawTitle);
|
||
return "";
|
||
})
|
||
.replace(/<!--AYANAM_[\s\S]*$/, "")
|
||
.trim();
|
||
|
||
return { text, title };
|
||
}
|
||
|
||
export function parseAgentReply(value: string, metadata?: ConsultationReplyMetadata) {
|
||
const parsed = stripAgentReplyMetadata(value);
|
||
return { text: parsed.text, title: metadata?.title ?? parsed.title };
|
||
}
|
||
|
||
export type SessionTitleOptions = {
|
||
readonly entrypoint?: ConsultationEntrypoint | null;
|
||
readonly theme?: ConsultationDomain;
|
||
/** Wall clock. Only for minting a brand-new session. Re-titling an existing
|
||
* session must pass that session's created time; the default `new Date()`
|
||
* would stamp today's date onto old chats (BUG-699). */
|
||
readonly at?: Date;
|
||
readonly existingTitles?: readonly string[];
|
||
};
|
||
|
||
const DATED_CATEGORY_PREFIX = /^(?:生时校正|今日节奏)\s*·\s*\d{1,2}月\d{1,2}日/;
|
||
const DATED_CATEGORY_SUFFIX = /^\d{1,2}月\d{1,2}日\s*·\s*(?:生时校正|今日节奏)(?:\s+\d{2}:\d{2})?$/;
|
||
|
||
export function isGenericSessionTitle(title: string): boolean {
|
||
const text = title.replace(/\s+/g, " ").trim();
|
||
if (!text) return true;
|
||
if (GENERIC_SESSION_TITLES.has(text)) return true;
|
||
if (DATED_CATEGORY_PREFIX.test(text) || DATED_CATEGORY_SUFFIX.test(text)) return true;
|
||
return /^(?:深入看今日|从今日问起|查看今日运势|生时校正|再次校正)/.test(text);
|
||
}
|
||
|
||
function clipTitle(value: string, maxChars = 14): string {
|
||
const characters = Array.from(value);
|
||
return characters.length > maxChars ? `${characters.slice(0, maxChars).join("")}…` : value;
|
||
}
|
||
|
||
// Daily rhythm only. Rectification titles are the result time, written by
|
||
// public.rectification_session_title when the case result changes (BUG-1001 / D3).
|
||
function datedSessionTitle(at: Date, category: string): string {
|
||
const { month, day } = shanghaiDateParts(at);
|
||
return `${category} · ${month}月${day}日`;
|
||
}
|
||
|
||
export function resolveSessionTitle(
|
||
question: string,
|
||
modelTitle?: string,
|
||
options: SessionTitleOptions = {},
|
||
): string {
|
||
const at = options.at ?? new Date(); // new sessions only; see SessionTitleOptions.at
|
||
if (modelTitle && !isGenericSessionTitle(modelTitle)) {
|
||
return clipTitle(modelTitle);
|
||
}
|
||
if (options.entrypoint === "daily_starlanguage" || isGeneralDailyFortuneQuestion(question)) {
|
||
return datedSessionTitle(at, "今日节奏");
|
||
}
|
||
if (options.entrypoint === "birth_time_rectification" || isRectificationHandoffQuestion(question)) {
|
||
return "生时校正";
|
||
}
|
||
const normalized = question.replace(/\s+/g, " ").trim().replace(/[??!!。.,,;;::]+$/u, "");
|
||
if (!normalized) return "新对话";
|
||
if (options.theme && options.theme !== "general") {
|
||
const label = consultationDomainDefinition(options.theme).label;
|
||
return `${label} · ${clipTitle(normalized, 10)}`;
|
||
}
|
||
return clipTitle(normalized);
|
||
}
|