Compare commits

...

6 Commits

Author SHA1 Message Date
c6c8bf2d0c style(theme): 明亮/经典主题视觉层次修正
- light 主题:摘要行降为三级文字色与正文拉开层级(含 running 动画变体)
- light 主题:代码块头部浅灰、代码区与对话底同色;表格白体+浅灰表头+可见边框
- light 主题:输入栏壳体改浅灰浮起,发送按钮柔化为 78% 黑、hover 加深
- classic 主题:git 状态栏及子菜单背景与输入栏壳体对齐为纯白
- 顺带修正:思考模式 slash 菜单描述去掉已下线的 deep
2026-08-23 17:13:16 +08:00
9d3784bd90 refactor(compression): 压缩注入引导语与存档不再重复附带用户最近一次输入
最近一次输入本身会作为正常消息出现在上下文中,注入消息里再附一份属于重复
2026-08-23 17:13:02 +08:00
80284f1732 fix(chat): show_image 本地图片路径统一走 /api/file/content,修复裸相对路径静默不显示
- normalizeShowImageSrc 重写:http(s) 与 /user_upload/ 原样保留,其余本地路径
  统一映射为 /api/file/content?path=...(与 show_file/view_image 同一通道)
- docker/web 模式:支持工作区内任意相对路径(含 ./、/ 前缀归一化);
  host 模式:另支持任意绝对路径,file:// 前缀剥离后按本地路径处理
- invalid-src 不再静默吞掉:渲染可见错误占位(不支持的图片路径 xxx)
- 提示词同步澄清:本地图片写工作区相对路径,host 模式可用绝对路径
2026-08-23 17:12:46 +08:00
d3622ce438 fix(quickdock): 行高由 1 改为 1.4 避免下伸字母被裁剪
line-height 压成 1 时瘦高字体的下伸部(g/p/y 等)会超出行盒被 overflow 裁掉
2026-08-22 19:12:08 +08:00
0563588c74 fix(chat): skill 链接正则适配后端相对路径格式
后端自 cedef87d 起 /api/skills 返回相对工作区路径(.astrion/skills/...),
SKILL_MARKDOWN_LINK_RE 与 USER_SKILL_LINK_RE 的路径前缀改为可选,兼容历史绝对路径消息
2026-08-22 19:12:08 +08:00
da176ba4c7 chore: 记忆审计修正 —— AGENTS.md APIClient 引用 + 删除保存调试 trace 代码
- AGENTS.md §11.2: DeepSeekClient → APIClient(2026-07-28 改名的遗留引用)
- crud_mixin.py: 删除已过观察期的 _debug_conversation_save_trace(含 shrink/wipe_suspect 判定、调用栈捕获、conversation_save_debug.log 落盘)及配套 _CONV_SAVE_LOG_DIR / threading 导入
- 保留 ConvSaveGuard 断言与 ConvSaveMerge 矫正打印(机制级长期日志)
- 验证:模块加载成功 + test_server_refactor_smoke 6/6 通过
2026-08-22 19:12:08 +08:00
14 changed files with 149 additions and 102 deletions

View File

