feat(web): 历史解卦列表、结果页与追问功能

- 合并 DivinationResultPage 和 HistoryResultPage 为统一结果页
- 重写 HistoryFollowUpPage:API 加载历史消息、SSE 流式追问、配额管理
- 追问免费且限一次,输入框 UI 对齐设计稿(圆角容器+配额徽章+圆形发送按钮)
- 结果页追问状态根据线程消息数动态判断
- 历史列表筛选改为 9 类独立类型
- 提取 historyMessageToResultData 为共享函数,新增 enqueueFollowUpRun API
- 新增 auto_awesome/search/arrow_upward 图标
- 新增三语言 [id].astro、[id]/followup.astro、divination/result.astro 页面
This commit is contained in:
ZL-Q
2026-05-10 13:59:04 +08:00
parent 654e5ce188
commit efe48f2068
23 changed files with 2119 additions and 225 deletions
+3
View File
@@ -24,6 +24,9 @@ export const API_ROUTES = {
},
agent: {
history: '/api/v1/agent/history',
historyByThread: (threadId: string) => `/api/v1/agent/history?threadId=${threadId}`,
runs: '/api/v1/agent/runs',
runEvents: (threadId: string) => `/api/v1/agent/runs/${threadId}/events`,
},
feedback: {
submit: '/api/v1/feedback',
+404 -14
View File
@@ -241,6 +241,8 @@ export interface HistoryAgentOutput {
keywords?: string[];
answer?: string | null;
divination_derived?: {
question?: string;
questionType?: string;
guaName?: string;
gua_name?: string;
binaryCode?: string;
@@ -266,7 +268,6 @@ export interface HistoryItem {
hexagram_name: string;
rating: string;
created_at: string;
can_follow_up: boolean;
}
export interface HistorySnapshot {
@@ -281,19 +282,408 @@ export async function getAgentHistory(): Promise<HistorySnapshot> {
return authFetch<HistorySnapshot>(API_ROUTES.agent.history);
}
export async function getAgentHistoryByThread(threadId: string): Promise<HistorySnapshot> {
return authFetch<HistorySnapshot>(API_ROUTES.agent.historyByThread(threadId));
}
// 问题类型中文到英文的映射
const QUESTION_TYPE_MAP: Record<string, string> = {
'事业': 'career',
'情感': 'love',
'感情': 'love',
'财富': 'wealth',
'运势': 'fortune',
'解梦': 'dream',
'健康': 'health',
'学业': 'study',
'寻物': 'search',
'其他': 'other',
};
export function mapHistoryMessagesToItems(messages: HistoryMessage[]): HistoryItem[] {
return messages.map((message) => {
const output = message.agent_output;
const derived = output?.divination_derived;
return {
id: message.id,
threadId: message.threadId,
question: output?.answer || message.content,
category: output?.keywords?.[0] || '',
hexagram_name: derived?.guaName || derived?.gua_name || '',
rating: output?.sign_level || '',
created_at: message.timestamp,
can_follow_up: output?.status === 'success',
};
return messages
.filter((m) => m.role === 'assistant' && m.agent_output)
.map((message) => {
const output = message.agent_output;
const derived = output?.divination_derived;
const question = derived?.question || message.content;
const questionTypeRaw = derived?.questionType || '';
const category = QUESTION_TYPE_MAP[questionTypeRaw] || questionTypeRaw.toLowerCase();
const hexagramName = derived?.guaName || derived?.gua_name || '';
const rating = output?.sign_level || '';
return {
id: message.id,
threadId: message.threadId,
question,
category,
hexagram_name: hexagramName,
rating,
created_at: message.timestamp,
};
});
}
export function parseChineseDate(dateStr: string): Date {
const match = dateStr.match(/(\d{4})年(\d{2})月(\d{2})日\s+(\d{2}):(\d{2})/);
if (match) {
const [, year, month, day, hour, minute] = match;
return new Date(`${year}-${month}-${day}T${hour}:${minute}:00`);
}
try {
const d = new Date(dateStr);
if (!isNaN(d.getTime())) return d;
} catch { /* ignore */ }
return new Date();
}
export function historyMessageToResultData(message: HistoryMessage): DivinationResultData | null {
const output = message.agent_output;
const derived = output?.divination_derived;
if (!output || !derived) return null;
const yaoLines: DivinationResultData['yaoLines'] = [];
const yaoInfoList = (derived as Record<string, unknown>).yaoInfoList;
if (Array.isArray(yaoInfoList)) {
yaoInfoList.forEach((item: Record<string, unknown>, idx: number) => {
yaoLines.push({
index: idx,
spirit: (item.spiritName as string) || '',
relation: (item.relationName as string) || '',
branch: (item.tiganName as string) || '',
element: (item.elementName as string) || '',
type: item.isYang ? 'youngYang' : 'youngYin',
mark: (item.specialMark as string) || '',
});
});
}
const ganzhiRaw = (derived as Record<string, unknown>).ganzhi as Record<string, string> || {};
const ganzhi: DivinationResultData['ganzhi'] = {
yearGanZhi: ganzhiRaw.yearGanZhi || '',
monthGanZhi: ganzhiRaw.monthGanZhi || '',
dayGanZhi: ganzhiRaw.dayGanZhi || '',
timeGanZhi: ganzhiRaw.timeGanZhi || '',
yearKongWang: ganzhiRaw.yearKongWang || '',
monthKongWang: ganzhiRaw.monthKongWang || '',
dayKongWang: ganzhiRaw.dayKongWang || '',
timeKongWang: ganzhiRaw.timeKongWang || '',
yueJian: ganzhiRaw.yueJian || '',
riChen: ganzhiRaw.riChen || '',
yuePo: ganzhiRaw.yuePo || '',
riChong: ganzhiRaw.riChong || '',
};
const divinationTimeStr = (derived as Record<string, unknown>).divinationTime as string || '';
const divinationTime = parseChineseDate(divinationTimeStr);
return {
threadId: message.threadId,
params: {
method: ((derived as Record<string, unknown>).divinationMethod as string)?.includes('手动') ? 'manual' : 'auto',
questionType: (derived as Record<string, unknown>).questionType as string || '',
question: (derived as Record<string, unknown>).question as string || '',
divinationTime,
},
binaryCode: (derived as Record<string, unknown>).binaryCode as string || '',
changedBinaryCode: (derived as Record<string, unknown>).changedBinaryCode as string || '',
guaName: (derived as Record<string, unknown>).guaName as string || '',
targetGuaName: (derived as Record<string, unknown>).targetGuaName as string || '',
upperName: (derived as Record<string, unknown>).upperName as string || '',
lowerName: (derived as Record<string, unknown>).lowerName as string || '',
signType: output.sign_level || '',
keywords: (output.keywords || []).join(' · '),
focusPoints: output.focus_points || [],
conclusion: (output.conclusion || []).join('\n'),
analysis: output.answer || '',
suggestion: (output.advice || []).join('\n'),
ganzhi,
wuXingStatus: (derived as Record<string, unknown>).wuXingStatuses as Record<string, string> || {},
yaoLines,
targetYaoLines: [],
status: (output.status as 'success' | 'failed' | 'refused') || 'success',
};
}
// --- Divination Run ---
export type YaoType = 'youngYang' | 'youngYin' | 'oldYang' | 'oldYin';
export interface DivinationParams {
method: 'manual' | 'auto';
questionType: string;
question: string;
divinationTime: Date;
}
export interface RunAcceptedData {
threadId: string;
runId: string;
}
interface GanzhiData {
yearGanZhi: string;
monthGanZhi: string;
dayGanZhi: string;
timeGanZhi: string;
yearKongWang: string;
monthKongWang: string;
dayKongWang: string;
timeKongWang: string;
yueJian: string;
riChen: string;
yuePo: string;
riChong: string;
}
interface YaoLineData {
index: number;
spirit: string;
relation: string;
branch: string;
element: string;
type: YaoType;
mark: string;
}
export interface DivinationResultData {
threadId?: string;
params: DivinationParams;
binaryCode: string;
changedBinaryCode: string;
guaName: string;
targetGuaName: string;
upperName: string;
lowerName: string;
signType: string;
keywords: string;
focusPoints: string[];
conclusion: string;
analysis: string;
suggestion: string;
ganzhi: GanzhiData;
wuXingStatus: Record<string, string>;
yaoLines: YaoLineData[];
targetYaoLines: YaoLineData[];
status: 'success' | 'failed' | 'refused';
}
export type DivinationEventType =
| 'DIVINATION_DERIVED'
| 'TEXT_MESSAGE_END'
| 'RUN_ERROR'
| 'RUN_FINISHED';
export interface DivinationEvent {
type: DivinationEventType;
data: Record<string, unknown>;
}
function yaoTypeToText(type: YaoType): string {
return type === 'youngYang' ? '少阳'
: type === 'youngYin' ? '少阴'
: type === 'oldYang' ? '老阳'
: '老阴';
}
function questionTypeToText(type: string): string {
const map: Record<string, string> = {
career: '事业',
love: '情感',
wealth: '财富',
fortune: '运势',
dream: '解梦',
health: '健康',
study: '学业',
search: '寻物',
other: '其他',
};
return map[type] || type;
}
function toRfc3339Utc(date: Date): string {
return date.toISOString();
}
export async function enqueueDivinationRun(
params: DivinationParams,
yaoStates: YaoType[]
): Promise<RunAcceptedData> {
const threadId = crypto.randomUUID();
const runId = crypto.randomUUID();
const payload = {
threadId,
runId,
state: {},
messages: [
{ id: `msg_${runId}_user_0`, role: 'user', content: params.question },
],
tools: [],
context: [],
forwardedProps: {
runtime_mode: 'chat',
client_time: {
device_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
client_now_iso: toRfc3339Utc(new Date()),
client_epoch_ms: Date.now(),
},
divinationPayload: {
divinationMethod: params.method === 'manual' ? '手动起卦' : '自动起卦',
questionType: questionTypeToText(params.questionType),
question: params.question,
divinationTimeIso: toRfc3339Utc(params.divinationTime),
yaoLines: yaoStates.map(yaoTypeToText),
},
},
};
return authFetch<RunAcceptedData>(API_ROUTES.agent.runs, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function enqueueFollowUpRun(
threadId: string,
question: string,
result: DivinationResultData
): Promise<RunAcceptedData> {
const runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const yaoStates = result.yaoLines.map((line) => line.type);
const divinationTime = result.params.divinationTime instanceof Date
? result.params.divinationTime
: new Date(result.params.divinationTime);
const payload = {
threadId,
runId,
state: {},
messages: [
{ id: `msg_${runId}_user_0`, role: 'user', content: question },
],
tools: [],
context: [],
forwardedProps: {
runtime_mode: 'follow_up',
client_time: {
device_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
client_now_iso: toRfc3339Utc(new Date()),
client_epoch_ms: Date.now(),
},
divinationPayload: {
divinationMethod: result.params.method === 'manual' ? '手动起卦' : '自动起卦',
questionType: questionTypeToText(result.params.questionType),
question: result.params.question,
divinationTimeIso: toRfc3339Utc(divinationTime),
yaoLines: yaoStates.map(yaoTypeToText),
},
},
};
return authFetch<RunAcceptedData>(API_ROUTES.agent.runs, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function* streamDivinationEvents(
threadId: string,
runId: string
): AsyncGenerator<DivinationEvent> {
const { authFetchRaw } = await import('./auth');
const response = await authFetchRaw(
`${API_ROUTES.agent.runEvents(threadId)}?runId=${runId}`,
{
headers: {
Accept: 'text/event-stream',
},
}
);
if (!response.ok) {
throw new Error(`Failed to connect to event stream: ${response.status}`);
}
yield* readSseStream(response);
}
async function* readSseStream(response: Response): AsyncGenerator<DivinationEvent> {
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE frames (separated by \n\n)
while (true) {
const splitAt = buffer.indexOf('\n\n');
if (splitAt < 0) break;
const frame = buffer.substring(0, splitAt);
buffer = buffer.substring(splitAt + 2);
const event = parseSseFrame(frame);
if (event) {
yield event;
}
}
}
// Process any remaining buffer
if (buffer.trim()) {
const event = parseSseFrame(buffer);
if (event) {
yield event;
}
}
} finally {
reader.releaseLock();
}
}
function parseSseFrame(frame: string): DivinationEvent | null {
if (frame.startsWith(':')) return null;
const lines = frame.split('\n');
let eventType = '';
const dataLines: string[] = [];
for (const raw of lines) {
const line = raw.trimEnd();
if (line.startsWith('event:')) {
eventType = line.substring(6).trim();
} else if (line.startsWith('data:')) {
dataLines.push(line.substring(5).trimStart());
}
}
if (dataLines.length === 0) return null;
const dataText = dataLines.join('\n');
if (!dataText.trim()) return null;
let data: Record<string, unknown>;
try {
data = JSON.parse(dataText);
} catch {
data = { raw: dataText };
}
// Use event type from SSE event line, fallback to data.type
const typeFromData = data.type as string | undefined;
const type: DivinationEventType = (eventType || typeFromData || 'UNKNOWN') as DivinationEventType;
return { type, data };
}
+36
View File
@@ -237,3 +237,39 @@ export async function authFetch<T>(path: string, options?: RequestInit): Promise
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
/**
* Like authFetch but returns raw Response for streaming (SSE, etc.)
* Does NOT throw on non-OK responses - caller must handle response.status
*/
export async function authFetchRaw(path: string, options?: RequestInit): Promise<Response> {
if (isTokenExpired()) {
await refreshAccessToken();
}
const auth = getAuth();
if (!auth) {
redirectToLogin();
throw new Error('Not authenticated');
}
const headers = jsonHeaders(options);
headers.set('Authorization', `Bearer ${auth.access_token}`);
const url = apiUrl(path);
let res = await fetch(url, { ...options, headers });
if (res.status === 401) {
await refreshAccessToken();
const refreshed = getAuth();
if (!refreshed) {
redirectToLogin();
throw new Error('Not authenticated');
}
const retryHeaders = jsonHeaders(options);
retryHeaders.set('Authorization', `Bearer ${refreshed.access_token}`);
res = await fetch(url, { ...options, headers: retryHeaders });
}
return res;
}