feat(chat): add compact message display mode and goal-review/compression headers

- 审核(goal_review) user 消息气泡显示「审核 + circle-alert 图标」
- compact 消息新增「简略信息」显示形式(个人空间可切换),渲染为横线包裹的一行概要
- 压缩续接消息单独标记为 compression_handoff,visibility 改为 compact

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
JOJO 2026-05-30 19:05:59 +08:00
parent 32865f6d6e
commit f5e4541d83
8 changed files with 252 additions and 59 deletions

View File

@ -152,6 +152,7 @@ _VALID_USER_MESSAGE_SOURCES = {
"goal_prompt",
"goal_review",
"compression",
"compression_handoff",
"permission",
"sandbox",
"skill",
@ -164,7 +165,7 @@ def _user_message_ui_defaults(source: str, *, auto_user_message_event: bool = Fa
return {"visibility": "hidden", "starts_work": False}
if normalized in {"guidance", "notify"}:
return {"visibility": "compact", "starts_work": False}
if normalized in {"goal_review", "compression", "sub_agent", "background_command"}:
if normalized in {"goal_review", "compression", "compression_handoff", "sub_agent", "background_command"}:
return {"visibility": "compact", "starts_work": True}
if normalized == "presend":
return {"visibility": "chat", "starts_work": True}
@ -1085,16 +1086,22 @@ async def handle_task_with_sender(
if guide_message:
finalize_user_work_timer()
finalize_run_versioning_checkpoint("auto_deep_compress_handoff")
await handle_task_with_sender(
web_terminal,
workspace,
guide_message,
[],
sender,
client_sid,
username,
[]
)
# 压缩后的续接引导语单独标记来源,使其以 compact 形式展示,
# 而不是混入普通用户消息流。
setattr(web_terminal, "_current_user_message_source", "compression_handoff")
try:
await handle_task_with_sender(
web_terminal,
workspace,
guide_message,
[],
sender,
client_sid,
username,
[]
)
finally:
setattr(web_terminal, "_current_user_message_source", "user")
return
finalize_user_work_timer()
finalize_run_versioning_checkpoint("auto_deep_compress_end")
@ -1570,16 +1577,22 @@ async def handle_task_with_sender(
if deep_result.get("success") and guide_message:
finalize_user_work_timer()
finalize_run_versioning_checkpoint("deep_compress_handoff")
await handle_task_with_sender(
web_terminal,
workspace,
guide_message,
[],
sender,
client_sid,
username,
[]
)
# 压缩后的续接引导语单独标记来源,使其以 compact 形式展示,
# 而不是混入普通用户消息流。
setattr(web_terminal, "_current_user_message_source", "compression_handoff")
try:
await handle_task_with_sender(
web_terminal,
workspace,
guide_message,
[],
sender,
client_sid,
username,
[]
)
finally:
setattr(web_terminal, "_current_user_message_source", "user")
return
finalize_user_work_timer()
finalize_run_versioning_checkpoint("deep_compress_end")

View File

@ -19,6 +19,7 @@ _VALID_SOURCES = {
"goal_prompt",
"goal_review",
"compression",
"compression_handoff",
"permission",
"sandbox",
"skill",
@ -32,7 +33,7 @@ def _runtime_message_ui_defaults(src: str, *, inline: bool = False) -> Dict[str,
return {"visibility": "hidden", "starts_work": False}
if normalized in {"guidance", "notify"}:
return {"visibility": "compact", "starts_work": False}
if normalized in {"goal_review", "compression", "sub_agent", "background_command", "presend"}:
if normalized in {"goal_review", "compression", "compression_handoff", "sub_agent", "background_command", "presend"}:
return {"visibility": "compact", "starts_work": True}
if normalized == "goal":
# Backward-compatible fallback for old callers. Inline goal injections are

View File

@ -1207,6 +1207,7 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use
images=[],
conversation_id=new_conversation_id,
videos=[],
message_source="compression_handoff",
)
response_payload["auto_task_started"] = True
response_payload["auto_task_id"] = task_rec.task_id

View File

@ -11,44 +11,49 @@
'message-block--before-sub-agent-notice': isFollowedBySubAgentNotice(index)
}"
>
<div v-if="msg.role === 'user'" class="user-message" :class="{ 'user-message--compact': getMessageVisibility(msg) === 'compact' }">
<div class="message-header icon-label">
<span class="icon icon-sm" :style="iconStyleSafe(userHeaderIconKey(msg))" aria-hidden="true"></span>
<span>{{ userHeaderLabel(msg) }}</span>
<div v-if="msg.role === 'user'" class="user-message" :class="{ 'user-message--compact': getMessageVisibility(msg) === 'compact', 'user-message--brief': isBriefCompactMessage(msg) }">
<div v-if="isBriefCompactMessage(msg)" class="compact-brief-line" role="separator">
<span class="compact-brief-text">{{ compactBriefLabel(msg) }}</span>
</div>
<div class="message-text user-bubble-text">
<div
v-if="msg.content"
class="bubble-text"
v-html="renderUserMessageContent(msg.content)"
></div>
<div v-if="msg.images && msg.images.length" class="image-inline-row">
<template v-else>
<div class="message-header icon-label">
<span class="icon icon-sm" :style="iconStyleSafe(userHeaderIconKey(msg))" aria-hidden="true"></span>
<span>{{ userHeaderLabel(msg) }}</span>
</div>
<div class="message-text user-bubble-text">
<div
class="image-thumbnail-wrapper"
v-for="(img, imgIndex) in msg.images"
:key="mediaPreviewKey(msg, img, imgIndex)"
>
<img
:src="getPreviewUrl(msg, img, 'image')"
:alt="formatImageName(img)"
class="image-thumbnail"
/>
v-if="msg.content"
class="bubble-text"
v-html="renderUserMessageContent(msg.content)"
></div>
<div v-if="msg.images && msg.images.length" class="image-inline-row">
<div
class="image-thumbnail-wrapper"
v-for="(img, imgIndex) in msg.images"
:key="mediaPreviewKey(msg, img, imgIndex)"
>
<img
:src="getPreviewUrl(msg, img, 'image')"
:alt="formatImageName(img)"
class="image-thumbnail"
/>
</div>
</div>
<div v-if="msg.videos && msg.videos.length" class="image-inline-row video-inline-row">
<div
class="image-thumbnail-wrapper"
v-for="(video, videoIndex) in msg.videos"
:key="mediaPreviewKey(msg, video, videoIndex)"
>
<img
:src="getPreviewUrl(msg, video, 'video')"
:alt="formatImageName(video)"
class="image-thumbnail"
/>
</div>
</div>
</div>
<div v-if="msg.videos && msg.videos.length" class="image-inline-row video-inline-row">
<div
class="image-thumbnail-wrapper"
v-for="(video, videoIndex) in msg.videos"
:key="mediaPreviewKey(msg, video, videoIndex)"
>
<img
:src="getPreviewUrl(msg, video, 'video')"
:alt="formatImageName(video)"
class="image-thumbnail"
/>
</div>
</div>
</div>
</template>
</div>
<div v-else-if="msg.role === 'assistant'" class="assistant-message">
<!-- assistant 消息前显示 AI Assistant 头部 -->
@ -627,6 +632,9 @@ const personalization = usePersonalizationStore();
const blockDisplayMode = computed(() => {
return personalization.experiments.blockDisplayMode || 'stacked';
});
const compactMessageDisplay = computed(() => {
return personalization.experiments.compactMessageDisplay || 'full';
});
const stackedBlocksEnabled = computed(() => {
// 使
return blockDisplayMode.value === 'stacked' || blockDisplayMode.value === 'minimal';
@ -1380,6 +1388,12 @@ function userHeaderLabel(msg: any): string {
if (source === 'goal') {
return '目标';
}
if (source === 'goal_review') {
return '审核';
}
if (source === 'compression' || source === 'compression_handoff') {
return '压缩';
}
if (source === 'notify' || source === 'sub_agent' || source === 'background_command') {
return '通知';
}
@ -1394,12 +1408,54 @@ function userHeaderIconKey(msg: any): string {
if (source === 'goal') {
return 'flag';
}
if (source === 'goal_review') {
return 'circleAlert';
}
if (source === 'compression' || source === 'compression_handoff') {
return 'layers';
}
if (source === 'notify' || source === 'sub_agent' || source === 'background_command') {
return 'bell';
}
return 'user';
}
// ---- compact ----
// compact
function isBriefCompactMessage(msg: any): boolean {
if (!msg || msg.role !== 'user') {
return false;
}
if (compactMessageDisplay.value !== 'brief') {
return false;
}
return getMessageVisibility(msg) === 'compact';
}
//
function compactBriefLabel(msg: any): string {
const source = userHeaderSource(msg);
switch (source) {
case 'goal_review':
return '目标审核完成';
case 'sub_agent':
return '后台子智能体完成';
case 'background_command':
return '后台指令完成';
case 'compression':
case 'compression_handoff':
return '对话压缩完成';
case 'goal':
return '目标进行中';
case 'guidance':
return '运行中引导';
case 'notify':
return '系统通知';
default:
return '系统消息';
}
}
onMounted(() => {
attachBounceListener();
console.warn('[SCROLL_BOUNCE_TRACE]', 'mounted', {

View File

@ -615,6 +615,45 @@
</div>
</div>
</div>
<div class="settings-select-row">
<span class="settings-row-copy"
><span class="settings-row-title">简略消息显示</span
><span class="settings-row-desc"
>审核子智能体等紧凑系统消息的显示形式</span
></span
>
<div
class="settings-select-wrap"
:class="{ open: activeDropdown === 'compact-message' }"
@click.stop
>
<button
type="button"
class="settings-select-button"
@click="toggleDropdown('compact-message')"
>
{{ compactMessageDisplayLabel }}
<span class="select-chevron" aria-hidden="true"></span>
</button>
<div
:class="['settings-floating-menu', { dark: activeTheme === 'dark' }]"
:style="activeDropdown ? floatingMenuStyle : undefined"
>
<button
v-for="option in compactMessageDisplayOptions"
:key="option.id"
type="button"
class="settings-menu-option"
:class="{ selected: currentCompactMessageDisplay === option.value }"
@click="selectCompactMessageDisplay(option.value)"
>
<strong>{{ option.label }}</strong
><span>{{ option.desc }}</span
><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
</button>
</div>
</div>
</div>
</section>
<section
@ -1656,6 +1695,16 @@ const blockDisplayLabel = computed(() => {
);
});
const currentCompactMessageDisplay = computed(() => experiments.value.compactMessageDisplay);
const compactMessageDisplayLabel = computed(() => {
return (
compactMessageDisplayOptions.find(
(option) => option.value === currentCompactMessageDisplay.value
)?.label || '完整信息'
);
});
const usageSummary = ref({
total_input_tokens: 0,
total_output_tokens: 0,
@ -2084,6 +2133,26 @@ const handleBlockDisplayModeChange = (mode: 'traditional' | 'stacked' | 'minimal
personalization.setBlockDisplayMode(mode);
};
const compactMessageDisplayOptions = [
{
id: 'full',
label: '完整信息',
desc: '审核、子智能体等系统消息显示完整原始内容',
value: 'full' as const
},
{
id: 'brief',
label: '简略信息',
desc: '仅以一行横线概要替代(如「目标审核完成」)',
value: 'brief' as const
}
];
const selectCompactMessageDisplay = (mode: 'full' | 'brief') => {
personalization.setCompactMessageDisplay(mode);
closeDropdown();
};
const openAdminPanel = () => {
window.open('/admin/monitor', '_blank', 'noopener');
personalization.closeDrawer();

View File

@ -2,6 +2,7 @@ import { defineStore } from 'pinia';
import { useModelStore } from './model';
export type BlockDisplayMode = 'traditional' | 'stacked' | 'minimal';
export type CompactMessageDisplay = 'full' | 'brief';
type RunMode = 'fast' | 'thinking' | 'deep';
type PermissionMode = 'readonly' | 'approval' | 'auto_approval' | 'unrestricted';
@ -53,6 +54,7 @@ interface PersonalForm {
interface ExperimentState {
blockDisplayMode: BlockDisplayMode;
compactMessageDisplay: CompactMessageDisplay;
}
interface PersonalizationState {
@ -142,7 +144,8 @@ const defaultForm = (): PersonalForm => ({
});
const defaultExperimentState = (): ExperimentState => ({
blockDisplayMode: 'stacked'
blockDisplayMode: 'stacked',
compactMessageDisplay: 'full'
});
const loadExperimentState = (): ExperimentState => {
@ -168,8 +171,18 @@ const loadExperimentState = (): ExperimentState => {
blockDisplayMode = parsed.stackedBlocksEnabled ? 'stacked' : 'traditional';
}
let compactMessageDisplay: CompactMessageDisplay =
defaultExperimentState().compactMessageDisplay;
if (
typeof parsed?.compactMessageDisplay === 'string' &&
['full', 'brief'].includes(parsed.compactMessageDisplay)
) {
compactMessageDisplay = parsed.compactMessageDisplay;
}
return {
blockDisplayMode
blockDisplayMode,
compactMessageDisplay
};
} catch (error) {
console.warn('无法读取实验功能设置:', error);
@ -760,6 +773,13 @@ export const usePersonalizationStore = defineStore('personalization', {
};
this.persistExperiments();
},
setCompactMessageDisplay(mode: CompactMessageDisplay) {
this.experiments = {
...this.experiments,
compactMessageDisplay: mode
};
this.persistExperiments();
},
setCommunicationStyle(style: 'default' | 'human_like') {
this.form = {
...this.form,

View File

@ -511,6 +511,38 @@
border: 1px solid var(--theme-control-border);
}
/* 简略信息:以横跨显示区域的横线包裹一行概要文字(类似 markdown 的 --- 分隔线)。 */
.user-message.user-message--brief {
align-items: stretch;
width: 100%;
}
.user-message.user-message--brief .compact-brief-line {
display: flex;
align-items: center;
gap: 14px;
width: min(760px, 92%);
margin: 2px auto;
color: var(--claude-text-secondary);
user-select: none;
}
.user-message.user-message--brief .compact-brief-line::before,
.user-message.user-message--brief .compact-brief-line::after {
content: '';
flex: 1 1 auto;
height: 0;
border-top: 1px solid var(--theme-control-border, var(--claude-border));
}
.user-message.user-message--brief .compact-brief-text {
flex: 0 0 auto;
font-size: 12.5px;
letter-spacing: 0.04em;
white-space: nowrap;
opacity: 0.9;
}
.user-message .message-text .user-skill-link {
color: #2563eb;
font-weight: 600;

View File

@ -6,6 +6,7 @@ const COMPACT_FALLBACK_SOURCES = new Set([
'guidance',
'goal_review',
'compression',
'compression_handoff',
'sub_agent',
'background_command'
]);
@ -72,7 +73,7 @@ export function messageStartsWork(message: any): boolean {
if (source === 'guidance' || source === 'goal_prompt' || source === 'skill') {
return false;
}
if (source === 'goal_review' || source === 'compression' || isLegacyGoalReview(message)) {
if (source === 'goal_review' || source === 'compression' || source === 'compression_handoff' || isLegacyGoalReview(message)) {
return true;
}
return LEGACY_STARTS_WORK_SOURCES.has(source || 'user');