@ -410,7 +410,7 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
### 11.2 子智能体执行机制 ### 11.2 子智能体执行机制
- 子智能体在主进程内 `asyncio.Task`,跑在独立后台事件循环线程里(避开 Flask-SocketIO threading 冲突)。工具调用复用主进程沙箱/容器链路,网络调用走 `utils.api_client.DeepSeekClient`。 - 子智能体在主进程内 `asyncio.Task`,跑在独立后台事件循环线程里(避开 Flask-SocketIO threading 冲突)。工具调用复用主进程沙箱/容器链路,网络调用走 `utils.api_client.APIClient`。
- 子智能体在多智能体模式下: - 子智能体在多智能体模式下:
- `create_sub_agent` 强制 `run_in_background=False`,不触发 `sub_agent_waiting` 事件,不阻塞前端输入区。 - `create_sub_agent` 强制 `run_in_background=False`,不触发 `sub_agent_waiting` 事件,不阻塞前端输入区。
- 子智能体自然的 assistant 输出结束(无 tool_calls即本轮任务结束进入 `idle`,上下文保留,不算 failed。 - 子智能体自然的 assistant 输出结束(无 tool_calls即本轮任务结束进入 `idle`,上下文保留,不算 failed。

View File

@ -54,7 +54,7 @@
- 一次展示优先只输出一个完整标签块(开标签 + 内容 + 闭标签),不要拆段输出 - 一次展示优先只输出一个完整标签块(开标签 + 内容 + 闭标签),不要拆段输出
- 若还需要解释说明,把说明写在标签之外,且不要再次写标签字面量 - 若还需要解释说明,把说明写在标签之外,且不要再次写标签字面量
如需在界面直接展示图片卡片,使用 `<show_image src="路径" alt="描述" />`,支持本地路径或网络链接。禁止使用 Markdown 图片语法。 如需在界面直接展示图片卡片,使用 `<show_image src="路径" alt="描述" />`。本地图片写工作区相对路径(如 `output/chart.png`host/宿主机模式也可用任意绝对路径),网络图片写 http(s) 链接。禁止使用 Markdown 图片语法。
如需在界面直接渲染 HTML 卡片,使用 `<show_html ratio="比例" js="off|on">...</show_html>`。 如需在界面直接渲染 HTML 卡片,使用 `<show_html ratio="比例" js="off|on">...</show_html>`。
- `ratio` 只能从以下五种里选:`1:1`、`16:9`、`9:16`、`4:3`、`3:4` - `ratio` 只能从以下五种里选:`1:1`、`16:9`、`9:16`、`4:3`、`3:4`

View File

@ -54,7 +54,7 @@
- 一次展示优先只输出一个完整标签块(开标签 + 内容 + 闭标签),不要拆段输出 - 一次展示优先只输出一个完整标签块(开标签 + 内容 + 闭标签),不要拆段输出
- 若还需要解释说明,把说明写在标签之外,且不要再次写标签字面量 - 若还需要解释说明,把说明写在标签之外,且不要再次写标签字面量
如需在界面直接展示图片卡片,使用 `<show_image src="路径" alt="描述" />`,支持本地路径或网络链接。禁止使用 Markdown 图片语法。 如需在界面直接展示图片卡片,使用 `<show_image src="路径" alt="描述" />`。本地图片写工作区相对路径(如 `output/chart.png`host/宿主机模式也可用任意绝对路径),网络图片写 http(s) 链接。禁止使用 Markdown 图片语法。
如需在界面直接渲染 HTML 卡片,使用 `<show_html ratio="比例" js="off|on">...</show_html>`。 如需在界面直接渲染 HTML 卡片,使用 `<show_html ratio="比例" js="off|on">...</show_html>`。
- `ratio` 只能从以下五种里选:`1:1`、`16:9`、`9:16`、`4:3`、`3:4` - `ratio` 只能从以下五种里选:`1:1`、`16:9`、`9:16`、`4:3`、`3:4`

View File

@ -226,7 +226,7 @@ id: ask_fse_001
- 一次展示优先只输出一个完整标签块(开标签 + 内容 + 闭标签),不要拆段输出 - 一次展示优先只输出一个完整标签块(开标签 + 内容 + 闭标签),不要拆段输出
- 若还需要解释说明,把说明写在标签之外,且不要再次写标签字面量 - 若还需要解释说明,把说明写在标签之外,且不要再次写标签字面量
如需在界面直接展示图片卡片,使用 `<show_image src="路径" alt="描述" />`,支持本地路径或网络链接。禁止使用 Markdown 图片语法。 如需在界面直接展示图片卡片,使用 `<show_image src="路径" alt="描述" />`。本地图片写工作区相对路径(如 `output/chart.png`host/宿主机模式也可用任意绝对路径),网络图片写 http(s) 链接。禁止使用 Markdown 图片语法。
如需在界面直接渲染 HTML 卡片,使用 `<show_html ratio="比例" js="off|on">...</show_html>`。 如需在界面直接渲染 HTML 卡片,使用 `<show_html ratio="比例" js="off|on">...</show_html>`。
- `ratio` 只能从以下五种里选:`1:1`、`16:9`、`9:16`、`4:3`、`3:4` - `ratio` 只能从以下五种里选:`1:1`、`16:9`、`9:16`、`4:3`、`3:4`

View File

@ -296,12 +296,11 @@ def _build_inject_guide_message(
current_record: Dict[str, Any], current_record: Dict[str, Any],
previous_records: List[Dict[str, Any]], previous_records: List[Dict[str, Any]],
user_inputs: List[str], user_inputs: List[str],
latest_user_input: str,
runtime_state_lines: Optional[List[str]] = None, runtime_state_lines: Optional[List[str]] = None,
) -> str: ) -> str:
"""生成直接注入模式的引导语:把历次压缩总结、用户输入按顺序拼入正文。""" """生成直接注入模式的引导语:把历次压缩总结、用户输入按顺序拼入正文。"""
lines: List[str] = [ lines: List[str] = [
f"当前对话已被第{compression_index}次压缩。以下为按时间顺序汇总的用户输入、历次压缩总结以及最近一次输入,请据此继续工作。", f"当前对话已被第{compression_index}次压缩。以下为按时间顺序汇总的用户输入、历次压缩总结,请据此继续工作。",
"", "",
"用户的所有输入", "用户的所有输入",
] ]
@ -315,12 +314,6 @@ def _build_inject_guide_message(
compression_index, compression_index,
) )
lines.extend(summary_lines) lines.extend(summary_lines)
lines.append("")
lines.append("用户的最近一次输入:")
if (latest_user_input or "").strip():
lines.append(latest_user_input.strip())
else:
lines.append("(无)")
if runtime_state_lines: if runtime_state_lines:
lines.append("") lines.append("")
lines.append("当前运行时状态") lines.append("当前运行时状态")
@ -398,7 +391,6 @@ def _write_compact_file(
compression_index: int, compression_index: int,
summary_text: str, summary_text: str,
user_inputs: List[str], user_inputs: List[str],
latest_user_input: str,
previous_records: List[Dict[str, Any]], previous_records: List[Dict[str, Any]],
runtime_state_lines: Optional[List[str]] = None, runtime_state_lines: Optional[List[str]] = None,
) -> str: ) -> str:
@ -422,12 +414,6 @@ def _write_compact_file(
compression_index, compression_index,
) )
lines.extend(summary_lines) lines.extend(summary_lines)
lines.append("")
lines.append("## 用户最新的一次输入")
if (latest_user_input or "").strip():
lines.append(latest_user_input.strip())
else:
lines.append("(无)")
if runtime_state_lines: if runtime_state_lines:
lines.append("") lines.append("")
lines.append("## 当前运行时状态") lines.append("## 当前运行时状态")
@ -566,7 +552,6 @@ async def run_deep_compression(
compression_index=target_count, compression_index=target_count,
summary_text=summary_text, summary_text=summary_text,
user_inputs=user_inputs, user_inputs=user_inputs,
latest_user_input=latest_user_input,
previous_records=previous_records, previous_records=previous_records,
runtime_state_lines=runtime_state_lines, runtime_state_lines=runtime_state_lines,
) )
@ -622,7 +607,6 @@ async def run_deep_compression(
current_record=current_record, current_record=current_record,
previous_records=previous_records, previous_records=previous_records,
user_inputs=user_inputs, user_inputs=user_inputs,
latest_user_input=latest_user_input,
runtime_state_lines=runtime_state_lines, runtime_state_lines=runtime_state_lines,
) )
else: else:

