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 子智能体执行机制
- 子智能体在主进程内 `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` 事件,不阻塞前端输入区。
- 子智能体自然的 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>`。
- `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>`。
- `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>`。
- `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],
previous_records: List[Dict[str, Any]],
user_inputs: List[str],
latest_user_input: str,
runtime_state_lines: Optional[List[str]] = None,
) -> str:
"""生成直接注入模式的引导语:把历次压缩总结、用户输入按顺序拼入正文。"""
lines: List[str] = [
f"当前对话已被第{compression_index}次压缩。以下为按时间顺序汇总的用户输入、历次压缩总结以及最近一次输入,请据此继续工作。",
f"当前对话已被第{compression_index}次压缩。以下为按时间顺序汇总的用户输入、历次压缩总结,请据此继续工作。",
"",
"用户的所有输入",
]
@ -315,12 +314,6 @@ def _build_inject_guide_message(
compression_index,
)
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:
lines.append("")
lines.append("当前运行时状态")
@ -398,7 +391,6 @@ def _write_compact_file(
compression_index: int,
summary_text: str,
user_inputs: List[str],
latest_user_input: str,
previous_records: List[Dict[str, Any]],
runtime_state_lines: Optional[List[str]] = None,
) -> str:
@ -422,12 +414,6 @@ def _write_compact_file(
compression_index,
)
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:
lines.append("")
lines.append("## 当前运行时状态")
@ -566,7 +552,6 @@ async def run_deep_compression(
compression_index=target_count,
summary_text=summary_text,
user_inputs=user_inputs,
latest_user_input=latest_user_input,
previous_records=previous_records,
runtime_state_lines=runtime_state_lines,
)
@ -622,7 +607,6 @@ async def run_deep_compression(
current_record=current_record,
previous_records=previous_records,
user_inputs=user_inputs,
latest_user_input=latest_user_input,
runtime_state_lines=runtime_state_lines,
)
else:

View File

@ -143,20 +143,61 @@ function collectShowImageOrder(root: ParentNode | null = document) {
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) {
if (!src) return '';
const trimmed = src.trim();
let trimmed = src.trim();
if (!trimmed) return '';
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;
// 兼容容器内部路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx
const idx = trimmed.toLowerCase().indexOf('/user_upload/');
if (idx >= 0) {
return '/user_upload/' + trimmed.slice(idx + '/user_upload/'.length);
}
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../')) {
return trimmed;
// /workspace 前缀是容器内绝对路径写法,剥掉后按工作区相对路径处理
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) {
@ -555,6 +596,17 @@ function renderShowImages(root: ParentNode | null = document) {
const rawSrc = node.getAttribute('src') || '';
const mappedSrc = normalizeShowImageSrc(rawSrc);
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-error', 'invalid-src');
if (verbose) debugShowImageLog('render:invalid-src', { renderId, rawSrc });

View File

@ -4,7 +4,8 @@ import { debugLog } from '../common';
import { useModelStore } from '../../../stores/model';
// 兼容 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 = '') {
const refs = [];

View File

@ -1069,7 +1069,8 @@ const escapeUserHtml = (value: string): string =>
.replace(/'/g, '&#39;');
// 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 SUB_AGENT_DONE_LABEL_RE = /^子智能体\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 {
display: grid;

View File

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

View File

@ -1597,7 +1597,7 @@ const rootSlashMenuItems = computed<SlashMenuItem[]>(() => {
{
id: 'run-mode',
label: '思考模式',
description: '切换 fast / thinking / deep',
description: '切换 fast / thinking',
disabled: !props.isConnected || props.streamingMessage,
mode: 'run-mode'
},
@ -3841,6 +3841,15 @@ onBeforeUnmount(() => {
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-leave-active {
transition:

View File

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

View File

@ -4,7 +4,6 @@ import json
import os
import time
import tempfile
import threading
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Any
@ -24,78 +23,10 @@ try:
except Exception:
perf_log = None
try:
from config import LOGS_DIR as _CONV_SAVE_LOG_DIR
except Exception:
_CONV_SAVE_LOG_DIR = None
# save_conversation 专用哨兵:区分「不更新该字段」与「显式清除为 None」
_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
class ConversationMetadata:
"""对话元数据"""
@ -266,7 +197,6 @@ class CrudMixin:
try:
# 确保Token统计数据有效
data = self._validate_token_statistics(data)
_debug_conversation_save_trace(conversation_id, file_path, data)
with self._io_lock:
self._atomic_write_json(file_path, data)
except Exception as e: