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:
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user