View File

@ -143,20 +143,61 @@ function collectShowImageOrder(root: ParentNode | null = document) {
return tokens.slice(0, 200); return tokens.slice(0, 200);
} }
/* host/docker app/state.ts WORKSPACE_MODE_STORAGE_KEY
socket localStorage */
function getShowImageWorkspaceMode(): 'host' | 'docker' {
try {
return window.localStorage.getItem('agents_workspace_mode') === 'host' ? 'host' : 'docker';
} catch {
return 'docker';
}
}
/**
* show_image src
* - http(s) /user_upload/ 使
* - /api/file/content inline MIME
* - docker/web ../ _validate_path
* - host _validate_path host
*
*/
function normalizeShowImageSrc(src: string) { function normalizeShowImageSrc(src: string) {
if (!src) return ''; if (!src) return '';
const trimmed = src.trim(); let trimmed = src.trim();
if (!trimmed) return '';
if (/^https?:\/\//i.test(trimmed)) return trimmed; if (/^https?:\/\//i.test(trimmed)) return trimmed;
// file:// 前缀剥离按本地路径处理(浏览器禁止 http 页面直接加载 file:// 资源)
if (/^file:\/\//i.test(trimmed)) {
trimmed = trimmed.replace(/^file:\/\//i, '');
// Windows file:///C:/... → C:/...
trimmed = trimmed.replace(/^\/(?=[A-Za-z]:\/)/, '');
}
if (trimmed.startsWith('/user_upload/')) return trimmed; if (trimmed.startsWith('/user_upload/')) return trimmed;
// 兼容容器内部路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx // 兼容容器内部路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx
const idx = trimmed.toLowerCase().indexOf('/user_upload/'); const idx = trimmed.toLowerCase().indexOf('/user_upload/');
if (idx >= 0) { if (idx >= 0) {
return '/user_upload/' + trimmed.slice(idx + '/user_upload/'.length); return '/user_upload/' + trimmed.slice(idx + '/user_upload/'.length);
} }
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../')) { // /workspace 前缀是容器内绝对路径写法,剥掉后按工作区相对路径处理
return trimmed; if (trimmed === '/workspace') return '';
if (trimmed.startsWith('/workspace/')) {
trimmed = trimmed.slice('/workspace/'.length);
} }
return ''; // 前导 ./ 语义等同工作区相对路径,剥掉保持整洁
if (trimmed.startsWith('./')) {
trimmed = trimmed.slice(2);
}
if (!trimmed) return '';
if (trimmed.startsWith('/')) {
if (getShowImageWorkspaceMode() === 'host') {
// host 模式保留绝对路径语义(如 /Users/xxx/img.png后端全放行
return `/api/file/content?path=${encodeURIComponent(trimmed)}`;
}
// docker 模式下前导 / 视为工作区根相对路径(真绝对路径会被后端拒绝)
trimmed = trimmed.replace(/^\/+/, '');
if (!trimmed) return '';
}
return `/api/file/content?path=${encodeURIComponent(trimmed)}`;
} }
function parsePixelSize(raw: string | null, fallback: number) { function parsePixelSize(raw: string | null, fallback: number) {
@ -555,6 +596,17 @@ function renderShowImages(root: ParentNode | null = document) {
const rawSrc = node.getAttribute('src') || ''; const rawSrc = node.getAttribute('src') || '';
const mappedSrc = normalizeShowImageSrc(rawSrc); const mappedSrc = normalizeShowImageSrc(rawSrc);
if (!mappedSrc) { if (!mappedSrc) {
// 不再静默吞掉:渲染可见错误占位,方便定位路径问题
const figure = document.createElement('figure');
figure.className =
'chat-inline-card chat-inline-card--image chat-inline-image chat-inline-card--error chat-inline-image--error';
const tip = document.createElement('div');
tip.className = 'chat-inline-card__error chat-inline-image__error';
tip.textContent = rawSrc
? `无法显示图片:不支持的图片路径 ${rawSrc}`
: '无法显示图片:缺少图片路径';
figure.appendChild(tip);
node.replaceChildren(figure);
node.setAttribute('data-rendered', '1'); node.setAttribute('data-rendered', '1');
node.setAttribute('data-rendered-error', 'invalid-src'); node.setAttribute('data-rendered-error', 'invalid-src');
if (verbose) debugShowImageLog('render:invalid-src', { renderId, rawSrc }); if (verbose) debugShowImageLog('render:invalid-src', { renderId, rawSrc });

View File

@ -4,7 +4,8 @@ import { debugLog } from '../common';
import { useModelStore } from '../../../stores/model'; import { useModelStore } from '../../../stores/model';
// 兼容 Windows 反斜杠路径skills 目录与 SKILL.md 前的分隔符同时接受 / 和 \ // 兼容 Windows 反斜杠路径skills 目录与 SKILL.md 前的分隔符同时接受 / 和 \
export const SKILL_MARKDOWN_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*[\\/]\.astrion[\\/]skills[\\/][^)\n]+[\\/]SKILL\.md)\)/g; // 路径前缀可选:后端自 cedef87d 起返回相对工作区路径(.astrion/skills/...),历史消息仍可能是绝对路径
export const SKILL_MARKDOWN_LINK_RE = /\[\$([^\]\n]+)\]\(((?:[^)\n]*[\\/])?\.astrion[\\/]skills[\\/][^)\n]+[\\/]SKILL\.md)\)/g;
export function extractSkillRefsFromMessage(message = '') { export function extractSkillRefsFromMessage(message = '') {
const refs = []; const refs = [];

View File

@ -1069,7 +1069,8 @@ const escapeUserHtml = (value: string): string =>
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
// Windows skills SKILL.md / \ // Windows skills SKILL.md / \
const USER_SKILL_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*[\\/]\.astrion[\\/]skills[\\/][^)\n]+[\\/]SKILL\.md)\)/g; // cedef87d .astrion/skills/...
const USER_SKILL_LINK_RE = /\[\$([^\]\n]+)\]\(((?:[^)\n]*[\\/])?\.astrion[\\/]skills[\\/][^)\n]+[\\/]SKILL\.md)\)/g;
const USER_FILE_LINK_RE = /\[([^\]\n]+)\]\(file:\/\/([^)\n]+)\)/g; const USER_FILE_LINK_RE = /\[([^\]\n]+)\]\(file:\/\/([^)\n]+)\)/g;
const SUB_AGENT_DONE_LABEL_RE = /^子智能体\d+\s*任务完成$/; const SUB_AGENT_DONE_LABEL_RE = /^子智能体\d+\s*任务完成$/;
const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[:]/; const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[:]/;

