|
|
@@ -142,46 +142,84 @@ export const upsertDmsSession = (params: {
|
|
|
});
|
|
|
|
|
|
/**
|
|
|
- * 问答记录 upsert(栏目 1889,幂等键 `c_record_id`)。
|
|
|
- * 提交时只带 question,回答完成后再带 answer 补一次。
|
|
|
+ * 会话里的单条消息(存进 DMS 的那份结构)。
|
|
|
+ *
|
|
|
+ * 只存渲染与追溯需要的字段;`feedback*` 是反馈落地的地方。
|
|
|
+ * **不存消息级时间戳**:本地消息本来就没有时间戳,而整段对话是每次覆盖重写的,
|
|
|
+ * 若在这里现造一个 Date.now(),每条消息的时间都会变成「最后一次保存的时刻」——
|
|
|
+ * 假数据比没有更糟。顺序由数组顺序表达,会话起始时间看该行的 `c_created_at`。
|
|
|
*/
|
|
|
-export const upsertDmsRecord = (params: {
|
|
|
- recordId: string;
|
|
|
+export interface DmsTranscriptMessage {
|
|
|
+ id: string;
|
|
|
+ role: "user" | "ai";
|
|
|
+ content: string;
|
|
|
+ feedback?: number;
|
|
|
+ feedbackOption?: string;
|
|
|
+ feedbackRemark?: string;
|
|
|
+ feedbackAt?: number;
|
|
|
+}
|
|
|
+
|
|
|
+/** 会话行 `c_answer` 里存的整段对话(用包装对象留出版本位,方便将来改格式) */
|
|
|
+interface DmsTranscript {
|
|
|
+ version: 1;
|
|
|
+ messages: DmsTranscriptMessage[];
|
|
|
+}
|
|
|
+
|
|
|
+const serializeTranscript = (messages: DmsTranscriptMessage[]): string =>
|
|
|
+ JSON.stringify({ version: 1, messages } satisfies DmsTranscript);
|
|
|
+
|
|
|
+const parseTranscript = (raw: unknown): DmsTranscriptMessage[] => {
|
|
|
+ if (typeof raw !== "string" || !raw.trim()) return [];
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(raw);
|
|
|
+ // 兼容两种形态:包装对象 {version, messages} 与纯数组
|
|
|
+ const list = Array.isArray(parsed) ? parsed : parsed?.messages;
|
|
|
+ if (!Array.isArray(list)) return [];
|
|
|
+ return list.filter((m: any) => m && typeof m.content === "string");
|
|
|
+ } catch {
|
|
|
+ // 老数据(这个字段曾经存的是单条回答的纯文本)—— 当作一条 AI 消息读出来,不丢内容
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * 写入某个会话的**整段对话**(栏目 1889,幂等键 `c_session_id`)。
|
|
|
+ *
|
|
|
+ * 粒度是「一个会话一行」:每次提交/回答完成都把该会话的完整消息列表
|
|
|
+ * 序列化后覆盖写进这一行的 `c_answer`。以本地 messages 为准整段重写,
|
|
|
+ * 因此不需要「读-改-写」,也就没有并发覆盖的问题。
|
|
|
+ *
|
|
|
+ * ⚠️ 1889 **没有 `c_updated_at` 字段**(只有 `c_created_at`),别写它。
|
|
|
+ */
|
|
|
+export const saveDmsTranscript = (params: {
|
|
|
sessionId: string;
|
|
|
+ messages: DmsTranscriptMessage[];
|
|
|
creditCode?: string;
|
|
|
- question?: string;
|
|
|
- answer?: string;
|
|
|
}): Promise<boolean> =>
|
|
|
- enqueueWrite(`record:${params.recordId}`, async () => {
|
|
|
- const base: DmsRow = {
|
|
|
+ enqueueWrite(`session:${params.sessionId}`, async () => {
|
|
|
+ const firstQuestion = params.messages.find((m) => m.role === "user")?.content || "";
|
|
|
+ const content: DmsRow = {
|
|
|
c_credit_code: params.creditCode || resolveDmsCreditCode(),
|
|
|
c_session_id: params.sessionId,
|
|
|
- c_record_id: params.recordId,
|
|
|
+ c_question: firstQuestion,
|
|
|
+ c_answer: serializeTranscript(params.messages),
|
|
|
c_source: resolveSource(),
|
|
|
};
|
|
|
|
|
|
- const existing = await findRowBy(DMS_COLUMN_RECORD, "c_record_id", params.recordId);
|
|
|
+ const existing = await findRowBy(DMS_COLUMN_RECORD, "c_session_id", params.sessionId);
|
|
|
if (!existing) {
|
|
|
- const content: DmsRow = { ...base };
|
|
|
- if (params.question !== undefined) content.c_question = params.question;
|
|
|
- if (params.answer !== undefined) content.c_answer = params.answer;
|
|
|
content.c_created_at = nowDmsTimestamp();
|
|
|
const id = await addDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, content);
|
|
|
- if (!id) console.warn("[dms] 问答记录写入失败", params.recordId);
|
|
|
+ if (!id) console.warn("[dms] 会话对话写入失败", params.sessionId);
|
|
|
return !!id;
|
|
|
}
|
|
|
|
|
|
const id = existing.id as string | undefined;
|
|
|
if (!id) {
|
|
|
- console.warn("[dms] 问答记录行缺少 uuid,无法更新", existing);
|
|
|
+ console.warn("[dms] 会话对话行缺少 uuid,无法更新", existing);
|
|
|
return false;
|
|
|
}
|
|
|
- const patch: DmsRow = {};
|
|
|
- // 不覆盖已有内容:补 answer 时不要把 question 冲掉
|
|
|
- if (params.question !== undefined && !existing.c_question) patch.c_question = params.question;
|
|
|
- if (params.answer !== undefined) patch.c_answer = params.answer;
|
|
|
- if (!Object.keys(patch).length) return true;
|
|
|
- return updateDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, id, patch);
|
|
|
+ return updateDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, id, content);
|
|
|
});
|
|
|
|
|
|
/** 会话列表(按更新时间倒序) */
|
|
|
@@ -205,37 +243,46 @@ export const fetchDmsSessions = async (creditCode: string): Promise<RemoteSessio
|
|
|
.filter((s) => s.session_id);
|
|
|
};
|
|
|
|
|
|
-/** 某个会话的全部问答记录(按创建时间正序,保证历史消息顺序正确) */
|
|
|
+/**
|
|
|
+ * 某个会话的问答对(供历史消息渲染)。
|
|
|
+ * 数据源是该会话那一行的整段消息 JSON,这里按「用户消息 + 紧随其后的 AI 回复」配对还原。
|
|
|
+ */
|
|
|
export const fetchDmsSessionRecords = async (
|
|
|
sessionId: string
|
|
|
): Promise<RemoteSessionRecord[]> => {
|
|
|
const rows = await searchDmsContents(DMS_COLUMN_RECORD, {
|
|
|
search: [{ field: "c_session_id", searchType: 1, content: { value: sessionId } }],
|
|
|
- orderBy: [{ field: "c_created_at", orderByType: 1 }],
|
|
|
page: 0,
|
|
|
- pageSize: 200,
|
|
|
+ pageSize: 10,
|
|
|
});
|
|
|
+ const messages = parseTranscript(rows[0]?.c_answer);
|
|
|
|
|
|
- return rows.map((row) => ({
|
|
|
- session_id: sessionId,
|
|
|
- question: String(row.c_question || ""),
|
|
|
- answer: String(row.c_answer || ""),
|
|
|
- created_at: parseDmsTimestamp(row.c_created_at),
|
|
|
- // 回填记录 id:让「从 DMS 读回的历史消息」点赞时仍能命中原始行
|
|
|
- record_id: row.c_record_id ? String(row.c_record_id) : undefined,
|
|
|
- }));
|
|
|
+ const records: RemoteSessionRecord[] = [];
|
|
|
+ for (let i = 0; i < messages.length; i++) {
|
|
|
+ const msg = messages[i];
|
|
|
+ if (msg.role !== "user") continue;
|
|
|
+ const next = messages[i + 1];
|
|
|
+ const reply = next?.role === "ai" ? next : null;
|
|
|
+ records.push({
|
|
|
+ session_id: sessionId,
|
|
|
+ question: msg.content,
|
|
|
+ answer: reply?.content || "",
|
|
|
+ created_at: parseDmsTimestamp(rows[0]?.c_created_at),
|
|
|
+ // 回填 AI 消息 id:从 DMS 读回的历史消息点赞时仍能命中同一条消息
|
|
|
+ // (反馈写回 JSON 时靠它定位)
|
|
|
+ record_id: reply?.id,
|
|
|
+ });
|
|
|
+ if (reply) i++; // 跳过已消费的回复
|
|
|
+ }
|
|
|
+ return records;
|
|
|
};
|
|
|
|
|
|
-/** 删除会话:连带删掉它的问答记录(DMS 的删除是置 state=4) */
|
|
|
+/** 删除会话:会话行 + 它的整段对话行(DMS 的删除是置 state=4) */
|
|
|
export const deleteDmsSession = (sessionId: string): Promise<boolean> =>
|
|
|
enqueueWrite(`session:${sessionId}`, async () => {
|
|
|
- const rows = await searchDmsContents(DMS_COLUMN_RECORD, {
|
|
|
- search: [{ field: "c_session_id", searchType: 1, content: { value: sessionId } }],
|
|
|
- page: 0,
|
|
|
- pageSize: 200,
|
|
|
- });
|
|
|
- for (const row of rows) {
|
|
|
- if (row.id) await deleteDmsContent(DMS_COLUMN_RECORD, String(row.id));
|
|
|
+ const transcriptRow = await findRowBy(DMS_COLUMN_RECORD, "c_session_id", sessionId);
|
|
|
+ if (transcriptRow?.id) {
|
|
|
+ await deleteDmsContent(DMS_COLUMN_RECORD, String(transcriptRow.id));
|
|
|
}
|
|
|
|
|
|
const sessionRow = await findRowBy(DMS_COLUMN_SESSION, "c_session_id", sessionId);
|
|
|
@@ -244,8 +291,10 @@ export const deleteDmsSession = (sessionId: string): Promise<boolean> =>
|
|
|
});
|
|
|
|
|
|
/**
|
|
|
- * 反馈写入(栏目 1889 的 `c_feedback_*`)。
|
|
|
- * 先按 `c_record_id` 找到那一行再更新;找不到(历史遗留的本地消息)就补建一行。
|
|
|
+ * 反馈写入:定位到该会话那一行,把反馈记在**对应那条消息**的 JSON 里。
|
|
|
+ *
|
|
|
+ * 因为是「读整行 → 改那条消息 → 写回」,同一会话的写操作共用 `session:` 顺序链,
|
|
|
+ * 避免与对话写入相互覆盖(单页内串行;跨标签页理论上仍可能覆盖,已知可接受)。
|
|
|
*/
|
|
|
export const writeDmsFeedback = (params: {
|
|
|
recordId: string;
|
|
|
@@ -253,29 +302,36 @@ export const writeDmsFeedback = (params: {
|
|
|
status: number;
|
|
|
option?: string;
|
|
|
remark?: string;
|
|
|
-}): Promise<boolean> =>
|
|
|
- enqueueWrite(`record:${params.recordId}`, async () => {
|
|
|
- const feedback: DmsRow = {
|
|
|
- c_feedback_status: params.status,
|
|
|
- c_feedback_at: nowDmsTimestamp(),
|
|
|
- };
|
|
|
- if (params.option !== undefined) feedback.c_feedback_option = params.option;
|
|
|
- if (params.remark !== undefined) feedback.c_feedback_remark = params.remark;
|
|
|
+}): Promise<boolean> => {
|
|
|
+ const sessionId = params.sessionId || "";
|
|
|
+ return enqueueWrite(`session:${sessionId}`, async () => {
|
|
|
+ const existing = await findRowBy(DMS_COLUMN_RECORD, "c_session_id", sessionId);
|
|
|
+ if (!existing?.id) {
|
|
|
+ // 找不到对应会话(例如从本地历史里点的赞,对话还没写进 DMS)——
|
|
|
+ // 不新建空行,避免在库里造出没有内容只有反馈的记录
|
|
|
+ console.warn("[dms] 反馈未落库:DMS 里还没有该会话的对话行", sessionId, params.recordId);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
|
|
|
- const existing = await findRowBy(DMS_COLUMN_RECORD, "c_record_id", params.recordId);
|
|
|
- if (!existing) {
|
|
|
- const id = await addDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, {
|
|
|
- c_credit_code: resolveDmsCreditCode(),
|
|
|
- c_record_id: params.recordId,
|
|
|
- c_source: resolveSource(),
|
|
|
- ...(params.sessionId ? { c_session_id: params.sessionId } : {}),
|
|
|
- c_created_at: nowDmsTimestamp(),
|
|
|
- ...feedback,
|
|
|
- });
|
|
|
- return !!id;
|
|
|
+ const messages = parseTranscript(existing.c_answer);
|
|
|
+ const target = messages.find((m) => m.id === params.recordId);
|
|
|
+ if (!target) {
|
|
|
+ console.warn("[dms] 反馈未落库:该会话的对话里没有这条消息", params.recordId);
|
|
|
+ return false;
|
|
|
}
|
|
|
|
|
|
- const id = existing.id as string | undefined;
|
|
|
- if (!id) return false;
|
|
|
- return updateDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, id, feedback);
|
|
|
+ target.feedback = params.status;
|
|
|
+ target.feedbackAt = Date.now();
|
|
|
+ if (params.option !== undefined) target.feedbackOption = params.option;
|
|
|
+ if (params.remark !== undefined) target.feedbackRemark = params.remark;
|
|
|
+
|
|
|
+ // 同时把列级字段写上(按会话维度看反馈时用;明细在 c_answer 的 JSON 里)
|
|
|
+ return updateDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, String(existing.id), {
|
|
|
+ c_answer: serializeTranscript(messages),
|
|
|
+ c_feedback_status: params.status,
|
|
|
+ c_feedback_at: nowDmsTimestamp(),
|
|
|
+ ...(params.option !== undefined ? { c_feedback_option: params.option } : {}),
|
|
|
+ ...(params.remark !== undefined ? { c_feedback_remark: params.remark } : {}),
|
|
|
+ });
|
|
|
});
|
|
|
+};
|