Compare commits

...

4 Commits

20 changed files with 880 additions and 159 deletions

View File

@ -173,6 +173,7 @@
- CLI React/TS保留现有 Ink 渲染方式与光标修正逻辑,不要轻易重写输入框定位策略。
- 日志:优先复用现有 logger/日志路径,不引入大量临时 `print`
- 提交前至少做与改动相关的最小验证(命令输出或手工步骤要可复现)。
- 视觉验证默认由用户完成:除非用户特别说明,所有需要视觉确认的修改(尤其是动画/过渡效果),构建/lint 通过后交由用户亲自查看确认,不要用 Playwright 截图或浏览器自动化代替用户验收。
- 运行根目录前端构建时,默认使用 `npm run build --silent 2>&1 | tail -n 5`
### CLI 当前交互约束2026-05-15

View File

@ -125,9 +125,23 @@ class MessagesMixin:
logger.warning(f"[messages] 加载多智能体主 prompt 失败: {exc}")
system_prompt_template = self.load_prompt(prompt_name)
# main_system.txt / main_system_vl.txt 仅使用 {model_description}
return system_prompt_template.format(
base_prompt = system_prompt_template.format(
model_description=prompt_replacements.get("model_description", "")
)
# 修改留痕附言:开关开启且 conversation_id 可用时追加独立 prompt 段落
# (含本对话专属留痕目录;冻结在 prompt 中,与该对话终身一致)
try:
from modules.modify_history import build_modify_history_prompt_note
note = build_modify_history_prompt_note(
project_path=getattr(self, "project_path", None),
data_dir=getattr(self, "data_dir", None),
conversation_id=getattr(getattr(self, "context_manager", None), "current_conversation_id", None),
)
if note:
base_prompt = f"{base_prompt}\n\n{note}"
except Exception as exc:
logger.warning(f"[messages] 构建修改留痕 prompt 失败: {exc}")
return base_prompt
main_system_frozen_key = (
"frozen_main_system_prompt_multi_agent"

View File

@ -163,6 +163,28 @@ def _find_current_work_user_message(context_manager: Any) -> Optional[Dict[str,
return None
def _strip_fulltext_for_broadcast(summary: Dict[str, Any]) -> Dict[str, Any]:
"""广播副本剥离 current_text前端只用 lines/计数,且全文可能很大)。
持久化的对话 JSON 保留 current_text供修改留痕modify_history重建 diff
"""
files = summary.get("files")
if not isinstance(files, list):
return summary
stripped_files: List[Dict[str, Any]] = []
changed = False
for entry in files:
if isinstance(entry, dict) and "current_text" in entry:
entry = {k: v for k, v in entry.items() if k != "current_text"}
changed = True
stripped_files.append(entry)
if not changed:
return summary
out = dict(summary)
out["files"] = stripped_files
return out
def _persist_and_broadcast(
context_manager: Any,
conversation_id: str,
@ -170,7 +192,13 @@ def _persist_and_broadcast(
summary: Dict[str, Any],
web_callback: WebCallback,
) -> None:
"""持久化对话并广播最新编辑摘要。"""
"""持久化对话并广播最新编辑摘要;同步刷新修改留痕文件。"""
# 实时修改留痕:每次摘要变化即重渲染本轮 diff 文件(编辑时刻内容,零磁盘依赖)
try:
from modules.modify_history import update_modify_history_for_task
update_modify_history_for_task(context_manager, msg, summary)
except Exception as exc:
print(f"⚠️ 修改留痕实时写入失败: {exc}")
try:
context_manager.auto_save_conversation(force=True)
except Exception as exc:
@ -180,7 +208,7 @@ def _persist_and_broadcast(
web_callback("edit_summary_updated", {
"conversation_id": conversation_id,
"message_id": msg.get("message_id"),
"edit_summary": summary,
"edit_summary": _strip_fulltext_for_broadcast(summary),
})
except Exception:
pass
@ -246,12 +274,16 @@ def update_edit_summary(
status = "added" if entry.get("baseline") is None and not entry.get("baseline_truncated") else "modified"
now_iso = datetime.now().isoformat()
current_too_large = isinstance(current_text, str) and len(current_text) > MAX_BASELINE_CHARS
entry.update({
"status": status,
"added": diff["added"],
"removed": diff["removed"],
"lines": diff["lines"],
"truncated": bool(diff["truncated"]) or bool(entry.get("baseline_truncated")),
# 编辑时刻的最新全文:供修改留痕重建 diff不依赖任务结束时的磁盘状态
"current_text": None if current_too_large else current_text,
"current_truncated": current_too_large,
"updated_at": now_iso,
})
summary["updated_at"] = now_iso

465
modules/modify_history.py Normal file
View File

@ -0,0 +1,465 @@
"""修改留痕Modify History把本轮工作的净修改实时落盘为增强版 unified diff。
设计要点已与用户确认
- **实时写入**与编辑摘要edit_summary完全同步每次 write_file / edit_file /
delete_file / rename_file 引起摘要变化时立即重渲染本轮留痕文件
``update_modify_history_for_task``挂在 ``modules/edit_summary._persist_and_broadcast``
任务中途异常停止留痕也保留到最后一次编辑的状态
- **零磁盘依赖**diff 的新侧内容来自 edit_summary entry 在编辑时刻记录的
``current_text``全文 baseline 同享 400KB 上限不读任务结束时的磁盘
``run_command`` 对文件的改动/删除不会混入留痕用户定义只记录原生编辑工具
- **任务结束收尾**``finalize_modify_history_for_task``挂在
``server/chat_flow_task_main.finalize_user_work_timer``更新头部完成时间
并做文件存在性检查任务结束时已不存在 rm/移动的文件小节转为
``/dev/null 最后内容`` new file diff 并标注``git apply`` 可直接恢复该文件
- 输出``<工作区>/.astrion/modify_history/<conversation_id>/<用户输入截断>_<任务开始时间>.diff``
一次任务一条用户输入一个文件
- 格式``#`` 注释头(任务信息)+ 每文件小节注释 + 标准 ``diff --git`` 主体。
注释行在 diff 块之外不影响 ``git apply`` / ``patch``已实测验证
文件处于修改前状态时 ``git apply`` 重做处于修改后状态时 ``git apply -R`` 撤销
- 直接 IO 写入绕开 write_file / edit_file 工具层不触发深度备份
shallow_versioning.track_edit与编辑摘要零递归
- 开关个性化 ``modify_history_enabled``默认开启关闭时既不落盘也不注入
system prompt 附言
- 已知边界baseline current 400KB 被截断的文件无法重建完整 diff
小节内会明确标注仅保留计数
"""
from __future__ import annotations
import difflib
import logging
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
_ASTRION_DIR_NAME = ".astrion"
MODIFY_HISTORY_DIR_NAME = "modify_history"
_PROMPT_FILE_NAME = "modify_history.txt"
_COMMENT_BAR_HEAVY = "# " + "" * 60
_COMMENT_BAR_LIGHT = "# " + "" * 60
# summary 中记录本轮留痕文件名的私有字段(随对话 JSON 持久化,供收尾定位同一文件)
_HISTORY_FILE_KEY = "_history_file"
# 文件名中用户输入片段的长度上限与文件系统非法字符
_FILENAME_INPUT_MAX_CHARS = 40
_FILENAME_ILLEGAL_RE = re.compile(r'[/\\:*?"<>|\x00-\x1f]')
# ---------------------------------------------------------------------------
# 路径与开关
# ---------------------------------------------------------------------------
def get_modify_history_dir(project_path: Any, conversation_id: Optional[str]) -> Optional[Path]:
"""本对话的留痕目录conversation_id 缺失时返回 None。"""
conv_id = str(conversation_id or "").strip()
if not project_path or not conv_id:
return None
return (
Path(project_path).expanduser().resolve()
/ _ASTRION_DIR_NAME
/ MODIFY_HISTORY_DIR_NAME
/ conv_id
)
def is_modify_history_enabled(data_dir: Any) -> bool:
"""读取个性化开关;读取失败按默认开启处理。"""
try:
from modules.personalization_manager import load_personalization_config
config = load_personalization_config(data_dir)
return bool(config.get("modify_history_enabled", True))
except Exception:
return True
# ---------------------------------------------------------------------------
# system prompt 附言
# ---------------------------------------------------------------------------
def build_modify_history_prompt_note(
*,
project_path: Any,
data_dir: Any,
conversation_id: Optional[str],
) -> str:
"""构建注入 frozen system prompt 的留痕说明段落;开关关闭或信息缺失时返回空串。"""
if not is_modify_history_enabled(data_dir):
return ""
history_dir = get_modify_history_dir(project_path, conversation_id)
if history_dir is None:
return ""
try:
prompt_path = Path(__file__).resolve().parent.parent / "prompts" / _PROMPT_FILE_NAME
template = prompt_path.read_text(encoding="utf-8")
return template.format(modify_history_dir=str(history_dir)).strip()
except Exception as exc:
logger.warning(f"[modify_history] 加载留痕 prompt 失败: {exc}")
return ""
# ---------------------------------------------------------------------------
# diff 渲染
# ---------------------------------------------------------------------------
def _split_text_lines(text: Optional[str]) -> List[str]:
if not text:
return []
return str(text).splitlines()
def _render_file_diff_body(path: str, status: str, old_text: Optional[str], new_text: str) -> List[str]:
"""单文件的标准 diff 主体diff --git 头 + hunk保证 git apply 可用。"""
out: List[str] = [f"diff --git a/{path} b/{path}"]
if status == "added":
out.append("new file mode 100644")
out.append("--- /dev/null")
else:
out.append(f"--- a/{path}")
out.append(f"+++ b/{path}")
old_lines = _split_text_lines(old_text)
new_lines = _split_text_lines(new_text)
diff_iter = difflib.unified_diff(
old_lines,
new_lines,
fromfile=f"a/{path}",
tofile=f"b/{path}",
n=3,
lineterm="",
)
for line in list(diff_iter)[2:]: # 跳过 difflib 自产 ---/+++ 头,使用自拼头部
out.append(line)
return out
def render_modify_history_diff(
*,
conversation_id: str,
user_message_text: str,
started_at: Optional[str],
finished_at: Optional[str],
files: List[Dict[str, Any]],
) -> str:
"""渲染完整留痕文件内容。
files 元素
path, status, added, removed, old_text, new_text,
truncated baseline/current 超上限仅保留计数不产 diff 主体
missing_at_finalize 收尾时文件已不存在主体改为 /dev/nullnew_text可恢复
unrecoverable 文件已不存在且无内容记录仅标注不产 diff 主体
"""
total_added = sum(int(f.get("added") or 0) for f in files)
total_removed = sum(int(f.get("removed") or 0) for f in files)
task_summary = " ".join(str(user_message_text or "").split())
if len(task_summary) > 200:
task_summary = task_summary[:200] + ""
lines: List[str] = [
_COMMENT_BAR_HEAVY,
"# 修改记录Astrion Modify History",
_COMMENT_BAR_HEAVY,
f"# 对话: {conversation_id}",
f"# 任务: {task_summary}",
f"# 时间: {started_at or '?'}{finished_at or '?'}",
f"# 合计: {len(files)} 个文件,+{total_added} / {total_removed}",
"#",
"# 恢复方式:",
"# 重做本次修改(文件处于修改前状态时): git apply 本文件",
"# 撤销本次修改(文件处于修改后状态时): git apply -R 本文件",
"# 恢复已删除文件(小节标注「已不存在」时): git apply 本文件",
"# 所有 # 注释行不影响 git apply / patch无需剔除",
"",
]
for index, entry in enumerate(files, 1):
path = str(entry.get("path") or "")
status = str(entry.get("status") or "modified")
status_label = "新建" if status == "added" else "修改"
added = int(entry.get("added") or 0)
removed = int(entry.get("removed") or 0)
truncated = bool(entry.get("truncated"))
missing = bool(entry.get("missing_at_finalize"))
unrecoverable = bool(entry.get("unrecoverable"))
lines.extend(
[
_COMMENT_BAR_LIGHT,
f"# [{index}/{len(files)}] {path}",
f"# {status_label} · +{added} / {removed}",
]
)
if truncated:
lines.append("# ⚠ 文件内容超出保存上限400KB无法重建完整 diff仅保留计数")
if missing and not unrecoverable:
lines.append("# ⚠ 文件在任务结束时已不存在(可能被删除/移动以下按其最后记录内容完整保留git apply 可直接恢复")
if unrecoverable:
lines.append("# ⚠ 文件在任务结束时已不存在,且本轮无内容记录,无法重建")
lines.append(_COMMENT_BAR_LIGHT)
if truncated or unrecoverable:
lines.append("")
continue
if missing:
# 已消失文件:产出 new file diff/dev/null → 最后内容git apply 即恢复
lines.extend(
_render_file_diff_body(
path=path,
status="added",
old_text=None,
new_text=str(entry.get("new_text") or ""),
)
)
else:
lines.extend(
_render_file_diff_body(
path=path,
status=status,
old_text=entry.get("old_text"),
new_text=str(entry.get("new_text") or ""),
)
)
lines.append("")
return "\n".join(lines).rstrip("\n") + "\n"
# ---------------------------------------------------------------------------
# 任务信息辅助
# ---------------------------------------------------------------------------
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).replace(tzinfo=None)
except Exception:
return None
def _task_started_at(msg: Dict[str, Any]) -> Optional[str]:
metadata = msg.get("metadata") or {}
timer = metadata.get("work_timer")
if isinstance(timer, dict) and timer.get("started_at"):
return str(timer.get("started_at"))
return msg.get("timestamp") or None
def _message_text(msg: Dict[str, Any]) -> str:
content = msg.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
# 多模态消息:拼接其中的文本片段
parts = []
for item in content:
if isinstance(item, dict) and isinstance(item.get("text"), str):
parts.append(item["text"])
return " ".join(parts)
return str(content or "")
def _sanitize_input_for_filename(text: Any, max_chars: int = _FILENAME_INPUT_MAX_CHARS) -> str:
"""用户输入转文件名片段:压缩空白、非法字符替换为 _、截断空结果回退 task。"""
cleaned = " ".join(str(text or "").split())
cleaned = _FILENAME_ILLEGAL_RE.sub("_", cleaned)
cleaned = re.sub(r"_+", "_", cleaned).strip(" ._")
if len(cleaned) > max_chars:
cleaned = cleaned[:max_chars].rstrip(" ._")
return cleaned or "task"
def _ensure_history_file_name(
msg: Dict[str, Any],
summary: Dict[str, Any],
*,
history_dir: Optional[Path] = None,
) -> str:
"""本轮留痕文件名:<用户输入截断>_<任务开始时间>.diff首次生成后存入 summary 复用。
同对话内输入截断后同名且同窗时间戳冲突时追加 _2/_3 序号避免后轮覆盖前轮
"""
existing = summary.get(_HISTORY_FILE_KEY)
if isinstance(existing, str) and existing.strip():
return existing.strip()
input_part = _sanitize_input_for_filename(_message_text(msg))
dt = _parse_iso(_task_started_at(msg)) or datetime.now()
base = f"{input_part}_{dt.strftime('%Y-%m-%d_%H%M%S_%f')[:-3]}"
name = f"{base}.diff"
if history_dir is not None:
seq = 2
while (history_dir / name).exists():
name = f"{base}_{seq}.diff"
seq += 1
summary[_HISTORY_FILE_KEY] = name
return name
def _build_files_payload(
files: List[Any],
*,
project_root: Optional[Path] = None,
) -> List[Dict[str, Any]]:
"""把 edit_summary entries 转成渲染载荷;数据源为编辑时刻记录的 baseline/current。
project_root 提供时做存在性检查任务结束收尾已消失的文件按
missing_at_finalize有内容记录可恢复 unrecoverable无记录处理
"""
payload: List[Dict[str, Any]] = []
for entry in files:
if not isinstance(entry, dict):
continue
rel_path = str(entry.get("path") or "").strip()
if not rel_path:
continue
current = entry.get("current_text")
truncated = (
bool(entry.get("truncated"))
or bool(entry.get("baseline_truncated"))
or bool(entry.get("current_truncated"))
)
item: Dict[str, Any] = {
"path": rel_path,
"status": str(entry.get("status") or "modified"),
"added": entry.get("added") or 0,
"removed": entry.get("removed") or 0,
"old_text": entry.get("baseline"),
"new_text": current if isinstance(current, str) else "",
"truncated": truncated,
}
if project_root is not None:
try:
exists = (project_root / rel_path).is_file()
except Exception:
exists = True # 检查本身失败时按存在处理,不误标
if not exists:
if isinstance(current, str) and not truncated:
item["missing_at_finalize"] = True
else:
item["unrecoverable"] = True
payload.append(item)
return payload
def _write_history_file(history_dir: Path, file_name: str, content: str) -> str:
history_dir.mkdir(parents=True, exist_ok=True)
target = history_dir / file_name
target.write_text(content, encoding="utf-8")
return str(target)
# ---------------------------------------------------------------------------
# 实时写入(每次编辑后)与任务收尾(任务结束时)
# ---------------------------------------------------------------------------
def update_modify_history_for_task(
context_manager: Any,
msg: Dict[str, Any],
summary: Dict[str, Any],
) -> Optional[str]:
"""编辑摘要每次变化时重渲染本轮留痕文件(实时)。
modules/edit_summary._persist_and_broadcast 调用覆盖 update/remove/rename
三条路径数据源全部来自 summary entriesbaseline + current_text不读磁盘
返回留痕文件路径开关关闭/信息缺失/无有效文件时返回 None
"""
try:
if not isinstance(summary, dict):
return None
project_path = getattr(context_manager, "project_path", None)
data_dir = getattr(context_manager, "data_dir", None)
conversation_id = getattr(context_manager, "current_conversation_id", None)
history_dir = get_modify_history_dir(project_path, conversation_id)
if history_dir is None:
return None
files = summary.get("files")
if not isinstance(files, list) or not files:
# 本轮编辑已全部回滚(如 write 后又 delete_file移除已生成的留痕文件
existing_name = summary.get(_HISTORY_FILE_KEY)
if isinstance(existing_name, str) and existing_name.strip():
if not is_modify_history_enabled(data_dir):
return None
try:
(history_dir / existing_name.strip()).unlink(missing_ok=True)
except Exception:
pass
summary.pop(_HISTORY_FILE_KEY, None)
return None
if not is_modify_history_enabled(data_dir):
return None
file_name = _ensure_history_file_name(msg, summary, history_dir=history_dir)
payload = _build_files_payload(files)
if not payload:
return None
content = render_modify_history_diff(
conversation_id=str(conversation_id),
user_message_text=_message_text(msg),
started_at=_task_started_at(msg),
finished_at=datetime.now().isoformat(timespec="seconds"),
files=payload,
)
return _write_history_file(history_dir, file_name, content)
except Exception as exc:
logger.warning(f"[modify_history] 实时写入修改留痕失败: {exc}")
return None
def finalize_modify_history_for_task(
*,
project_path: Any,
data_dir: Any,
conversation_id: Optional[str],
msg: Dict[str, Any],
finished_at: Optional[str] = None,
) -> Optional[str]:
"""任务结束收尾:写入完成时间,并检查文件存在性。
任务结束时已不存在 rm/移动的文件其小节转为 /dev/null最后内容
new file diffgit apply 可直接恢复无内容记录的标注为无法重建
server/chat_flow_task_main.finalize_user_work_timer 调用直接 IO失败仅告警
"""
try:
metadata = msg.get("metadata") if isinstance(msg, dict) else None
summary = (metadata or {}).get("edit_summary")
if not isinstance(summary, dict):
return None
files = summary.get("files")
if not isinstance(files, list) or not files:
return None
if not is_modify_history_enabled(data_dir):
return None
history_dir = get_modify_history_dir(project_path, conversation_id)
if history_dir is None:
return None
project_root = Path(project_path).expanduser().resolve()
file_name = summary.get(_HISTORY_FILE_KEY)
if not isinstance(file_name, str) or not file_name.strip():
# 实时路径未写过(异常/旧对话):按收尾补一次完整渲染
file_name = _ensure_history_file_name(msg, summary, history_dir=history_dir)
payload = _build_files_payload(files, project_root=project_root)
if not payload:
return None
content = render_modify_history_diff(
conversation_id=str(conversation_id),
user_message_text=_message_text(msg),
started_at=_task_started_at(msg),
finished_at=finished_at or datetime.now().isoformat(timespec="seconds"),
files=payload,
)
return _write_history_file(history_dir, file_name.strip(), content)
except Exception as exc:
logger.warning(f"[modify_history] 收尾写入修改留痕失败: {exc}")
return None

View File

@ -109,6 +109,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"quick_dock_auto_expand": True, # 快捷窗口自动展开True-有内容时自动展开 / False-只能手动点击按钮展开
"file_preview_auto_wrap": False, # 文件预览窗口自动换行True-按面板宽度换行显示 / False-长行横向滚动
"edit_summary_live_display": False, # 编辑摘要卡片显示时机False-一次工作完成后才显示(默认) / True-工作运行期间实时显示
"modify_history_enabled": True, # 修改留痕True-任务完成时把本轮净修改落盘到工作区 .astrion/modify_history/(默认开启) / False-不落盘也不注入 prompt
"group_sidebar_by_workspace": False, # 侧边栏按工作区/项目分组显示对话
"sidebar_pinned_workspaces": [], # 分组侧边栏中永久置顶的工作区ID列表
"sidebar_workspace_order": [], # 分组侧边栏中非置顶工作区的显示顺序
@ -557,6 +558,12 @@ def sanitize_personalization_payload(
else:
base["edit_summary_live_display"] = bool(base.get("edit_summary_live_display", False))
# 修改留痕(默认开启 = 任务完成时落盘 diff 并注入 prompt
if "modify_history_enabled" in data:
base["modify_history_enabled"] = bool(data.get("modify_history_enabled"))
else:
base["modify_history_enabled"] = bool(base.get("modify_history_enabled", True))
# 侧边栏按工作区/项目分组显示对话
if "group_sidebar_by_workspace" in data:
base["group_sidebar_by_workspace"] = bool(data.get("group_sidebar_by_workspace"))

View File

@ -0,0 +1,10 @@
## 修改留痕Modify History
本工作区开启了修改留痕:每次你使用 write_file / edit_file 编辑文件后系统会把本次任务的修改实时记录unified diff含任务说明注释到以下目录
{modify_history_dir}
该目录按对话隔离,上述路径即本对话专属目录;每次任务(一条用户输入)对应一个 .diff 文件,文件名为「该任务的用户输入(截断)+ 任务开始时间」记录该任务全部原生编辑write_file / edit_file的净 +/- 修改,随每次编辑实时更新。留痕文件可直接用 `git apply`(重做)或 `git apply -R`(撤销)应用,用于版本控制缺失或被误操作(如 git checkout 覆盖)时的人工恢复;标注「已不存在」的小节为任务结束时已被删除/移动的文件,对其执行 `git apply` 可直接恢复。
- 该目录由系统自动维护:不要创建、修改、移动或删除其中任何文件
- 它不影响你的正常工作流程,无需主动向用户提及,除非用户询问修改恢复相关事宜

View File

@ -1638,6 +1638,19 @@ async def handle_task_with_sender(
target_msg["metadata"] = metadata
history[target_index] = target_msg
web_terminal.context_manager.auto_save_conversation(force=True)
# 修改留痕收尾:写入完成时间;被 rm/移动的文件转为可恢复 diff实时内容已在编辑时落盘
try:
from modules.modify_history import finalize_modify_history_for_task
finalize_modify_history_for_task(
project_path=getattr(web_terminal, "project_path", None),
data_dir=getattr(web_terminal, "data_dir", None),
conversation_id=conversation_id,
msg=target_msg,
finished_at=timer.get("finished_at"),
)
except Exception as exc:
import logging
logging.getLogger(__name__).warning(f"[modify_history] 收尾钩子执行失败: {exc}")
finalized_work_message_indices.add(target_index)
def switch_current_work_timer_to_latest_user():

View File

@ -196,10 +196,13 @@ export const actionMethods = {
[newConversationId]: 'create'
};
this.conversationListAnimationMode = 'create';
this.conversations = [
/* 原地 splice 保持数组引用conversations 与双类型缓存中当前类型列表同一引用 */
this.conversations.splice(
0,
this.conversations.length,
placeholder,
...this.conversations.filter((conv) => conv && conv.id !== newConversationId)
];
);
// 分组视图下同步在当前工作区分组顶部插入占位
try {
@ -212,10 +215,12 @@ export const actionMethods = {
(g: any) => g.workspaceId === currentWorkspaceId
);
if (group) {
group.conversations = [
group.conversations.splice(
0,
group.conversations.length,
placeholder,
...group.conversations.filter((conv: any) => conv.id !== newConversationId)
];
);
group.expanded = true;
group.visibleOffset = 0;
}
@ -336,10 +341,12 @@ export const actionMethods = {
conversationStore.ensureWorkspaceGroup(workspaceId);
const group = conversationStore.workspaceGroups.find((g: any) => g.workspaceId === workspaceId);
if (group) {
group.conversations = [
group.conversations.splice(
0,
group.conversations.length,
placeholder,
...group.conversations.filter((conv: any) => conv.id !== newConversationId)
];
);
group.expanded = true;
group.visibleOffset = 0;
}
@ -432,7 +439,11 @@ export const actionMethods = {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
conversationStore.workspaceGroups.forEach((group: any) => {
group.conversations = group.conversations.filter((conv: any) => conv.id !== conversationId);
group.conversations.splice(
0,
group.conversations.length,
...group.conversations.filter((conv: any) => conv.id !== conversationId)
);
const maxVisibleOffset = Math.max(0, group.conversations.length - group.visibleLimit);
if (group.visibleOffset > maxVisibleOffset) {
group.visibleOffset = maxVisibleOffset;
@ -443,8 +454,10 @@ export const actionMethods = {
}
await waitForAnimation(430);
this.conversations = this.conversations.filter(
(conversation) => conversation.id !== conversationId
this.conversations.splice(
0,
this.conversations.length,
...this.conversations.filter((conversation) => conversation.id !== conversationId)
);
this.searchResults = this.searchResults.filter(
(conversation) => conversation.id !== conversationId
@ -532,13 +545,15 @@ export const actionMethods = {
(conv) => conv && conv.id === conversationId
);
if (insertIndex >= 0) {
this.conversations = [
this.conversations.splice(
0,
this.conversations.length,
...withoutDuplicate.slice(0, insertIndex + 1),
duplicatePlaceholder,
...withoutDuplicate.slice(insertIndex + 1)
];
);
} else {
this.conversations = [duplicatePlaceholder, ...withoutDuplicate];
this.conversations.splice(0, this.conversations.length, duplicatePlaceholder, ...withoutDuplicate);
}
window.setTimeout(() => {
@ -560,13 +575,15 @@ export const actionMethods = {
const sourceGroupIndex = group.conversations.findIndex((conv: any) => conv.id === conversationId);
const withoutDuplicate = group.conversations.filter((conv: any) => conv.id !== newId);
if (sourceGroupIndex >= 0) {
group.conversations = [
group.conversations.splice(
0,
group.conversations.length,
...withoutDuplicate.slice(0, sourceGroupIndex + 1),
duplicatePlaceholder,
...withoutDuplicate.slice(sourceGroupIndex + 1)
];
);
} else {
group.conversations = [duplicatePlaceholder, ...withoutDuplicate];
group.conversations.splice(0, group.conversations.length, duplicatePlaceholder, ...withoutDuplicate);
}
group.expanded = true;
}

View File

@ -12,37 +12,49 @@ export const loadMethods = {
* ConversationSidebar conversation-type-change
* store sidebarConversationType
*/
/**
* /
* store setSidebarConversationType
*
*
*/
async handleSidebarConversationTypeChange(_type?: 'normal' | 'multi_agent') {
this.conversationsOffset = 0;
this.hasMoreConversations = false;
await this.loadConversationsList();
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
const listType = conversationStore.sidebarConversationType;
if (!conversationStore.conversationsCache[listType]?.loaded) {
this.conversationsOffset = 0;
this.hasMoreConversations = false;
await this.loadConversationsList();
}
try {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
const groups = Array.isArray(conversationStore.workspaceGroups)
? conversationStore.workspaceGroups
: [];
for (const group of groups) {
if (group && group.workspaceId) {
await this.loadWorkspaceConversations(group.workspaceId, { reset: true });
if (group && group.workspaceId && !group.pagingByType?.[listType]?.loaded) {
await conversationStore.loadWorkspaceConversations(group.workspaceId);
}
}
} catch (error) {
console.error('刷新工作区分组对话失败:', error);
console.error('补载工作区分组对话失败:', error);
}
},
async loadConversationsList() {
const queryOffset = this.conversationsOffset;
const queryLimit = this.conversationsLimit;
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
/* 锁定发起时类型:响应期间用户可能已切换过滤器,数据始终写入对应类型缓存 */
const listType = conversationStore.sidebarConversationType;
const refreshToken =
queryOffset === 0 ? ++this.conversationListRefreshToken : this.conversationListRefreshToken;
const requestSeq = ++this.conversationListRequestSeq;
this.conversationsLoading = true;
try {
// 列表过滤由侧边栏类型过滤器(普通/多智能体)决定
const { useConversationStore } = await import('../../../stores/conversation');
const maParam = useConversationStore().sidebarConversationType === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0';
// 列表过滤由请求发起时的侧边栏类型过滤器(普通/多智能体)决定
const maParam = listType === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0';
const response = await fetch(`/api/conversations?limit=${queryLimit}&offset=${queryOffset}${maParam}`);
const data = await response.json();
@ -57,32 +69,48 @@ export const loadMethods = {
return;
}
/* 原地写入该类型缓存(当前显示类型的缓存与 conversations 同一引用,原地修改即同步显示) */
const cache = conversationStore.conversationsCache[listType];
const items = data.data.conversations;
if (queryOffset === 0) {
this.conversations = data.data.conversations;
cache.list.splice(0, cache.list.length, ...items);
} else {
this.conversations.push(...data.data.conversations);
cache.list.push(...items);
}
if (this.currentConversationId) {
this.promoteConversationToTop(this.currentConversationId);
}
this.hasMoreConversations = data.data.has_more;
debugLog(`已加载 ${this.conversations.length} 个对话`);
cache.offset = queryOffset;
cache.hasMore = data.data.has_more;
cache.loaded = true;
if (
this.conversationsOffset === 0 &&
!this.currentConversationId &&
this.conversations.length > 0 &&
!this.isExplicitNewConversationRoute()
) {
// 只有在初始化完成后,才自动加载第一个对话
// 避免与 bootstrapRoute 冲突
if (this.initialRouteResolved) {
const latestConversation = this.conversations[0];
if (latestConversation && latestConversation.id) {
await this.loadConversation(latestConversation.id);
/* 仅仍是当前显示类型时同步扁平字段与首对话自动加载 */
if (listType === conversationStore.sidebarConversationType) {
this.conversations = cache.list;
this.hasMoreConversations = cache.hasMore;
if (this.currentConversationId) {
this.promoteConversationToTop(this.currentConversationId);
}
if (
queryOffset === 0 &&
!this.currentConversationId &&
cache.list.length > 0 &&
!this.isExplicitNewConversationRoute()
) {
// 只有在初始化完成后,才自动加载第一个对话
// 避免与 bootstrapRoute 冲突
if (this.initialRouteResolved) {
const latestConversation = cache.list[0];
if (latestConversation && latestConversation.id) {
await this.loadConversation(latestConversation.id);
}
}
}
}
debugLog(`已加载 ${cache.list.length} 个对话`);
/* 首页加载成功后后台补载另一类型,保证切换过滤器时零等待 */
const otherType = listType === 'multi_agent' ? 'normal' : 'multi_agent';
if (queryOffset === 0 && !conversationStore.conversationsCache[otherType].loaded) {
this.loadConversationTypeCache(otherType).catch(() => {});
}
} else {
console.error('加载对话列表失败:', data.error);
}
@ -94,6 +122,32 @@ export const loadMethods = {
}
}
},
/** 后台补载指定类型的首页列表缓存已加载则跳过refreshToken 快照防 reset/刷新后旧响应污染 */
async loadConversationTypeCache(type: 'normal' | 'multi_agent') {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
const cache = conversationStore.conversationsCache[type];
if (!cache || cache.loaded) return;
const tokenAtStart = this.conversationListRefreshToken;
const maParam = type === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0';
try {
const response = await fetch(
`/api/conversations?limit=${this.conversationsLimit}&offset=0${maParam}`
);
const data = await response.json();
if (!data?.success) return;
/* reset/刷新后旧响应丢弃;并发补载去重 */
if (tokenAtStart !== this.conversationListRefreshToken || cache.loaded) return;
const items = data.data?.conversations || [];
cache.list.push(...items);
cache.offset = 0;
cache.hasMore = !!data.data?.has_more;
cache.loaded = true;
} catch (error) {
console.error('补载对话列表缓存异常:', error);
}
},
async loadMoreConversations() {
if (this.loadingMoreConversations || !this.hasMoreConversations) return;
@ -254,70 +308,9 @@ export const loadMethods = {
type: 'error'
});
}
},
async loadWorkspaceConversations(workspaceId: string, { reset = false } = {}) {
if (!workspaceId) return;
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
let group = conversationStore.workspaceGroups.find((g: any) => g.workspaceId === workspaceId);
if (!group) {
group = {
workspaceId,
conversations: [],
loading: true,
hasMore: false,
loadingMore: false,
offset: 0,
limit: 20,
expanded: true
};
conversationStore.workspaceGroups.push(group);
}
if (reset) {
group.conversations = [];
group.offset = 0;
group.hasMore = false;
}
if (group.loading || group.loadingMore) return;
group.loading = true;
try {
const { useConversationStore: useConvStore } = await import('../../../stores/conversation');
const maParam = useConvStore().sidebarConversationType === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0';
const response = await fetch(`/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.limit}&offset=${group.offset}${maParam}`);
const data = await response.json();
if (data.success) {
const items = (data.data?.conversations || []).map((conv: any) => ({
id: conv.id,
title: conv.title,
updated_at: conv.updated_at,
total_messages: conv.total_messages,
total_tools: conv.total_tools
}));
if (group.offset === 0) {
group.conversations = items;
} else {
group.conversations.push(...items);
}
group.hasMore = !!data.data?.has_more;
} else {
console.error('加载工作区对话失败:', data.error);
}
} catch (error) {
console.error('加载工作区对话异常:', error);
} finally {
group.loading = false;
}
},
async loadMoreWorkspaceConversations(workspaceId: string) {
const { useConversationStore } = await import('../../../stores/conversation');
const conversationStore = useConversationStore();
const group = conversationStore.workspaceGroups.find((g: any) => g.workspaceId === workspaceId);
if (!group || group.loadingMore || !group.hasMore) return;
group.loadingMore = true;
group.offset += group.limit;
await this.loadWorkspaceConversations(workspaceId);
group.loadingMore = false;
}
};
/* store stores/conversation.ts loadWorkspaceConversations/
loadMoreWorkspaceConversations/loadWorkspaceConversationTypeCache
*/

View File

@ -302,7 +302,12 @@ export const sendMethods = {
total_messages: 0,
total_tools: 0
};
this.conversations = [newPlaceholder, ...this.conversations.filter((conv) => conv && conv.id !== targetConversationId)];
this.conversations.splice(
0,
this.conversations.length,
newPlaceholder,
...this.conversations.filter((conv) => conv && conv.id !== targetConversationId)
);
// 分组视图下同步到当前工作区
try {
@ -315,7 +320,12 @@ export const sendMethods = {
(g: any) => g.workspaceId === currentWorkspaceId
);
if (group) {
group.conversations = [newPlaceholder, ...group.conversations.filter((conv: any) => conv.id !== targetConversationId)];
group.conversations.splice(
0,
group.conversations.length,
newPlaceholder,
...group.conversations.filter((conv: any) => conv.id !== targetConversationId)
);
group.expanded = true;
group.visibleOffset = 0;
group.visibleLimit = 5;

View File

@ -38,19 +38,15 @@ export const titleMethods = {
}
if (Array.isArray(this.conversations)) {
let listChanged = false;
const nextConversations = this.conversations.map((conv: any) => {
if (!conv || conv.id !== normalizedConversationId || conv.title === normalizedTitle) {
return conv;
}
listChanged = true;
return {
...conv,
/* 原地替换保持数组引用conversations 与双类型缓存中当前类型列表同一引用 */
const convIndex = this.conversations.findIndex(
(conv: any) => conv && conv.id === normalizedConversationId && conv.title !== normalizedTitle
);
if (convIndex >= 0) {
this.conversations.splice(convIndex, 1, {
...this.conversations[convIndex],
title: normalizedTitle
};
});
if (listChanged) {
this.conversations = nextConversations;
});
changed = true;
}
}

View File

@ -78,7 +78,9 @@ export const hostWorkspaceMethods = {
const previousSearchActive = this.searchActive;
this.searchActive = false;
this.searchResults = [];
this.conversations = [];
/* 主列表是当前工作区作用域切换前使双类型缓存整体失效conversations 重指空缓存) */
const { useConversationStore } = await import('../../../stores/conversation');
useConversationStore().resetConversationsTypeCache();
this.conversationsOffset = 0;
this.hasMoreConversations = false;
this.conversationsLoading = true;
@ -153,7 +155,12 @@ export const hostWorkspaceMethods = {
this.refreshProjectGitSummary?.();
this.fetchTerminalCount();
} catch (error) {
this.conversations = previousConversationList;
/* 切换失败仍在旧工作区:恢复旧列表并同步回当前类型缓存,保持引用一致 */
const conversationStore = useConversationStore();
const restoredCache = conversationStore.conversationsCache[conversationStore.sidebarConversationType];
restoredCache.list = previousConversationList;
restoredCache.loaded = true;
this.conversations = restoredCache.list;
this.searchActive = previousSearchActive;
this.conversationsLoading = false;
const message = error instanceof Error ? error.message : String(error || '切换失败');
@ -322,7 +329,9 @@ export const hostWorkspaceMethods = {
this.currentConversationTitle = '新对话';
this.searchActive = false;
this.searchResults = [];
this.conversations = [];
/* 当前工作区已删除:双类型缓存整体失效后重新加载 */
const { useConversationStore } = await import('../../../stores/conversation');
useConversationStore().resetConversationsTypeCache();
this.conversationsOffset = 0;
this.hasMoreConversations = false;
this.conversationsLoading = true;

View File

@ -221,10 +221,12 @@ export const versioningMethods = {
await this.loadConversationsList();
// copy 模式下给侧边栏一个即时占位,随后列表刷新会补齐真实数据
if (restoreMode === 'copy' && !this.conversations.some((c) => c && c.id === targetConversationId)) {
this.conversations = [
this.conversations.splice(
0,
this.conversations.length,
{ id: targetConversationId, title: '版本回溯对话', updated_at: new Date().toISOString(), total_messages: 0, total_tools: 0 },
...this.conversations.filter((c) => c && c.id !== targetConversationId)
];
);
}
this.uiPushToast({
title: '版本管理',

View File

@ -320,10 +320,6 @@
:aria-expanded="agentTypeMenuOpen"
aria-haspopup="true"
>
<svg class="agent-type-switcher__icon" viewBox="0 0 16 16" width="14" height="14" fill="none" aria-hidden="true">
<circle cx="8" cy="5" r="2.4" stroke="currentColor" stroke-width="1.3" />
<path d="M3.2 13.2c.6-2.3 2.5-3.6 4.8-3.6s4.2 1.3 4.8 3.6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
</svg>
<span>{{ agentTypeLabel }}</span>
<span class="agent-type-switcher__caret" :class="{ open: agentTypeMenuOpen }" aria-hidden="true"></span>
</button>

View File

@ -892,6 +892,23 @@
})
" /><FancyCheck :checked="form.agents_md_auto_inject" /></label>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">文件修改留痕</span
><span class="settings-row-desc"
>每次任务完成后把本轮 write/edit 的文件修改以 diff 形式保存到工作区
.astrion/modify_history/误操作如被 git checkout 覆盖后可据此人工恢复</span
></span
><input
type="checkbox"
:checked="form.modify_history_enabled"
@change="
personalization.updateField({
key: 'modify_history_enabled',
value: $event.target.checked
})
" /><FancyCheck :checked="form.modify_history_enabled" /></label>
<div class="settings-section-divider">
<span class="settings-section-divider__label">版本控制</span>
</div>

View File

@ -768,6 +768,9 @@ const slideTransitionName = computed(() =>
/** 列表容器:切换类型时重置滚动位置,保证推挤动画从顶部开始 */
const conversationListEl = ref<HTMLElement | null>(null);
/* store setSidebarConversationType
同步交换 conversations 引用与分页状态无请求无加载态pane key 与数据同 tick 变化
新面板初始挂载即新数据transition-group 初始渲染不播动画只保留整板左右平移 */
const setSidebarType = (type: 'normal' | 'multi_agent') => {
if (conversationStore.sidebarConversationType === type) return;
if (conversationListEl.value) conversationListEl.value.scrollTop = 0;

View File

@ -30,6 +30,7 @@ export interface ConversationSummary {
export interface WorkspaceConversationGroup {
workspaceId: string;
/** 当前过滤器类型对应的列表:与 conversationsByType[sidebarConversationType] 同一数组引用 */
conversations: ConversationSummary[];
loading: boolean;
hasMore: boolean;
@ -40,8 +41,27 @@ export interface WorkspaceConversationGroup {
bufferLimit: number;
fetchLimit: number;
expanded: boolean;
/** 双类型常驻缓存:切换过滤器时 conversations 只换引用,不重请求 */
conversationsByType: { normal: ConversationSummary[]; multi_agent: ConversationSummary[] };
/** 各类型分页状态offset/hasMore与是否已加载过切换时与扁平字段交换 */
pagingByType: {
normal: { offset: number; hasMore: boolean; loaded: boolean };
multi_agent: { offset: number; hasMore: boolean; loaded: boolean };
};
}
/** 新建空分组时的双类型缓存初始化 */
export const createEmptyGroupTypeCache = () => ({
conversationsByType: { normal: [], multi_agent: [] } as {
normal: ConversationSummary[];
multi_agent: ConversationSummary[];
},
pagingByType: {
normal: { offset: 0, hasMore: false, loaded: false },
multi_agent: { offset: 0, hasMore: false, loaded: false }
}
});
/** 跨工作区搜索结果分组(后端 /api/conversations/search?all_workspaces=1 返回) */
export interface WorkspaceSearchGroup {
workspace_id: string;
@ -78,6 +98,14 @@ interface ConversationState {
multiAgentMode: boolean;
/** 侧边栏对话类型过滤器:'normal' 普通对话 | 'multi_agent' 多智能体对话localStorage 持久化) */
sidebarConversationType: 'normal' | 'multi_agent';
/** conversations conversationsCache[sidebarConversationType].list
push/splice/unshift conversations
loaded */
conversationsCache: {
normal: { list: ConversationSummary[]; offset: number; hasMore: boolean; loaded: boolean };
multi_agent: { list: ConversationSummary[]; offset: number; hasMore: boolean; loaded: boolean };
};
}
export const useConversationStore = defineStore('conversation', {
@ -106,22 +134,50 @@ export const useConversationStore = defineStore('conversation', {
acknowledgedCompletedTaskIds: [],
workspaceGroups: [],
multiAgentMode: false,
sidebarConversationType: loadSidebarConversationType()
sidebarConversationType: loadSidebarConversationType(),
conversationsCache: {
normal: { list: [], offset: 0, hasMore: false, loaded: false },
multi_agent: { list: [], offset: 0, hasMore: false, loaded: false }
}
}),
actions: {
/** 当前对话 id 同步(由 app watcher 在 this.currentConversationId 变化时写入) */
setCurrentConversationId(id: string | null) {
this.currentConversationId = id;
},
/** 侧边栏对话类型过滤器切换(左右切换控件写入,驱动列表请求的服务端过滤) */
/**
*
* conversations
* pane
*/
setSidebarConversationType(type: 'normal' | 'multi_agent') {
const normalized = type === 'multi_agent' ? 'multi_agent' : 'normal';
if (this.sidebarConversationType === normalized) return;
const oldType = this.sidebarConversationType;
const curCache = this.conversationsCache[oldType];
curCache.offset = this.conversationsOffset;
curCache.hasMore = this.hasMoreConversations;
this.sidebarConversationType = normalized;
persistSidebarConversationType(normalized);
const nextCache = this.conversationsCache[normalized];
this.conversations = nextCache.list;
this.conversationsOffset = nextCache.offset;
this.hasMoreConversations = nextCache.hasMore;
this.loadingMoreConversations = false;
for (const group of this.workspaceGroups) {
if (!group || !group.conversationsByType) continue;
const curPaging = group.pagingByType[oldType];
curPaging.offset = group.offset;
curPaging.hasMore = group.hasMore;
group.conversations = group.conversationsByType[normalized];
group.offset = group.pagingByType[normalized].offset;
group.hasMore = group.pagingByType[normalized].hasMore;
group.loadingMore = false;
}
},
resetConversations() {
this.conversations = [];
/* 重置双类型缓存并对齐 conversations 引用(核心不变量) */
this.resetConversationsCacheOnly();
this.searchResults = [];
this.searchGroups = [];
this.conversationInsertAnimations = {};
@ -138,6 +194,19 @@ export const useConversationStore = defineStore('conversation', {
this.runningWorkspaceTasks = [];
this.acknowledgedCompletedTaskIds = [];
},
/** /使
conversations */
resetConversationsTypeCache() {
this.resetConversationsCacheOnly();
},
/** 内部共用:仅重置双类型缓存并对齐 conversations 引用 */
resetConversationsCacheOnly() {
this.conversationsCache = {
normal: { list: [], offset: 0, hasMore: false, loaded: false },
multi_agent: { list: [], offset: 0, hasMore: false, loaded: false }
};
this.conversations = this.conversationsCache[this.sidebarConversationType].list;
},
cancelSearchTimer() {
if (this.searchTimer) {
clearTimeout(this.searchTimer);
@ -156,7 +225,8 @@ export const useConversationStore = defineStore('conversation', {
setWorkspaceGroupConversations(workspaceId: string, conversations: ConversationSummary[]) {
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
if (group) {
group.conversations = conversations;
/* 原地替换保持数组引用group.conversations 与 conversationsByType[当前类型] 是同一引用 */
group.conversations.splice(0, group.conversations.length, ...conversations);
}
},
appendWorkspaceGroupConversations(workspaceId: string, conversations: ConversationSummary[]) {
@ -199,9 +269,11 @@ export const useConversationStore = defineStore('conversation', {
if (!workspaceId) return;
const exists = this.workspaceGroups.some((g) => g.workspaceId === workspaceId);
if (!exists) {
const typeCache = createEmptyGroupTypeCache();
this.workspaceGroups.push({
workspaceId,
conversations: [],
conversations: typeCache.conversationsByType[this.sidebarConversationType],
...typeCache,
loading: false,
hasMore: false,
loadingMore: false,
@ -225,18 +297,27 @@ export const useConversationStore = defineStore('conversation', {
index = this.workspaceGroups.length - 1;
}
const group = this.workspaceGroups[index];
/* 锁定发起时类型:响应期间用户可能已切换过滤器,数据始终写入对应类型缓存 */
const listType = this.sidebarConversationType;
const listCache = group.conversationsByType[listType];
const paging = group.pagingByType[listType];
if (reset) {
group.conversations = [];
group.offset = 0;
listCache.length = 0;
paging.offset = 0;
paging.hasMore = false;
paging.loaded = false;
group.visibleOffset = 0;
group.hasMore = false;
if (listType === this.sidebarConversationType) {
group.offset = 0;
group.hasMore = false;
}
}
if (group.loading || group.loadingMore) return;
const fetchOffset = refresh ? 0 : group.offset;
const fetchOffset = refresh ? 0 : paging.offset;
group.loading = true;
try {
// 列表过滤由侧边栏类型过滤器(普通/多智能体)决定
const maParam = this.sidebarConversationType === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0';
// 列表过滤由请求发起时的侧边栏类型过滤器(普通/多智能体)决定
const maParam = listType === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0';
const response = await fetch(
`/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.fetchLimit}&offset=${fetchOffset}${maParam}`
);
@ -249,17 +330,29 @@ export const useConversationStore = defineStore('conversation', {
total_messages: conv.total_messages,
total_tools: conv.total_tools
}));
/* 原地写入缓存数组,保持 group.conversations 引用一致 */
if (refresh) {
const tail = group.conversations.slice(items.length);
group.conversations = [...items, ...tail];
const tail = listCache.slice(items.length);
listCache.splice(0, listCache.length, ...items, ...tail);
} else if (fetchOffset === 0) {
group.conversations = items;
listCache.splice(0, listCache.length, ...items);
} else {
group.conversations.push(...items);
listCache.push(...items);
}
group.hasMore = !!data.data?.has_more;
paging.hasMore = !!data.data?.has_more;
paging.loaded = true;
if (!refresh) {
group.offset = fetchOffset + items.length;
paging.offset = fetchOffset + items.length;
}
/* 仍是当前显示类型时同步扁平字段group.conversations 引用已是该缓存) */
if (listType === this.sidebarConversationType) {
group.hasMore = paging.hasMore;
group.offset = paging.offset;
}
/* 首页加载成功后后台补载另一类型,保证切换过滤器时零等待 */
const otherType = listType === 'multi_agent' ? 'normal' : 'multi_agent';
if (fetchOffset === 0 && !group.pagingByType[otherType].loaded) {
this.loadWorkspaceConversationTypeCache(workspaceId, otherType).catch(() => {});
}
} else {
console.error('加载工作区对话失败:', data.error);
@ -270,6 +363,36 @@ export const useConversationStore = defineStore('conversation', {
group.loading = false;
}
},
/** 后台补载某工作区指定类型的首页缓存:已加载则跳过,不干扰当前显示 */
async loadWorkspaceConversationTypeCache(
workspaceId: string,
type: 'normal' | 'multi_agent'
) {
const group = this.workspaceGroups.find((g) => g.workspaceId === workspaceId);
if (!group || group.pagingByType[type].loaded) return;
const maParam = type === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0';
try {
const response = await fetch(
`/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.fetchLimit}&offset=0${maParam}`
);
const data = await response.json();
/* 响应时重新校验:可能已被 reset/补载并发填充 */
if (!data?.success || group.pagingByType[type].loaded) return;
const items = (data.data?.conversations || []).map((conv: any) => ({
id: conv.id,
title: conv.title,
updated_at: conv.updated_at,
total_messages: conv.total_messages,
total_tools: conv.total_tools
}));
group.conversationsByType[type].push(...items);
group.pagingByType[type].offset = items.length;
group.pagingByType[type].hasMore = !!data.data?.has_more;
group.pagingByType[type].loaded = true;
} catch (error) {
console.error('补载工作区对话缓存异常:', error);
}
},
async loadMoreWorkspaceConversations(workspaceId: string) {
const index = this.workspaceGroups.findIndex((g) => g.workspaceId === workspaceId);
if (index === -1) return;
@ -281,9 +404,10 @@ export const useConversationStore = defineStore('conversation', {
group.loadingMore = true;
// 先尝试从后端再加载 20 条作为新的缓冲
if (group.hasMore) {
const listType = this.sidebarConversationType;
try {
const fetchOffset = group.conversations.length;
const maParam = this.sidebarConversationType === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0';
const maParam = listType === 'multi_agent' ? '&multi_agent_mode=1' : '&multi_agent_mode=0';
const response = await fetch(
`/api/conversations?workspace_id=${encodeURIComponent(workspaceId)}&limit=${group.bufferLimit}&offset=${fetchOffset}${maParam}`
);
@ -298,6 +422,12 @@ export const useConversationStore = defineStore('conversation', {
}));
group.conversations.push(...items);
group.hasMore = !!data.data?.has_more;
/* 同步该类型分页缓存group.conversations 与该缓存同一引用,原地 push 已同步) */
const paging = group.pagingByType?.[listType];
if (paging) {
paging.offset = group.conversations.length;
paging.hasMore = group.hasMore;
}
}
} catch (error) {
console.error('加载更多工作区对话异常:', error);

View File

@ -34,6 +34,7 @@ interface PersonalForm {
quick_dock_auto_expand: boolean;
file_preview_auto_wrap: boolean;
edit_summary_live_display: boolean;
modify_history_enabled: boolean;
stacked_hide_borders: boolean;
minimal_expand_height_limited: boolean;
enhanced_tool_display_categories: string[];
@ -231,6 +232,7 @@ const defaultForm = (): PersonalForm => ({
quick_dock_auto_expand: loadCachedQuickDockAutoExpand(),
file_preview_auto_wrap: false,
edit_summary_live_display: false,
modify_history_enabled: true,
stacked_hide_borders: loadCachedStackedHideBorders(),
minimal_expand_height_limited: loadCachedMinimalExpandHeightLimited(),
enhanced_tool_display_categories: [],
@ -441,6 +443,7 @@ export const usePersonalizationStore = defineStore('personalization', {
quick_dock_auto_expand: data.quick_dock_auto_expand !== false,
file_preview_auto_wrap: !!data.file_preview_auto_wrap,
edit_summary_live_display: !!data.edit_summary_live_display,
modify_history_enabled: data.modify_history_enabled !== false,
stacked_hide_borders: !!data.stacked_hide_borders,
minimal_expand_height_limited: data.minimal_expand_height_limited !== false,
enhanced_tool_display_categories: Array.isArray(data.enhanced_tool_display_categories)

View File

@ -1539,11 +1539,6 @@ body[data-theme='light'] {
cursor: default;
}
.agent-type-switcher__icon {
color: var(--text-secondary);
flex-shrink: 0;
}
.agent-type-switcher__caret {
font-size: 13px;
color: var(--text-secondary);

View File

@ -1164,6 +1164,14 @@ body[data-theme='dark'] .conversation-sidebar .load-more-btn:hover:not(:disabled
font-weight: 600;
}
/* 深色模式surface-soft 是近黑 #0f0f0f比轨道#141414更暗
选中态变成更黑的坑层级方向反了且违反深色禁近黑规则
改为白色微量 tint 提亮一档#3a3a3a与深色可见灰阶先例一致 */
:root[data-theme='dark'] .conversation-type-option.active,
body[data-theme='dark'] .conversation-type-option.active {
background: color-mix(in srgb, var(--text-primary) 16%, transparent);
}
/* 列表区域整体左右滑动切换动画推挤式新面板从侧边滑入把旧面板顶出去
out-in 空白期两面板同速同幅滑动边缘始终相接 */
.conversation-list-pane {