View File

@ -1257,6 +1257,29 @@ onBeforeUnmount(() => {
} }
} }
/* ===== 明亮主题摘要行降为三级文字色与正文text-primary拉开层级 ===== */
/* hover 反馈不做特化:与经典/深色保持一致(基础 hover 底色在三主题下均等于底色,均无可见反馈) */
body[data-theme='light'] .summary-content-wrapper,
body[data-theme='light'] .summary-tool-reel-item {
color: var(--text-tertiary);
}
body[data-theme='light'] .summary-line-text.running .summary-char {
color: var(--text-tertiary);
animation-name: summaryPassLight;
}
@keyframes summaryPassLight {
0%,
100% {
color: var(--text-tertiary);
}
50% {
color: var(--accent);
}
}
/* 步骤容器 */ /* 步骤容器 */
.steps-container { .steps-container {
display: grid; display: grid;

View File

@ -247,7 +247,8 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
font-size: 13px; font-size: 13px;
line-height: 1; /* 不能压成 1瘦高字体的下伸部g/p/y 等)会超出行盒被 overflow 裁掉 */
line-height: 1.4;
color: var(--text-primary); color: var(--text-primary);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -1161,7 +1162,8 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
font-size: 13px; font-size: 13px;
line-height: 1; /* 同 .qd-todo-textline-height 压 1 会裁掉下伸字母 */
line-height: 1.4;
color: var(--text-primary); color: var(--text-primary);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;

View File

@ -1597,7 +1597,7 @@ const rootSlashMenuItems = computed<SlashMenuItem[]>(() => {
{ {
id: 'run-mode', id: 'run-mode',
label: '思考模式', label: '思考模式',
description: '切换 fast / thinking / deep', description: '切换 fast / thinking',
disabled: !props.isConnected || props.streamingMessage, disabled: !props.isConnected || props.streamingMessage,
mode: 'run-mode' mode: 'run-mode'
}, },
@ -3841,6 +3841,15 @@ onBeforeUnmount(() => {
box-shadow: 0 1px 2px var(--shadow-color); box-shadow: 0 1px 2px var(--shadow-color);
} }
/* git --surface-raised
基础 --theme-surface-soft 在经典为米色 #f5f0e8与白色输入栏不一致 */
:global(html[data-theme='classic'] .floating-project-status),
:global(body[data-theme='classic'] .floating-project-status),
:global(html[data-theme='classic'] .floating-project-status__submenu),
:global(body[data-theme='classic'] .floating-project-status__submenu) {
background: var(--surface-raised);
}
.floating-status-motion-enter-active, .floating-status-motion-enter-active,
.floating-status-motion-leave-active { .floating-status-motion-leave-active {
transition: transition:

View File

@ -2523,3 +2523,41 @@ body[data-theme='dark'] {
background: var(--surface-muted) !important; background: var(--surface-muted) !important;
} }
} }
/* ========== 明亮主题变体(对话区组件) ========== */
/* 背景浅色主题表面 token 阶梯近乎纯平base/card/raised 全为 #ffffff
基础样式中代码块白底落白底表格 #f9f9f9 落白底均不可见
参照上方深色覆盖块的做法按用户确认的方向做组件级补偿
代码块仅头部着色代码区与对话底保持同色表格白体+浅灰表头+可见边框 */
body[data-theme='light'] {
/* 代码块 - 头部浅灰、代码区与对话底同为白色,外框加强到可见 */
.code-block-wrapper {
border-color: var(--border-strong);
}
.code-block-header {
background: var(--surface-muted);
border-bottom-color: var(--border-strong);
}
/* 表格 - 白体 + 浅灰表头 + 可见边框 */
.text-output .text-content table {
background: var(--surface-raised);
}
.text-output .text-content thead {
background: var(--surface-muted);
}
.text-output .text-content th,
.text-output .text-content td {
border-color: var(--border-strong);
}
.text-output .text-content .md-table-scroll,
.text-output .text-content [data-md-table-scroll='1'] {
border-color: var(--border-strong);
background: var(--surface-raised);
}
}

View File

@ -396,9 +396,16 @@ body[data-theme='light'] {
color: var(--state-info); color: var(--state-info);
} }
/* 发送按钮 - 黑底白三角,浅阴影做层次 */ /* 输入栏壳体 - 与 git 状态栏同色的浅灰(基础 --surface-raised 在本主题为纯白,与对话底同色无层次;无法比白更浅,反向用略浅灰让壳体浮起) */
.stadium-shell,
.stadium-shell.is-focused,
.stadium-shell.has-text {
background: var(--surface-soft);
}
/* 发送按钮 - 柔化黑底白三角78% 黑hover 加深为实黑,浅阴影做层次 */
.stadium-btn.send-btn { .stadium-btn.send-btn {
background: var(--text-primary); background: color-mix(in srgb, var(--text-primary) 78%, transparent);
color: var(--surface-raised); color: var(--surface-raised);
box-shadow: 0 1px 3px var(--shadow-color); box-shadow: 0 1px 3px var(--shadow-color);
} }

View File

@ -4,7 +4,6 @@ import json
import os import os
import time import time
import tempfile import tempfile
import threading
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
@ -24,78 +23,10 @@ try:
except Exception: except Exception:
perf_log = None perf_log = None
try:
from config import LOGS_DIR as _CONV_SAVE_LOG_DIR
except Exception:
_CONV_SAVE_LOG_DIR = None
# save_conversation 专用哨兵:区分「不更新该字段」与「显式清除为 None」 # save_conversation 专用哨兵:区分「不更新该字段」与「显式清除为 None」
_KEEP = object() _KEEP = object()
def _debug_conversation_save_trace(conversation_id: str, file_path, data: Dict):
"""[临时调试] 记录每次对话文件写入,重点捕获「非空被空覆盖」的调用栈。
输出: logs/conversation_save_debug.log JSONL
排查完对话消息被清空问题后应移除
"""
try:
import traceback
new_msgs = data.get("messages") if isinstance(data, dict) else None
new_len = len(new_msgs) if isinstance(new_msgs, list) else -1
old_len = None
try:
p = Path(file_path)
if p.exists():
old_data = json.loads(p.read_text(encoding="utf-8"))
old_msgs = old_data.get("messages")
old_len = len(old_msgs) if isinstance(old_msgs, list) else None
except Exception:
old_len = None
wipe_suspect = bool(old_len and new_len == 0)
shrink_suspect = bool(old_len and 0 < new_len < old_len)
# 只保留项目内调用帧,排除标准库/第三方
try:
repo_root = Path(__file__).resolve().parents[2]
except Exception:
repo_root = None
frames = []
for fr in traceback.extract_stack()[:-2]:
fn = str(fr.filename)
if repo_root and not fn.startswith(str(repo_root)):
continue
# Windows 下调用帧文件名用反斜杠,统一归一化再匹配
fn_norm = fn.replace("\\", "/")
if "/.astrion/" in fn_norm or "site-packages" in fn_norm:
continue
rel = fn[len(str(repo_root)) + 1:] if repo_root and fn.startswith(str(repo_root)) else fn
frames.append(f"{rel}:{fr.lineno}:{fr.name}")
record = {
"ts": datetime.now().isoformat(),
"event": "conversation_save",
"conversation_id": conversation_id,
"old_msg_len": old_len,
"new_msg_len": new_len,
"wipe_suspect": wipe_suspect,
"shrink_suspect": shrink_suspect,
"title": (data.get("title") if isinstance(data, dict) else None),
"thread": threading.current_thread().name,
"stack_tail": frames[-14:],
}
if _CONV_SAVE_LOG_DIR:
log_dir = Path(_CONV_SAVE_LOG_DIR)
else:
log_dir = Path(DATA_DIR).parent / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
with open(log_dir / "conversation_save_debug.log", "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
if wipe_suspect or shrink_suspect:
kind = "空列表覆盖" if wipe_suspect else f"缩减 {old_len}->{new_len}"
print(f"🚨 [ConvSaveDebug] 对话 {conversation_id} 消息将被{kind}!栈: {frames[-14:]}")
except Exception:
pass
@dataclass @dataclass
class ConversationMetadata: class ConversationMetadata:
"""对话元数据""" """对话元数据"""
@ -266,7 +197,6 @@ class CrudMixin:
try: try:
# 确保Token统计数据有效 # 确保Token统计数据有效
data = self._validate_token_statistics(data) data = self._validate_token_statistics(data)
_debug_conversation_save_trace(conversation_id, file_path, data)
with self._io_lock: with self._io_lock:
self._atomic_write_json(file_path, data) self._atomic_write_json(file_path, data)
except Exception as e: except Exception as e: