feat(feedback): unify task-mode 点赞/点踩 on the task-result ChatMessage

The linsight task result is already persisted as a category="task" ChatMessage
in the unified daily conversation, so rate it through the shared chatmessage
feedback (/liked + /chat/comment) instead of a linsight_session_version-specific
column/endpoint. All four AI Q&A surfaces now share one storage + rollup path.

Backend:
- chat_helpers: agent history formatter returns liked + remark (fixes daily +
  in-conversation task-turn highlight on reload)
- workstation_schema: WorkstationMessage carries liked + remark (channel surface)
- linsight utils: get_task_feedback_by_version maps session_version -> its task
  ChatMessage {message_id, liked}
- linsight endpoint: session-version-list enriches each version with message_id +
  liked for the standalone linsight page

Frontend:
- ResultPanel: rate by messageId via likeChatApi / disLikeCommentApi
- TaskTurnPanel forwards messageId + liked; AiMessageBubble passes the task row's
  message.messageId (hidden on the read-only share view); ExecutionFlow reads them
  from the enriched session-version store field
- drop the now-unused likeLinsightVersion / commentLinsightVersion
This commit is contained in:
dolphin
2026-07-10 01:31:08 +08:00
parent 18adc59068
commit ec99d17d43
10 changed files with 77 additions and 34 deletions
@@ -537,7 +537,18 @@ async def get_linsight_session_version_list(
model for model in linsight_session_version_models if model.id == shared_version_id
]
return resp_200([model.model_dump() for model in linsight_session_version_models])
# Unified like/dislike via ChatMessage: the task result is itself a
# category="task" ChatMessage and the verdict lives on that row. Attach each
# version's linked task message_id + liked so the standalone linsight page can
# rate through the shared /liked endpoint and re-highlight on reload (same as
# the in-conversation task turn).
version_dumps = [model.model_dump() for model in linsight_session_version_models]
feedback_map = await linsight_execute_utils.get_task_feedback_by_version(session_id)
for dump in version_dumps:
info = feedback_map.get(dump.get("id"))
dump["message_id"] = info["message_id"] if info else None
dump["liked"] = info["liked"] if info else 0
return resp_200(version_dumps)
# Get task execution details
@@ -387,6 +387,32 @@ async def persist_task_turn_message(session_model: LinsightSessionVersion) -> Ch
)
async def get_task_feedback_by_version(session_id: str) -> dict[str, dict]:
"""Map each linsight session_version id -> its task ChatMessage feedback.
The task result is a bot ``ChatMessage`` (``category="task"``) in the
conversation ``session_id`` carrying ``extra.linsight_session_version_id``.
The like/dislike verdict is stored on that ChatMessage row (unified with
daily / knowledge / channel), so the standalone linsight page rates and
echoes the highlight via the shared chatmessage feedback instead of a
linsight-specific column.
Returns ``{session_version_id: {"message_id": int, "liked": int}}``.
"""
rows = await ChatMessageDao.aget_messages_by_chat_id(chat_id=session_id, category_list=["task"], limit=1000)
result: dict[str, dict] = {}
for row in rows:
if not row.is_bot:
continue
try:
svid = json.loads(row.extra or "{}").get("linsight_session_version_id")
except (json.JSONDecodeError, TypeError):
svid = None
if svid:
result[svid] = {"message_id": row.id, "liked": row.liked or 0}
return result
async def persist_task_user_turn(chat_id: str, user_id: int, question: str, files: list | None = None) -> ChatMessage:
"""F035 Track J (TJ-3): persist the task user turn into the unified conversation.
@@ -31,6 +31,10 @@ class WorkstationMessage(BaseModel):
# to the execution detail for lazy-loading. Both absent on normal turns.
category: str | None = None
linsightSessionVersionId: str | None = None
# like/dislike echo: 0 none / 1 up / 2 down, plus the dislike reason. The
# frontend re-highlights the rated state on reload.
liked: int | None = None
remark: str | None = None
@field_validator("messageId", mode="before")
@classmethod
@@ -70,6 +74,8 @@ class WorkstationMessage(BaseModel):
source=message.source,
category=message.category,
linsightSessionVersionId=extra.get("linsight_session_version_id"),
liked=message.liked,
remark=message.remark,
)
@@ -219,6 +219,11 @@ def _message_base_fields(msg: ChatMessage) -> dict:
"flow_id": msg.flow_id,
"source": msg.source,
"sender": msg.sender,
# like/dislike echo: 0 none / 1 up / 2 down, plus the dislike reason. The
# frontend re-highlights the rated state on reload. Daily answers and task
# results are both ChatMessage rows, so they share this one path.
"liked": msg.liked,
"remark": msg.remark,
"create_time": msg.create_time.isoformat() if msg.create_time else None,
}
-17
View File
@@ -48,23 +48,6 @@ export function startLinsight(versionId: string): Promise<any> {
});
}
// 灵思任务结果点赞/点踩(liked: 0 未评 / 1 赞 / 2 踩)。
// 落库到 linsight_session_version.liked,并汇总进 message_session(后端待接入,见 PRD)。
export function likeLinsightVersion(versionId: string, liked: number): Promise<any> {
return request.post('/api/v1/linsight/workbench/feedback', {
session_version_id: versionId,
liked
});
}
// 灵思点踩原因,落库到 linsight_session_version.execute_feedback(后端待接入)。
export function commentLinsightVersion(versionId: string, comment: string): Promise<any> {
return request.post('/api/v1/linsight/workbench/feedback', {
session_version_id: versionId,
comment
});
}
// F035 多轮对话:在已完成的同一会话里追加新一轮(复用同一 session_version + agent thread,保留上下文)
export function continueLinsight(session_version_id: string, question: string): Promise<any> {
return request.post('/api/v1/linsight/workbench/continue', {
@@ -603,6 +603,8 @@ function AssistantBubble({
<div className={cn("min-w-0", knowledgeChatLayout ? "w-full max-w-none" : "max-w-[80%]")}>
<TaskTurnPanel
versionId={message.linsightSessionVersionId || ""}
messageId={allowFeedback ? message.messageId : undefined}
liked={message.liked}
conversationId={message.conversationId}
answer={message.text}
onPreviewFile={onPreviewFile}
@@ -231,7 +231,7 @@ export function ExecutionFlow({ versionId, conversationId, isSharePage = false,
card — lifted into the terminal ResultPanel (peak-end). ── */}
{completed && (
<ResultPanel
versionId={versionId}
messageId={linsight?.message_id ?? undefined}
liked={linsight?.liked ?? undefined}
allowFeedback={!readOnly && !isSharePage}
>
@@ -12,21 +12,23 @@ import { Outlined } from 'bisheng-icons';
import type { ReactNode } from 'react';
import { useLocalize } from '~/hooks';
import { MessageFeedbackButtons } from '~/components/Chat/MessageFeedbackButtons';
import { likeLinsightVersion, commentLinsightVersion } from '~/api/linsight';
import { likeChatApi, disLikeCommentApi } from '~/api/apps';
import { INK } from './execTokens';
interface ResultPanelProps {
/** the terminal deliverable (typically <ResultSection />) */
children: ReactNode;
/** linsight session_version id — the feedback target */
versionId?: string;
/** persisted 点赞/点踩 verdict: 0 none / 1 up / 2 down */
/** task-result ChatMessage id — the like/dislike target. The task result is a
category="task" ChatMessage, so feedback reuses the shared /liked +
/chat/comment endpoints (same as daily / knowledge / channel). */
messageId?: string;
/** persisted like/dislike verdict: 0 none / 1 up / 2 down */
liked?: number;
/** show 点赞/点踩 (off for read-only / share view) */
/** show like/dislike (off for read-only / share view) */
allowFeedback?: boolean;
}
export function ResultPanel({ children, versionId, liked, allowFeedback }: ResultPanelProps) {
export function ResultPanel({ children, messageId, liked, allowFeedback }: ResultPanelProps) {
const localize = useLocalize();
// peak-end (§2.6): a DoubleCheck Ink "task completed" header marks the
// terminal state and lifts the deliverable out of the homogeneous flow; body
@@ -45,15 +47,15 @@ export function ResultPanel({ children, versionId, liked, allowFeedback }: Resul
</span>
</div>
{children}
{/* 点赞/点踩 — the task result lives in linsight_session_version, so
persist via the linsight feedback endpoint keyed by version id
(best-effort: optimistic UI, backend wiring per PRD). */}
{allowFeedback && versionId && (
{/* like/dislike — the task result is a category="task" ChatMessage, so
rate it through the shared chatmessage feedback endpoints keyed by
message id (rollup to message_session is maintained backend-side). */}
{allowFeedback && messageId && (
<div className="mt-3">
<MessageFeedbackButtons
liked={liked}
onLike={(l) => { void likeLinsightVersion(versionId, l).catch(() => {}); }}
onDislikeComment={(c) => { void commentLinsightVersion(versionId, c).catch(() => {}); }}
onLike={(l) => likeChatApi(messageId, l)}
onDislikeComment={(c) => disLikeCommentApi(messageId, c)}
/>
</div>
)}
@@ -47,6 +47,10 @@ import { findPendingUserInput, hasRenderableTimeline, isTaskRunning, isTaskStart
interface TaskTurnPanelProps {
/** linsight session_version id holding this turn's execution detail */
versionId: string;
/** task-result ChatMessage id — the like/dislike target (this turn's bot row) */
messageId?: string;
/** persisted like/dislike verdict on the task result: 0 none / 1 up / 2 down */
liked?: number;
/** chat id of the hosting conversation (for the history lazy-load) */
conversationId?: string;
/** final answer text (fallback shown before the panel hydrates) */
@@ -58,7 +62,7 @@ interface TaskTurnPanelProps {
onPreviewFile?: (file: ArtifactFile) => void;
}
export function TaskTurnPanel({ versionId, conversationId, answer, readOnly = false, onPreviewFile }: TaskTurnPanelProps) {
export function TaskTurnPanel({ versionId, messageId, liked, conversationId, answer, readOnly = false, onPreviewFile }: TaskTurnPanelProps) {
const localize = useLocalize();
const { getLinsight, switchAndUpdateLinsight } = useLinsightManager();
// WS pump — self-guards on status===Running, so mounting it for a completed
@@ -250,7 +254,7 @@ export function TaskTurnPanel({ versionId, conversationId, answer, readOnly = fa
document link opens it directly in ChatView's inline workspace
panel (preview), replacing the legacy right-side drawer. */}
{completed && (
<ResultPanel versionId={versionId} liked={linsight?.liked ?? undefined} allowFeedback={!readOnly}>
<ResultPanel messageId={messageId} liked={liked} allowFeedback={!readOnly}>
<ResultSection
answer={linsight.output_result?.answer}
files={fileList}
+5 -1
View File
@@ -52,7 +52,11 @@ export type LinsightInfo = {
}[];
output_result: null | any;
score: null | number;
// 点赞/点踩 verdict on the task result: 0 none / 1 up / 2 down.
// like/dislike on the task result. The result is a category="task" ChatMessage;
// `message_id` is that row's id (the feedback target) and `liked` its verdict
// (0 none / 1 up / 2 down). Both come enriched on the session-version list
// (snake_case so they pass through the raw `...version` spread into the store).
message_id?: string | null;
liked?: null | number;
has_reexecute: boolean;
id: string;