Compare commits

...

3 Commits

Author SHA1 Message Date
a3fa5e59c6 feat(sandbox): Docker 只读执行叠加 Landlock 进程级只读域,封死 world-writable 绕 DAC 漏洞
Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
2026-09-01 15:17:23 +08:00
8da53635c4 refactor(personalization): 重新归类个人空间选项所属页签
- 数据管理页签删除,用量统计并入常规页
- 审核主动取证/续命轮数/token上限移至审核智能体页
- 使用自定义称呼移至个性化页,标题生成模型移至模型页
- 沙箱环境移至工作区与权限页
- 删除个性化页重复渲染的边线/高度开关
- 拆分撞车文案:默认思考模式(模型页)/默认工作模式(工作区页)
- 教程用量步骤锚点改指常规页
2026-09-01 11:53:29 +08:00
deaa1560a5 feat(personalization): 个人空间支持配置对话标题生成模型
新增 title_model 配置,复用子智能体模型库条目生成对话标题:
- 个人空间「常规」页签新增「标题生成模型」下拉菜单(复用子智能体
  模型列表,general 页签挂载时加载)
- 标题生成改为使用所选子智能体模型 profile;未配置时回落模型库
  default_model,移除 AGENT_TITLE_* 环境变量覆盖与主智能体默认
  模型回退(个人空间为唯一配置来源)
- personalization 白名单注册 title_model 键(默认值 + sanitize)
2026-09-01 09:46:05 +08:00
24 changed files with 832 additions and 457 deletions

View File

@ -412,10 +412,14 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
只读权限的强制由各平台原生机制兜底;`config/limits.py` 的命令文本白名单(`_is_readonly_run_command_allowed`)只是**审批决策的启发式**,不再是安全边界(已知可绕过,如 `find . -delete`;绕过后果只是多走一次审批)。 只读权限的强制由各平台原生机制兜底;`config/limits.py` 的命令文本白名单(`_is_readonly_run_command_allowed`)只是**审批决策的启发式**,不再是安全边界(已知可绕过,如 `find . -delete`;绕过后果只是多走一次审批)。
- **docker/web 模式 = 非特权 uid 执行角色**`modules/docker_readonly_exec.py` - **docker/web 模式 = 非特权 uid 执行角色 + Landlock 进程级只读域**`modules/docker_readonly_exec.py`
- 容器主进程保持 root可写执行不变`sandbox_write_access=False` 的执行通道(`terminal_ops/run.py`、`background_command_manager.py`、只读语境创建的持久终端)以 `-u 10001:10001` 运行,`DOCKER_READONLY_EXEC_UID/GID` 可覆盖 - 容器主进程保持 root可写执行不变`sandbox_write_access=False` 的执行通道(`terminal_ops/run.py`、`background_command_manager.py`、只读语境创建的持久终端)以 `-u 10001:10001` 运行,`DOCKER_READONLY_EXEC_UID/GID` 可覆盖
- 强制力 = 内核 DAC工作区属主为宿主机 root非属主无写权600 权限文件(如 .env不可读逃逸需提权setuid/内核漏洞),无 umount 类捷径 - 第一层强制力 = 内核 DAC工作区属主为宿主机 root非属主无写权600 权限文件(如 .env不可读逃逸需提权setuid/内核漏洞),无 umount 类捷径
- 前提:工作区属主与该 uid 不碰撞、无 o+w 文件、容器未挂 docker.sock云端已验证macOS Docker Desktopvirtiofs fakeowner不执行 uid 权限,仅 Linux 宿主生效 - **第二层 = Landlock 只读域**2026-09 新增,云端实测 kernel 6.8 / ABI V4 / Docker 28 默认 seccomp 放行):纯 DAC 的残留漏洞是工作区内历史遗留的 world-writable777/o+w路径对只读 uid 仍可写;只读执行时命令再经 `modules/landlock_launcher.py`(首次执行时 docker cp 进容器并自检)进入「工作区写类操作全拒」的内核域,最终权限 = DAC ∩ Landlock该洞封死。要点
- launcher 规则handled 只含写类操作ro 路径(工作区挂载点)不加规则(无覆盖即拒绝);/tmp、/var/tmp、/dev/shm 显式授写权以对齐纯 DAC 现状行为(只读身份 HOME=/tmp读/执行不进 handled仍由 DAC 管。注意不能写「/ 授全量 + ro 授空」的交集规则——空授权规则被内核拒绝ENOMSG/errno 42
- 自检失败(内核 <5.13 / seccomp 拦截 / 容器无 python3自动降级纯 DACwarning 日志标注 enforcement level`DOCKER_READONLY_LANDLOCK=0` 可整体停用
- 语义边界Landlock 不管 chmod/chown 等元数据修改,也不管未配置的网络——因此非特权 uid 层必须保留root+Landlock 的进程可 chmod 放宽权限位让域外进程受益)
- 前提:工作区属主与该 uid 不碰撞、容器未挂 docker.sock云端已验证macOS Docker Desktop 双重不适用virtiofs fakeowner 不按 uid 检查linuxkit 内核未编译 Landlock仅 Linux 宿主生效
- 持久终端在 readonly/approval/auto_approval 下同以只读身份创建(`terminal_readonly_enabled` 判定,`terminal_readonly_getter` 注入);终端里的写入会被拒,写命令走 run_command 审批通道权限跨界切换受限档⇄unrestricted时销毁现有终端会话重建`_apply_restricted_execution_mode_link` → `close_all()` - 持久终端在 readonly/approval/auto_approval 下同以只读身份创建(`terminal_readonly_enabled` 判定,`terminal_readonly_getter` 注入);终端里的写入会被拒,写命令走 run_command 审批通道权限跨界切换受限档⇄unrestricted时销毁现有终端会话重建`_apply_restricted_execution_mode_link` → `close_all()`
- Dockerfile 创建 `agent` 用户 + `/etc/gitconfig` safe.directory + 去 setuid 加固;数字 uid 不依赖镜像内用户存在,旧镜像直接受益 - Dockerfile 创建 `agent` 用户 + `/etc/gitconfig` safe.directory + 去 setuid 加固;数字 uid 不依赖镜像内用户存在,旧镜像直接受益
- **macOS 宿主机 = Seatbelt 白名单读模型**`modules/host_sandbox_runner.py` - **macOS 宿主机 = Seatbelt 白名单读模型**`modules/host_sandbox_runner.py`

View File

@ -19,7 +19,7 @@ from modules.host_sandbox_runner import (
build_host_sandbox_readonly_plan, build_host_sandbox_readonly_plan,
host_sandbox_enabled, host_sandbox_enabled,
) )
from modules.docker_readonly_exec import docker_readonly_exec_args from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_wrap_inner
from modules.i18n import tr from modules.i18n import tr
@ -225,9 +225,12 @@ class BackgroundCommandManager:
if relative: if relative:
container_workdir = f"{container_workdir}/{relative}" container_workdir = f"{container_workdir}/{relative}"
exec_cmd = [docker_bin, "exec"] exec_cmd = [docker_bin, "exec"]
inner_cmd = ["/bin/bash", "-lc", command]
if not sandbox_write_access: if not sandbox_write_access:
# 只读执行:非特权 uid内核 DAC 强制只读,见 modules/docker_readonly_exec.py # 只读执行:非特权 uid内核 DAC 强制只读,见 modules/docker_readonly_exec.py
exec_cmd += docker_readonly_exec_args() exec_cmd += docker_readonly_exec_args()
# Landlock 加固:可用时再以进程级只读域封住工作区写;失败自动降级纯 DAC。
inner_cmd = docker_readonly_wrap_inner(container_name, mount_path, inner_cmd, docker_bin)
exec_cmd += [ exec_cmd += [
"-e", "-e",
"PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
@ -236,9 +239,7 @@ class BackgroundCommandManager:
"-w", "-w",
container_workdir, container_workdir,
container_name, container_name,
"/bin/bash", *inner_cmd,
"-lc",
command,
] ]
use_shell = False use_shell = False

View File

@ -19,11 +19,37 @@
权限检查本机制仅在 Linux 宿主机云端/ Linux 桌面生效属预期差异 权限检查本机制仅在 Linux 宿主机云端/ Linux 桌面生效属预期差异
持久终端在 approval/auto_approval 模式下同为只读身份写入命令请走 持久终端在 approval/auto_approval 模式下同为只读身份写入命令请走
run_command审批通过后以 root 重跑 run_command审批通过后以 root 重跑
Landlock 加固2026-09
- DAC 的残留漏洞工作区内历史遗留的 world-writable777/o+w路径
对只读 uid 仍开放写权限为此在只读执行时额外用 Landlock 给进程套上
工作区写类操作全拒的内核域最终权限 = DAC Landlock封死该洞
- launcher 为本目录 landlock_launcher.py首次只读执行时 docker cp 进容器
并以只读身份自检本进程+子进程写工作区都必须被拒通过后才启用
任何一步失败都静默降级为纯 DAC并以 warning 日志标注 enforcement level
- 可用环境变量 DOCKER_READONLY_LANDLOCK=0 整体停用运维逃生门
- 语义刻意对齐纯 DAC 现状仅工作区写类操作被拒/tmp 等其余路径写
全部读/执行行为不变读保护仍由 DAC 承担
""" """
from __future__ import annotations from __future__ import annotations
import logging
import os import os
from typing import List, Tuple import shutil
import subprocess
import threading
from pathlib import Path
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger("docker_readonly_exec")
LANDLOCK_LAUNCHER_SRC = Path(__file__).resolve().with_name("landlock_launcher.py")
LANDLOCK_LAUNCHER_CONTAINER_PATH = "/opt/astrion-landlock/launcher.py"
# 容器名 -> "available" / "unavailable:<reason>"None 表示尚未探测
_landlock_states: Dict[str, str] = {}
_landlock_states_lock = threading.Lock()
def docker_readonly_uid_gid() -> Tuple[str, str]: def docker_readonly_uid_gid() -> Tuple[str, str]:
@ -54,3 +80,105 @@ def docker_readonly_exec_args() -> List[str]:
"-e", "GIT_CONFIG_KEY_0=safe.directory", "-e", "GIT_CONFIG_KEY_0=safe.directory",
"-e", "GIT_CONFIG_VALUE_0=*", "-e", "GIT_CONFIG_VALUE_0=*",
] ]
def _landlock_enabled() -> bool:
return os.environ.get("DOCKER_READONLY_LANDLOCK", "1").strip().lower() not in (
"0", "false", "off", "no",
)
def _docker_run(docker_bin: str, args: List[str], timeout: int = 20) -> subprocess.CompletedProcess:
return subprocess.run(
[docker_bin] + args, capture_output=True, timeout=timeout,
)
def _deploy_and_selftest(container_name: str, mount_path: str,
docker_bin: str) -> Tuple[bool, str]:
"""把 launcher 部署进容器并以只读身份自检。返回 (是否可用, 失败原因)。"""
mount_path = (mount_path or "/workspace").rstrip("/") or "/"
try:
# 1. 容器内 python3 可用性
r = _docker_run(docker_bin, ["exec", container_name, "sh", "-c",
"command -v python3"], timeout=10)
if r.returncode != 0:
return False, "no-python3-in-container"
# 2. 部署 launcher 文件
r = _docker_run(docker_bin, ["exec", container_name, "mkdir", "-p",
os.path.dirname(LANDLOCK_LAUNCHER_CONTAINER_PATH)],
timeout=10)
if r.returncode != 0:
return False, f"mkdir-failed:{r.stderr.decode(errors='replace')[:200]}"
r = _docker_run(docker_bin, ["cp", str(LANDLOCK_LAUNCHER_SRC),
f"{container_name}:{LANDLOCK_LAUNCHER_CONTAINER_PATH}"],
timeout=30)
if r.returncode != 0:
return False, f"docker-cp-failed:{r.stderr.decode(errors='replace')[:200]}"
# 3. 以只读身份自检(与真实只读执行同一 uid/环境)
r = _docker_run(
docker_bin,
["exec", *docker_readonly_exec_args(), container_name,
"python3", LANDLOCK_LAUNCHER_CONTAINER_PATH,
"--selftest", "--ro", mount_path],
timeout=30,
)
out = (r.stdout + r.stderr).decode(errors="replace")
# 4. 清理自检意外成功时的残留(此时说明 Landlock 未生效,但仍要扫尾)
try:
_docker_run(docker_bin, ["exec", container_name, "rm", "-f",
f"{mount_path}/.landlock_selftest_probe"],
timeout=10)
except Exception:
pass
if r.returncode == 0 and "LANDLOCK_SELFTEST_OK" in out:
return True, ""
return False, f"selftest-failed:{out.strip()[:300]}"
except subprocess.TimeoutExpired:
return False, "probe-timeout"
except Exception as e: # noqa: BLE001 - 部署探测必须兜底为降级而非异常
return False, f"{type(e).__name__}:{e}"
def ensure_landlock_ready(container_name: str, mount_path: str,
docker_bin: Optional[str] = None) -> bool:
"""确保容器内 Landlock 只读域可用(部署+自检,按容器缓存结果)。
任何失败都返回 False调用方回退纯 DAC不会抛出
"""
if not _landlock_enabled():
return False
with _landlock_states_lock:
state = _landlock_states.get(container_name)
if state is not None:
return state == "available"
docker_bin = docker_bin or shutil.which("docker") or "docker"
ok, reason = _deploy_and_selftest(container_name, mount_path, docker_bin)
with _landlock_states_lock:
_landlock_states[container_name] = "available" if ok else f"unavailable:{reason}"
if ok:
logger.info("landlock readonly ready: container=%s mount=%s",
container_name, mount_path)
else:
logger.warning(
"landlock unavailable: container=%s reason=%s"
"readonly enforcement falls back to DAC-only (world-writable "
"paths remain writable by the readonly uid)",
container_name, reason,
)
return ok
def docker_readonly_wrap_inner(container_name: str, mount_path: str,
inner_cmd: List[str],
docker_bin: Optional[str] = None) -> List[str]:
"""只读执行时包装容器内命令Landlock 可用则经 launcher 进入只读域。
可用 [python3, launcher, --ro, mount, --] + inner_cmd
不可用原样返回 inner_cmd DAC 降级
"""
if ensure_landlock_ready(container_name, mount_path, docker_bin):
mount = (mount_path or "/workspace").rstrip("/") or "/"
return ["python3", LANDLOCK_LAUNCHER_CONTAINER_PATH,
"--ro", mount, "--", *inner_cmd]
return list(inner_cmd)

View File

@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Astrion Landlock 只读域 launcher容器内运行非后端模块
由后端 modules/docker_readonly_exec.py 部署到容器内docker cp并调用
python3 launcher.py --ro /workspace -- <cmd> [args...]
python3 launcher.py --selftest --ro /workspace
语义2026-09 云端实测kernel 6.8 / Landlock ABI V4 / Docker 28 默认 seccomp 放行
- handled 集合只含写类操作写文件/建删文件目录/创建设备节点/符号链接/
rename-link 跨越/截断不授权的路径一律被内核拒绝
- ro 路径通常即工作区挂载点不加任何规则 写类操作全拒
- /tmp/var/tmp/dev/shm 显式授予写类权限 与纯 DAC 只读的历史行为对齐
只读身份 HOME=/tmp常见临时写入不受影响其余路径写权限收紧这正是
要修复的 world-writable777/o+w DAC 漏洞本身
- /执行不进入 handled 集合完全交给 DAC600 权限敏感文件仍不可读
- 域随 fork/exec 继承且不可自行解除配合 no_new_privs子进程同受限
不能采用/ 授全量 + ro 路径授空的交集写法landlock_add_rule
allowed_access=0 的规则返回 ENOMSGerrno 42内核拒绝空授权规则
仅依赖 python3 标准库ctypes 直调 syscallx86_64/aarch64 通用
"""
import ctypes
import os
import sys
libc = ctypes.CDLL(None, use_errno=True)
PR_SET_NO_NEW_PRIVS = 38
# x86_64 与 aarch64 编号一致
SYS_CREATE_RULESET = 444
SYS_ADD_RULE = 445
SYS_RESTRICT_SELF = 446
LANDLOCK_RULE_PATH_BENEATH = 1
LANDLOCK_CREATE_RULESET_VERSION = 1 << 0
# access_fs 位ABI V1 全集 + V2/V3 增量)
FS_EXECUTE = 1 << 0
FS_WRITE_FILE = 1 << 1
FS_READ_FILE = 1 << 2
FS_READ_DIR = 1 << 3
FS_REMOVE_DIR = 1 << 4
FS_REMOVE_FILE = 1 << 5
FS_MAKE_CHAR = 1 << 6
FS_MAKE_DIR = 1 << 7
FS_MAKE_REG = 1 << 8
FS_MAKE_SOCK = 1 << 9
FS_MAKE_FIFO = 1 << 10
FS_MAKE_BLOCK = 1 << 11
FS_MAKE_SYM = 1 << 12
FS_REFER = 1 << 13 # ABI V2跨目录 rename/link
FS_TRUNCATE = 1 << 14 # ABI V3truncate(2)
# 只读域要管的写类操作(读/执行刻意排除,留给 DAC 决定)
WRITE_OPS_V1 = (FS_WRITE_FILE | FS_REMOVE_DIR | FS_REMOVE_FILE |
FS_MAKE_CHAR | FS_MAKE_DIR | FS_MAKE_REG | FS_MAKE_SOCK |
FS_MAKE_FIFO | FS_MAKE_BLOCK | FS_MAKE_SYM)
class RulesetAttr(ctypes.Structure):
_fields_ = [("handled_access_fs", ctypes.c_uint64)]
class PathBeneathAttr(ctypes.Structure):
_fields_ = [("allowed_access", ctypes.c_uint64),
("parent_fd", ctypes.c_int32),
("_pad", ctypes.c_int32)]
def probe_abi() -> int:
"""返回内核 Landlock ABI 版本(>=1不支持/被拦返回 <1。"""
ctypes.set_errno(0)
ret = libc.syscall(SYS_CREATE_RULESET, None, 0,
LANDLOCK_CREATE_RULESET_VERSION, 0, 0, 0)
return ret
def handled_for_abi(abi: int) -> int:
handled = WRITE_OPS_V1
if abi >= 2:
handled |= FS_REFER
if abi >= 3:
handled |= FS_TRUNCATE
return handled
def install_readonly_domain(ro_paths, rw_paths=("/tmp", "/var/tmp", "/dev/shm")):
"""安装只读域。成功返回 None失败返回错误描述字符串。
ro_paths写类操作全拒的路径不加规则无覆盖即拒绝生效
rw_paths显式授予写类权限的路径对齐纯 DAC 只读下的常用可写区
"""
abi = probe_abi()
if abi < 1:
return f"kernel-unsupported(errno={ctypes.get_errno()})"
handled = handled_for_abi(abi)
attr = RulesetAttr(handled)
ctypes.set_errno(0)
ruleset_fd = libc.syscall(SYS_CREATE_RULESET, ctypes.byref(attr),
ctypes.sizeof(attr), 0, 0, 0)
if ruleset_fd < 0:
e = ctypes.get_errno()
return f"create_ruleset(errno={e}:{os.strerror(e)})"
# 只给存在的 rw 路径加授权规则ro 路径刻意不加规则(无覆盖 → 拒绝)
for path in rw_paths:
if not os.path.exists(path):
continue
try:
fd = os.open(path, os.O_PATH)
except OSError as e:
return f"open({path})(errno={e.errno}:{e.strerror})"
pba = PathBeneathAttr(handled, fd, 0)
ctypes.set_errno(0)
ret = libc.syscall(SYS_ADD_RULE, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
ctypes.byref(pba), 0, 0, 0)
os.close(fd)
if ret < 0:
e = ctypes.get_errno()
return f"add_rule({path})(errno={e}:{os.strerror(e)})"
if libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0:
return "prctl(PR_SET_NO_NEW_PRIVS) failed"
ctypes.set_errno(0)
ret = libc.syscall(SYS_RESTRICT_SELF, ruleset_fd, 0, 0, 0, 0, 0)
if ret < 0:
e = ctypes.get_errno()
return f"restrict_self(errno={e}:{os.strerror(e)})"
return None
def selftest(ro_paths) -> int:
"""自检:安装域后本进程与子进程写 ro 路径都必须被拒。"""
err = install_readonly_domain(ro_paths)
if err:
print(f"SELFTEST_FAIL install: {err}", file=sys.stderr)
return 2
probe = os.path.join(ro_paths[0], ".landlock_selftest_probe")
try:
with open(probe, "w") as f:
f.write("x")
print("SELFTEST_FAIL write-not-denied", file=sys.stderr)
return 3 # 残留文件由部署方以容器 root 清理
except OSError as e:
if e.errno != 13: # EACCES
print(f"SELFTEST_FAIL unexpected errno={e.errno} ({e.strerror})",
file=sys.stderr)
return 4
import subprocess
code = f"open({probe!r},'w').write('x')"
r = subprocess.run([sys.executable, "-c", code], capture_output=True)
if r.returncode == 0:
print("SELFTEST_FAIL child-not-denied", file=sys.stderr)
return 5
# 白名单区(/tmp必须仍可写否则说明规则误配会造成行为回归
try:
tmp_probe = f"/tmp/.landlock_selftest_rw.{os.getpid()}"
with open(tmp_probe, "w") as f:
f.write("x")
os.remove(tmp_probe)
except OSError as e:
print(f"SELFTEST_FAIL tmp-not-writable: errno={e.errno} ({e.strerror})",
file=sys.stderr)
return 6
print("LANDLOCK_SELFTEST_OK")
return 0
def main() -> None:
args = sys.argv[1:]
ro_paths = []
selftest_mode = False
cmd = []
i = 0
while i < len(args):
a = args[i]
if a == "--ro" and i + 1 < len(args):
ro_paths.append(os.path.normpath(args[i + 1]))
i += 2
elif a == "--selftest":
selftest_mode = True
i += 1
elif a == "--":
cmd = args[i + 1:]
break
else:
i += 1
if not ro_paths:
print("launcher: missing --ro <path>", file=sys.stderr)
sys.exit(2)
if selftest_mode:
sys.exit(selftest(ro_paths))
if not cmd:
print("launcher: missing command after --", file=sys.stderr)
sys.exit(2)
err = install_readonly_domain(ro_paths)
if err:
print(f"launcher: install readonly domain failed: {err}", file=sys.stderr)
sys.exit(2)
os.execvp(cmd[0], cmd)
if __name__ == "__main__":
main()

View File

@ -62,7 +62,7 @@ except ImportError:
TERMINAL_SANDBOX_REQUIRE, TERMINAL_SANDBOX_REQUIRE,
) )
from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_uid_gid from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_uid_gid, docker_readonly_wrap_inner
from modules.i18n import tr from modules.i18n import tr
@ -268,7 +268,8 @@ class StartMixin:
"exec", "exec",
"-i", "-i",
] ]
if self.sandbox_options.get("docker_readonly_exec"): readonly_exec = bool(self.sandbox_options.get("docker_readonly_exec"))
if readonly_exec:
# 只读身份会话:与 run_command 只读执行同一非特权 uid内核 DAC 强制) # 只读身份会话:与 run_command 只读执行同一非特权 uid内核 DAC 强制)
cmd += docker_readonly_exec_args() cmd += docker_readonly_exec_args()
if container_workdir: if container_workdir:
@ -284,10 +285,15 @@ class StartMixin:
for key, value in envs.items(): for key, value in envs.items():
cmd += ["-e", f"{key}={value}"] cmd += ["-e", f"{key}={value}"]
cmd.append(container_name) inner_cmd = [shell_path]
cmd.append(shell_path)
if shell_path.endswith("sh"): if shell_path.endswith("sh"):
cmd.append("-i") inner_cmd.append("-i")
if readonly_exec:
# Landlock 加固:可用时 shell 及其子进程全程处于工作区只读域;失败自动降级纯 DAC。
inner_cmd = docker_readonly_wrap_inner(container_name, mount_path, inner_cmd, docker_path)
cmd.append(container_name)
cmd.extend(inner_cmd)
env = os.environ.copy() env = os.environ.copy()
process = subprocess.Popen( process = subprocess.Popen(

View File

@ -82,6 +82,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"default_permission_mode": "unrestricted", "default_permission_mode": "unrestricted",
"default_work_mode": "plan", # 默认运行模式plan / ask / execute "default_work_mode": "plan", # 默认运行模式plan / ask / execute
"auto_generate_title": True, "auto_generate_title": True,
"title_model": "", # 对话标题生成使用的子智能体模型条目名(空=跟随主对话默认模型)
"recent_conversations_prompt_enabled": False, "recent_conversations_prompt_enabled": False,
"recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT, "recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
"project_memory_inject_limit": PROJECT_MEMORY_INJECT_LIMIT_DEFAULT, # 项目记忆索引最大注入条数None-无上限 / >=5-该值 "project_memory_inject_limit": PROJECT_MEMORY_INJECT_LIMIT_DEFAULT, # 项目记忆索引最大注入条数None-无上限 / >=5-该值
@ -278,6 +279,7 @@ def sanitize_personalization_payload(
else "medium" else "medium"
) )
base["auto_generate_title"] = bool(data.get("auto_generate_title", base["auto_generate_title"])) base["auto_generate_title"] = bool(data.get("auto_generate_title", base["auto_generate_title"]))
base["title_model"] = str(data.get("title_model", base.get("title_model", "")) or "").strip()
base["recent_conversations_prompt_enabled"] = bool( base["recent_conversations_prompt_enabled"] = bool(
data.get("recent_conversations_prompt_enabled", base.get("recent_conversations_prompt_enabled", False)) data.get("recent_conversations_prompt_enabled", base.get("recent_conversations_prompt_enabled", False))
) )

View File

@ -18,7 +18,7 @@ from config import DATA_DIR
from config.sub_agent import SUB_AGENT_MODELS_CONFIG_FILE from config.sub_agent import SUB_AGENT_MODELS_CONFIG_FILE
from modules.personalization_manager import REVIEW_AGENT_KEYS from modules.personalization_manager import REVIEW_AGENT_KEYS
__all__ = ["resolve_review_agent_config", "REVIEW_AGENT_KEYS"] __all__ = ["resolve_review_agent_config", "resolve_sub_agent_model_profile", "REVIEW_AGENT_KEYS"]
def _load_model_entry(model_name: str) -> Dict[str, Any]: def _load_model_entry(model_name: str) -> Dict[str, Any]:
@ -52,6 +52,15 @@ def _load_model_entry(model_name: str) -> Dict[str, Any]:
return model_map.get(chosen) return model_map.get(chosen)
def resolve_sub_agent_model_profile(model_name: str) -> Dict[str, Any]:
"""按名称从子智能体模型库解析模型 profileAPIClient.apply_profile 格式)。
名称留空或不存在时回落模型库 default_model模型库也不可用时返回 None
由调用方决定兜底行为
"""
return _load_model_entry(str(model_name).strip() if model_name and str(model_name).strip() else "")
def resolve_review_agent_config(agent_key: str) -> Dict[str, Any]: def resolve_review_agent_config(agent_key: str) -> Dict[str, Any]:
"""解析指定审核智能体的完整运行配置。 """解析指定审核智能体的完整运行配置。

View File

@ -40,7 +40,7 @@ from modules.host_sandbox_runner import (
build_host_sandbox_readonly_plan, build_host_sandbox_readonly_plan,
host_sandbox_enabled, host_sandbox_enabled,
) )
from modules.docker_readonly_exec import docker_readonly_exec_args from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_wrap_inner
from modules.i18n import tr from modules.i18n import tr
if TYPE_CHECKING: if TYPE_CHECKING:
@ -187,9 +187,15 @@ class RunMixin:
if relative: if relative:
container_workdir = f"{container_workdir}/{relative}" container_workdir = f"{container_workdir}/{relative}"
exec_cmd = [docker_bin, "exec"] exec_cmd = [docker_bin, "exec"]
inner_cmd = ["/bin/bash", "-lc", command]
if not sandbox_write_access: if not sandbox_write_access:
# 只读执行:非特权 uid内核 DAC 强制只读,见 modules/docker_readonly_exec.py # 只读执行:非特权 uid内核 DAC 强制只读,见 modules/docker_readonly_exec.py
exec_cmd += docker_readonly_exec_args() exec_cmd += docker_readonly_exec_args()
# Landlock 加固可用时再以进程级只读域封住工作区写777/o+w 绕 DAC 的洞);
# 部署/自检有阻塞 docker 调用,放线程池;失败自动降级纯 DAC。
inner_cmd = await asyncio.to_thread(
docker_readonly_wrap_inner, container_name, mount_path, inner_cmd, docker_bin
)
exec_cmd += [ exec_cmd += [
"-e", "-e",
"PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
@ -198,9 +204,7 @@ class RunMixin:
"-w", "-w",
container_workdir, container_workdir,
container_name, container_name,
"/bin/bash", *inner_cmd,
"-lc",
command,
] ]
use_shell = False use_shell = False

View File

@ -112,7 +112,7 @@ from modules.i18n import tr
conversation_bp = Blueprint('conversation', __name__) conversation_bp = Blueprint('conversation', __name__)
def generate_conversation_title_background(web_terminal: WebTerminal, conversation_id: str, user_message: str, username: str): def generate_conversation_title_background(web_terminal: WebTerminal, conversation_id: str, user_message: str, username: str, title_model: str = ""):
"""在后台生成对话标题并更新索引、推送给前端。""" """在后台生成对话标题并更新索引、推送给前端。"""
return _generate_conversation_title_background( return _generate_conversation_title_background(
web_terminal=web_terminal, web_terminal=web_terminal,
@ -122,6 +122,7 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati
socketio_instance=socketio, socketio_instance=socketio,
title_prompt_path=TITLE_PROMPT_PATH, title_prompt_path=TITLE_PROMPT_PATH,
debug_logger=debug_log, debug_logger=debug_log,
title_model=title_model,
) )

View File

@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os
import re import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -11,7 +10,6 @@ from typing import Any, Dict, List, Optional
from core.web_terminal import WebTerminal from core.web_terminal import WebTerminal
from config import LOGS_DIR from config import LOGS_DIR
from utils.api_client import APIClient from utils.api_client import APIClient
from config.model_profiles import get_default_model_key, get_model_profile
TITLE_DEBUG_DIR = Path(LOGS_DIR).expanduser().resolve() / "title_debug" TITLE_DEBUG_DIR = Path(LOGS_DIR).expanduser().resolve() / "title_debug"
TITLE_DEBUG_FILE = TITLE_DEBUG_DIR / "title_generation.log" TITLE_DEBUG_FILE = TITLE_DEBUG_DIR / "title_generation.log"
@ -32,44 +30,33 @@ def _title_debug_log(message: str, **extra: Any) -> None:
pass pass
def _env_optional(name: str) -> Optional[str]:
"""从环境变量获取可选配置(已由 config/__init__.py 统一注入)。"""
value = os.environ.get(name)
if value is None:
return None
value = value.strip()
return value or None
async def _generate_title_async( async def _generate_title_async(
user_message: str, user_message: str,
title_prompt_path, title_prompt_path,
debug_logger, debug_logger,
model_profile: Optional[Dict[str, Any]] = None,
) -> Optional[str]: ) -> Optional[str]:
"""使用快速模型生成对话标题。""" """使用子智能体模型生成对话标题。
model_profile 来自个人空间标题生成模型配置解析出的子智能体模型库条目
未配置时为模型库 default_model个人空间是唯一配置来源不使用主智能体
默认模型也不支持 AGENT_TITLE_* 环境变量覆盖profile 缺失或应用失败时
直接放弃生成带日志不做其他回退
"""
if not user_message: if not user_message:
_title_debug_log("skip_empty_user_message") _title_debug_log("skip_empty_user_message")
return None return None
client = APIClient(thinking_mode=False, web_mode=True) client = APIClient(thinking_mode=False, web_mode=True)
if not model_profile:
_title_debug_log("title_model_profile_missing")
return None
try: try:
default_model = get_default_model_key() client.apply_profile(model_profile)
client.model_key = default_model _title_debug_log("title_model_profile_applied", model_name=model_profile.get("name"))
client.apply_profile(get_model_profile(default_model))
except Exception as exc: except Exception as exc:
_title_debug_log("default_title_model_profile_failed", error=str(exc)) _title_debug_log("title_model_profile_failed", error=str(exc), model_name=model_profile.get("name"))
title_base = _env_optional("AGENT_TITLE_API_BASE_URL") return None
title_key = _env_optional("AGENT_TITLE_API_KEY")
title_model = _env_optional("AGENT_TITLE_MODEL_ID")
if title_base:
client.fast_api_config["base_url"] = title_base
client.api_base_url = title_base
if title_key:
client.fast_api_config["api_key"] = title_key
client.api_key = title_key
if title_model:
client.fast_api_config["model_id"] = title_model
client.model_id = title_model
_title_debug_log("start_generate_title", user_message_preview=str(user_message)[:200], user_message_len=len(str(user_message))) _title_debug_log("start_generate_title", user_message_preview=str(user_message)[:200], user_message_len=len(str(user_message)))
_title_debug_log( _title_debug_log(
"title_api_config", "title_api_config",
@ -118,13 +105,27 @@ def generate_conversation_title_background(
socketio_instance, socketio_instance,
title_prompt_path, title_prompt_path,
debug_logger, debug_logger,
title_model: str = "",
): ):
"""在后台生成对话标题并更新索引、推送给前端。""" """在后台生成对话标题并更新索引、推送给前端。
title_model 为个人空间配置的子智能体模型条目名 = 子智能体模型库
default_model个人空间是唯一配置来源
"""
if not conversation_id or not user_message: if not conversation_id or not user_message:
return return
async def _runner(): async def _runner():
title = await _generate_title_async(user_message, title_prompt_path, debug_logger) try:
from modules.review_agent_config import resolve_sub_agent_model_profile
# 未配置(空)时回落子智能体模型库 default_model个人空间为唯一配置来源
model_profile = resolve_sub_agent_model_profile(title_model)
except Exception:
model_profile = None
if model_profile is None:
_title_debug_log("title_model_profile_unavailable", title_model=title_model, conversation_id=conversation_id)
return
title = await _generate_title_async(user_message, title_prompt_path, debug_logger, model_profile=model_profile)
if not title: if not title:
_title_debug_log("title_not_generated", conversation_id=conversation_id, username=username) _title_debug_log("title_not_generated", conversation_id=conversation_id, username=username)
return return

View File

@ -104,7 +104,7 @@ from .chat_flow_runner_helpers import (
) )
def generate_conversation_title_background(web_terminal: WebTerminal, conversation_id: str, user_message: str, username: str): def generate_conversation_title_background(web_terminal: WebTerminal, conversation_id: str, user_message: str, username: str, title_model: str = ""):
return _generate_conversation_title_background( return _generate_conversation_title_background(
web_terminal=web_terminal, web_terminal=web_terminal,
conversation_id=conversation_id, conversation_id=conversation_id,
@ -113,6 +113,7 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati
socketio_instance=socketio, socketio_instance=socketio,
title_prompt_path=TITLE_PROMPT_PATH, title_prompt_path=TITLE_PROMPT_PATH,
debug_logger=debug_log, debug_logger=debug_log,
title_model=title_model,
) )
def detect_malformed_tool_call(text): def detect_malformed_tool_call(text):

View File

@ -1855,7 +1855,8 @@ async def handle_task_with_sender(
web_terminal, web_terminal,
conv_id, conv_id,
message, message,
username username,
personal_config.get("title_model", "")
) )
# 自动深层压缩(用户输入后触发) # 自动深层压缩(用户输入后触发)

View File

@ -131,8 +131,6 @@
<FilesTab v-else-if="activeTab === 'files'" key="files" /> <FilesTab v-else-if="activeTab === 'files'" key="files" />
<DataTab v-else-if="activeTab === 'data'" key="data" />
<VoiceTab v-else-if="activeTab === 'voice'" key="voice" /> <VoiceTab v-else-if="activeTab === 'voice'" key="voice" />
<SubAgentsTab v-else-if="activeTab === 'sub-agents'" key="sub-agents" /> <SubAgentsTab v-else-if="activeTab === 'sub-agents'" key="sub-agents" />
@ -174,7 +172,6 @@ import WorkspaceTab from './tabs/WorkspaceTab.vue';
import ContextTab from './tabs/ContextTab.vue'; import ContextTab from './tabs/ContextTab.vue';
import ToolsTab from './tabs/ToolsTab.vue'; import ToolsTab from './tabs/ToolsTab.vue';
import FilesTab from './tabs/FilesTab.vue'; import FilesTab from './tabs/FilesTab.vue';
import DataTab from './tabs/DataTab.vue';
import VoiceTab from './tabs/VoiceTab.vue'; import VoiceTab from './tabs/VoiceTab.vue';
import SubAgentsTab from './tabs/SubAgentsTab.vue'; import SubAgentsTab from './tabs/SubAgentsTab.vue';
import ReviewAgentsTab from './tabs/ReviewAgentsTab.vue'; import ReviewAgentsTab from './tabs/ReviewAgentsTab.vue';
@ -251,7 +248,6 @@ type PersonalTab =
| 'context' | 'context'
| 'tools' | 'tools'
| 'files' | 'files'
| 'data'
| 'voice' | 'voice'
| 'sub-agents' | 'sub-agents'
| 'review-agents' | 'review-agents'
@ -266,7 +262,6 @@ const baseTabs = [
{ id: 'context', labelKey: 'personalization.tabContext', icon: 'chatBubble' }, { id: 'context', labelKey: 'personalization.tabContext', icon: 'chatBubble' },
{ id: 'tools', labelKey: 'personalization.tabTools', icon: 'wrench' }, { id: 'tools', labelKey: 'personalization.tabTools', icon: 'wrench' },
{ id: 'files', labelKey: 'personalization.tabFiles', icon: 'file' }, { id: 'files', labelKey: 'personalization.tabFiles', icon: 'file' },
{ id: 'data', labelKey: 'personalization.tabData', icon: 'layers' },
{ id: 'voice', labelKey: 'personalization.tabVoice', icon: 'mic' }, { id: 'voice', labelKey: 'personalization.tabVoice', icon: 'mic' },
{ id: 'sub-agents', labelKey: 'personalization.tabSubAgents', icon: 'bot' }, { id: 'sub-agents', labelKey: 'personalization.tabSubAgents', icon: 'bot' },
{ id: 'review-agents', labelKey: 'personalization.tabReviewAgents', icon: 'checkbox' } { id: 'review-agents', labelKey: 'personalization.tabReviewAgents', icon: 'checkbox' }
@ -863,9 +858,6 @@ onMounted(async () => {
watch( watch(
() => [activeTab.value, visible.value], () => [activeTab.value, visible.value],
([tab, isVisible]) => { ([tab, isVisible]) => {
if (isVisible && tab === 'data') {
fetchUsageSummary();
}
if (isVisible && tab === 'sub-agents') { if (isVisible && tab === 'sub-agents') {
loadSubAgentRoles(); loadSubAgentRoles();
loadSubAgentSettings(); loadSubAgentSettings();
@ -874,14 +866,20 @@ watch(
if (isVisible && tab === 'review-agents') { if (isVisible && tab === 'review-agents') {
loadSubAgentModels(); loadSubAgentModels();
} }
if ( if (isVisible && tab === 'model') {
isVisible && //
tab === 'general' && loadSubAgentModels();
isAppShell.value && }
!appUpdateInfo.value && if (isVisible && tab === 'general') {
!appUpdateChecking.value //
) { fetchUsageSummary();
checkAppUpdate(); if (
isAppShell.value &&
!appUpdateInfo.value &&
!appUpdateChecking.value
) {
checkAppUpdate();
}
} }
} }
); );

View File

@ -145,21 +145,6 @@ const {
value: $event.target.checked ? 'route' : 'blank' value: $event.target.checked ? 'route' : 'blank'
}) })
" /><FancyCheck :checked="form.new_chat_button_behavior === 'route'" /></label> " /><FancyCheck :checked="form.new_chat_button_behavior === 'route'" /></label>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">{{ $t('personalization.useCustomNamesTitle') }}</span
><span class="settings-row-desc"
>{{ $t('personalization.useCustomNamesDesc') }}</span
></span
><input
type="checkbox"
:checked="form.use_custom_names"
@change="
personalization.updateField({
key: 'use_custom_names',
value: $event.target.checked
})
" /><FancyCheck :checked="form.use_custom_names" /></label>
<label class="settings-toggle-row" <label class="settings-toggle-row"
><span class="settings-row-copy" ><span class="settings-row-copy"
><span class="settings-row-title">{{ $t('personalization.enhancedToolDisplayTitle') }}</span ><span class="settings-row-title">{{ $t('personalization.enhancedToolDisplayTitle') }}</span

View File

@ -1,182 +0,0 @@
<script setup lang="ts">
import { inject } from 'vue';
defineOptions({ name: 'DataTab' });
/**
* 共享上下文由 PersonalizationDrawer.vue 通过 provide 注入
* 解构出的名称与主文件 script 顶层绑定一致模板可直接引用
*/
const ctx = inject<Record<string, any>>('personalizationDrawer')!;
const {
personalization,
fetchUsageSummary,
formatTokenCount,
usageError,
usageLoading,
usageSummary,
usageUpdatedText
} = ctx;
</script>
<template>
<section
class="settings-page usage-summary-page"
data-tutorial="personal-page-usage"
>
<div class="usage-summary-card settings-stats-card">
<div class="usage-summary-header">
<div>
<p class="usage-summary-eyebrow">{{ $t('personalization.usageEyebrow') }}</p>
<h3>{{ $t('personalization.usageTitle') }}</h3>
<p class="usage-summary-desc">{{ $t('personalization.usageDesc') }}
</p>
</div>
</div>
<div class="usage-summary-grid usage-summary-grid--tokens">
<div class="usage-summary-item">
<div class="label">{{ $t('personalization.usageInputLabel') }}</div>
<div class="value value--success">
{{ formatTokenCount(usageSummary.total_input_tokens) }}
</div>
</div>
<div class="usage-summary-item">
<div class="label">{{ $t('personalization.usageOutputLabel') }}</div>
<div class="value value--warning">
{{ formatTokenCount(usageSummary.total_output_tokens) }}
</div>
</div>
</div>
<div class="usage-summary-grid usage-summary-grid--counts">
<div class="usage-summary-item">
<div class="label">{{ $t('personalization.usageConversationsLabel') }}</div>
<div class="value">
{{ formatTokenCount(usageSummary.total_conversations) }}
</div>
</div>
<div class="usage-summary-item">
<div class="label">{{ $t('personalization.usageMessagesLabel') }}</div>
<div class="value">
{{ formatTokenCount(usageSummary.total_user_messages) }}
</div>
</div>
<div class="usage-summary-item">
<div class="label">{{ $t('personalization.usageToolsLabel') }}</div>
<div class="value">
{{ formatTokenCount(usageSummary.total_tools) }}
</div>
</div>
</div>
<div class="usage-summary-meta">
<span v-if="usageError" class="usage-summary-error">{{ usageError }}</span
><span v-else-if="usageLoading">{{ $t('personalization.usageSyncing') }}</span
><span v-else>{{ $t('personalization.usageUpdated', { time: usageUpdatedText }) }}</span
><button
type="button"
class="usage-summary-refresh"
@click="fetchUsageSummary"
:disabled="usageLoading"
>
{{ usageLoading ? $t('personalization.usageRefreshing') : $t('personalization.usageRefresh') }}
</button>
</div>
</div>
</section>
</template>
<style scoped>
.usage-summary-page {
display: block;
padding: 0;
}
.settings-stats-card,
.usage-summary-card {
width: 100%;
max-width: 720px;
padding: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.usage-summary-header h3 {
margin: 4px 0 6px;
font-size: 20px;
}
.usage-summary-eyebrow {
margin: 0;
font-size: 11px;
color: var(--accent-strong);
letter-spacing: 0.16em;
text-transform: uppercase;
}
.usage-summary-desc,
.usage-summary-note,
.usage-summary-meta {
margin: 0;
color: var(--text-secondary);
font-size: 12px;
}
.usage-summary-grid {
display: grid;
gap: 10px;
}
.usage-summary-grid--tokens {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.usage-summary-grid--counts {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.usage-summary-item {
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--theme-control-border);
background: transparent;
}
.usage-summary-item .label {
font-size: 12px;
color: var(--text-secondary);
}
.usage-summary-item .value {
margin-top: 4px;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.usage-summary-item .value--success {
color: var(--state-success);
}
.usage-summary-item .value--warning {
color: var(--state-warning);
}
.usage-summary-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.usage-summary-refresh {
border: 1px solid var(--theme-control-border);
border-radius: 999px;
padding: 7px 14px;
background: transparent;
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
}
</style>

View File

@ -1,31 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, onMounted } from 'vue'; import { inject } from 'vue';
import { useI18n } from 'vue-i18n';
import FancyCheck from '@/components/common/FancyCheck.vue'; import FancyCheck from '@/components/common/FancyCheck.vue';
import { useSandboxSetupStore } from '@/stores/sandboxSetup';
defineOptions({ name: 'GeneralTab' }); defineOptions({ name: 'GeneralTab' });
const { t } = useI18n();
const sandboxSetup = useSandboxSetupStore();
// store
onMounted(() => {
if (!sandboxSetup.status) void sandboxSetup.fetchStatus();
});
const sandboxStatusText = computed(() => {
const s = sandboxSetup.status;
if (!s || sandboxSetup.checking) return t('sandbox.sectionChecking');
if (!s.applicable) return t('sandbox.sectionUnavailable');
return s.state === 'ready' ? t('sandbox.sectionReady') : t('sandbox.sectionMissing');
});
const sandboxShowWizard = computed(() => {
const s = sandboxSetup.status;
return !!s && s.applicable && s.state !== 'ready';
});
/** /**
* 共享上下文由 PersonalizationDrawer.vue 通过 provide 注入 * 共享上下文由 PersonalizationDrawer.vue 通过 provide 注入
* 解构出的名称与主文件 script 顶层绑定一致模板可直接引用 * 解构出的名称与主文件 script 顶层绑定一致模板可直接引用
@ -42,7 +20,13 @@ const {
appUpdateStateText, appUpdateStateText,
appUpdateChecking, appUpdateChecking,
checkAppUpdate, checkAppUpdate,
downloadLatestApp downloadLatestApp,
fetchUsageSummary,
formatTokenCount,
usageError,
usageLoading,
usageSummary,
usageUpdatedText
} = ctx; } = ctx;
</script> </script>
@ -65,6 +49,7 @@ const {
/> />
<FancyCheck :checked="form.auto_generate_title" /> <FancyCheck :checked="form.auto_generate_title" />
</label> </label>
<div class="settings-action-row"> <div class="settings-action-row">
<span class="settings-row-copy"> <span class="settings-row-copy">
<span class="settings-row-title">{{ $t('personalization.tutorialTitle') }}</span> <span class="settings-row-title">{{ $t('personalization.tutorialTitle') }}</span>
@ -106,44 +91,68 @@ const {
</button> </button>
</div> </div>
</div> </div>
<div class="settings-action-row sandbox-env-row">
<span class="settings-row-copy"> <!-- 用量统计数据管理页签并入常规 -->
<span class="settings-row-title">{{ $t('sandbox.sectionTitle') }}</span> <div class="usage-summary-block" data-tutorial="personal-page-usage">
<span class="settings-row-desc">{{ $t('sandbox.sectionDesc') }}</span> <div class="usage-summary-card settings-stats-card">
</span> <div class="usage-summary-header">
<div class="settings-inline-actions"> <div>
<span class="settings-mini-status" :class="{ warning: sandboxShowWizard }">{{ <p class="usage-summary-eyebrow">{{ $t('personalization.usageEyebrow') }}</p>
sandboxStatusText <h3>{{ $t('personalization.usageTitle') }}</h3>
}}</span> <p class="usage-summary-desc">{{ $t('personalization.usageDesc') }}
<span v-if="sandboxSetup.neverAsk" class="settings-mini-status">{{ </p>
$t('sandbox.neverAgainSet') </div>
}}</span> </div>
<button <div class="usage-summary-grid usage-summary-grid--tokens">
v-if="sandboxSetup.neverAsk" <div class="usage-summary-item">
type="button" <div class="label">{{ $t('personalization.usageInputLabel') }}</div>
class="settings-secondary-button" <div class="value value--success">
@click="sandboxSetup.resetNeverAsk()" {{ formatTokenCount(usageSummary.total_input_tokens) }}
> </div>
{{ $t('sandbox.resetNeverAgain') }} </div>
</button> <div class="usage-summary-item">
<button <div class="label">{{ $t('personalization.usageOutputLabel') }}</div>
type="button" <div class="value value--warning">
class="settings-secondary-button" {{ formatTokenCount(usageSummary.total_output_tokens) }}
:disabled="sandboxSetup.checking" </div>
@click="sandboxSetup.recheck()" </div>
> </div>
{{ sandboxSetup.checking ? $t('common.refreshing') : $t('sandbox.recheck') }} <div class="usage-summary-grid usage-summary-grid--counts">
</button> <div class="usage-summary-item">
<button <div class="label">{{ $t('personalization.usageConversationsLabel') }}</div>
v-if="sandboxShowWizard" <div class="value">
type="button" {{ formatTokenCount(usageSummary.total_conversations) }}
class="settings-primary-button" </div>
@click="sandboxSetup.openWizard()" </div>
> <div class="usage-summary-item">
{{ $t('sandbox.openWizard') }} <div class="label">{{ $t('personalization.usageMessagesLabel') }}</div>
</button> <div class="value">
{{ formatTokenCount(usageSummary.total_user_messages) }}
</div>
</div>
<div class="usage-summary-item">
<div class="label">{{ $t('personalization.usageToolsLabel') }}</div>
<div class="value">
{{ formatTokenCount(usageSummary.total_tools) }}
</div>
</div>
</div>
<div class="usage-summary-meta">
<span v-if="usageError" class="usage-summary-error">{{ usageError }}</span
><span v-else-if="usageLoading">{{ $t('personalization.usageSyncing') }}</span
><span v-else>{{ $t('personalization.usageUpdated', { time: usageUpdatedText }) }}</span
><button
type="button"
class="usage-summary-refresh"
@click="fetchUsageSummary"
:disabled="usageLoading"
>
{{ usageLoading ? $t('personalization.usageRefreshing') : $t('personalization.usageRefresh') }}
</button>
</div>
</div> </div>
</div> </div>
<div class="settings-action-row danger-zone"> <div class="settings-action-row danger-zone">
<span class="settings-row-copy"> <span class="settings-row-copy">
<span class="settings-row-title">{{ $t('personalization.logoutTitle') }}</span> <span class="settings-row-title">{{ $t('personalization.logoutTitle') }}</span>
@ -161,14 +170,97 @@ const {
</template> </template>
<style scoped> <style scoped>
/* .usage-summary-block {
避免默认左右结构中右侧按钮组挤压左侧文案空间 */ display: block;
.sandbox-env-row { }
grid-template-columns: minmax(0, 1fr);
.settings-stats-card,
.usage-summary-card {
width: 100%;
max-width: 720px;
padding: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.usage-summary-header h3 {
margin: 4px 0 6px;
font-size: 20px;
}
.usage-summary-eyebrow {
margin: 0;
font-size: 11px;
color: var(--accent-strong);
letter-spacing: 0.16em;
text-transform: uppercase;
}
.usage-summary-desc,
.usage-summary-note,
.usage-summary-meta {
margin: 0;
color: var(--text-secondary);
font-size: 12px;
}
.usage-summary-grid {
display: grid;
gap: 10px; gap: 10px;
} }
.sandbox-env-row .settings-inline-actions { .usage-summary-grid--tokens {
justify-content: flex-start; grid-template-columns: repeat(2, minmax(0, 1fr));
}
.usage-summary-grid--counts {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.usage-summary-item {
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--theme-control-border);
background: transparent;
}
.usage-summary-item .label {
font-size: 12px;
color: var(--text-secondary);
}
.usage-summary-item .value {
margin-top: 4px;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.usage-summary-item .value--success {
color: var(--state-success);
}
.usage-summary-item .value--warning {
color: var(--state-warning);
}
.usage-summary-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.usage-summary-refresh {
border: 1px solid var(--theme-control-border);
border-radius: 999px;
padding: 7px 14px;
background: transparent;
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
} }
</style> </style>

View File

@ -12,6 +12,7 @@ const {
personalization, personalization,
form, form,
activeDropdown, activeDropdown,
closeDropdown,
defaultModelLabel, defaultModelLabel,
filteredModelOptions, filteredModelOptions,
floatingMenuStyle, floatingMenuStyle,
@ -22,6 +23,7 @@ const {
selectDefaultModel, selectDefaultModel,
selectDefaultReasoningEffort, selectDefaultReasoningEffort,
selectDefaultRunMode, selectDefaultRunMode,
subAgentModels,
toggleDropdown, toggleDropdown,
runModeOptions, runModeOptions,
reasoningEffortOptions, reasoningEffortOptions,
@ -79,9 +81,9 @@ const {
</div> </div>
<div class="settings-select-row"> <div class="settings-select-row">
<span class="settings-row-copy" <span class="settings-row-copy"
><span class="settings-row-title">{{ $t('personalization.defaultRunModeTitle') }}</span ><span class="settings-row-title">{{ $t('personalization.defaultThinkingModeTitle') }}</span
><span class="settings-row-desc" ><span class="settings-row-desc"
>{{ $t('personalization.defaultRunModeDesc') }}</span >{{ $t('personalization.defaultThinkingModeDesc') }}</span
></span ></span
> >
<div <div
@ -155,5 +157,49 @@ const {
</div> </div>
</div> </div>
</div> </div>
<!-- 标题生成模型复用子智能体模型库配置个人空间为唯一配置来源 -->
<div class="settings-select-row">
<span class="settings-row-copy">
<span class="settings-row-title">{{ $t('personalization.titleModelTitle') }}</span>
<span class="settings-row-desc">{{ $t('personalization.titleModelDesc') }}</span>
</span>
<div
class="settings-select-wrap"
:class="{ open: activeDropdown === 'title-model' }"
@click.stop
>
<button
type="button"
class="settings-select-button"
@click="toggleDropdown('title-model')"
>
{{ form.title_model || $t('personalization.defaultModelOption') }}
<span class="select-chevron" aria-hidden="true"></span>
</button>
<div
:class="['settings-floating-menu', { dark: activeTheme === 'dark' }]"
:style="activeDropdown === 'title-model' ? floatingMenuStyle : undefined"
>
<button
type="button"
class="settings-menu-option"
:class="{ selected: !form.title_model }"
@click="personalization.updateField({ key: 'title_model', value: '' }); closeDropdown()"
>
<strong>{{ $t('personalization.defaultModelOption') }}</strong><span>{{ $t('personalization.titleDefaultModelDesc') }}</span><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
</button>
<button
v-for="m in subAgentModels"
:key="m.name"
type="button"
class="settings-menu-option"
:class="{ selected: form.title_model === m.name }"
@click="personalization.updateField({ key: 'title_model', value: m.name }); closeDropdown()"
>
<strong>{{ m.name }}</strong><span>{{ m.modes }} · {{ m.multimodal || $t('personalization.textOnly') }}</span><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
</button>
</div>
</div>
</div>
</section> </section>
</template> </template>

View File

@ -20,11 +20,6 @@ const {
activeTheme, activeTheme,
communicationStyleLabel, communicationStyleLabel,
conversationContinuityLabel, conversationContinuityLabel,
currentBlockDisplayMode,
stackedHideBorders,
handleStackedHideBordersChange,
minimalExpandHeightLimited,
handleMinimalExpandHeightLimitedChange,
selectCommunicationStyle, selectCommunicationStyle,
selectConversationContinuity selectConversationContinuity
} = ctx; } = ctx;
@ -96,6 +91,21 @@ const {
@focus="personalization.clearFeedback()" @focus="personalization.clearFeedback()"
/> />
</label> </label>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">{{ $t('personalization.useCustomNamesTitle') }}</span
><span class="settings-row-desc"
>{{ $t('personalization.useCustomNamesDesc') }}</span
></span
><input
type="checkbox"
:checked="form.use_custom_names"
@change="
personalization.updateField({
key: 'use_custom_names',
value: $event.target.checked
})
" /><FancyCheck :checked="form.use_custom_names" /></label>
<div class="settings-input-row stackable"> <div class="settings-input-row stackable">
<span class="settings-row-title">{{ $t('personalization.toneTitle') }}</span> <span class="settings-row-title">{{ $t('personalization.toneTitle') }}</span>
<div class="settings-input-stack"> <div class="settings-input-stack">
@ -138,36 +148,6 @@ const {
@focus="personalization.clearFeedback()" @focus="personalization.clearFeedback()"
></textarea> ></textarea>
</div> </div>
<label
v-if="currentBlockDisplayMode === 'stacked'"
class="settings-toggle-row"
>
<span class="settings-row-copy">
<span class="settings-row-title">{{ $t('personalization.hideBlockBordersTitle') }}</span>
<span class="settings-row-desc">{{ $t('personalization.hideBlockBordersDesc') }}</span>
</span>
<input
type="checkbox"
:checked="stackedHideBorders"
@change="handleStackedHideBordersChange($event)"
/>
<FancyCheck :checked="stackedHideBorders" />
</label>
<label
v-if="currentBlockDisplayMode === 'minimal'"
class="settings-toggle-row"
>
<span class="settings-row-copy">
<span class="settings-row-title">{{ $t('personalization.minimalExpandHeightTitle') }}</span>
<span class="settings-row-desc">{{ $t('personalization.minimalExpandHeightDesc') }}</span>
</span>
<input
type="checkbox"
:checked="minimalExpandHeightLimited"
@change="handleMinimalExpandHeightLimitedChange($event)"
/>
<FancyCheck :checked="minimalExpandHeightLimited" />
</label>
<div class="settings-select-row"> <div class="settings-select-row">
<span class="settings-row-copy"> <span class="settings-row-copy">
<span class="settings-row-title">{{ $t('personalization.communicationStyleTitle') }}</span> <span class="settings-row-title">{{ $t('personalization.communicationStyleTitle') }}</span>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject } from 'vue'; import { inject } from 'vue';
import FancyCheck from '@/components/common/FancyCheck.vue';
defineOptions({ name: 'ReviewAgentsTab' }); defineOptions({ name: 'ReviewAgentsTab' });
@ -11,12 +12,18 @@ const ctx = inject<Record<string, any>>('personalizationDrawer')!;
const { const {
activeDropdown, activeDropdown,
activeTheme, activeTheme,
clampGoalMaxTokens,
clampGoalMaxTurns,
closeDropdown, closeDropdown,
floatingMenuStyle, floatingMenuStyle,
form,
goalTokenLimitEnabled,
personalization,
reviewAgentDefs, reviewAgentDefs,
reviewAgentOf, reviewAgentOf,
subAgentModels, subAgentModels,
toggleDropdown, toggleDropdown,
toggleGoalTokenLimit,
updateReviewAgent, updateReviewAgent,
updateReviewAgentInt updateReviewAgentInt
} = ctx; } = ctx;
@ -166,5 +173,79 @@ const {
/> />
</div> </div>
</template> </template>
<!-- 目标模式goal review 机制的运行参数工作区与权限迁入 -->
<div class="settings-section-divider">
<span class="settings-section-divider__label">{{ $t('personalization.goalModeDivider') }}</span>
</div>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">{{ $t('personalization.goalReviewActiveTitle') }}</span
><span class="settings-row-desc"
>{{ $t('personalization.goalReviewActiveDesc') }}</span
></span
><input
type="checkbox"
:checked="form.goal_review_mode === 'active'"
@change="
personalization.updateField({
key: 'goal_review_mode',
value: $event.target.checked ? 'active' : 'readonly'
})
" /><FancyCheck :checked="form.goal_review_mode === 'active'" /></label>
<div class="settings-select-row">
<span class="settings-row-copy"
><span class="settings-row-title">{{ $t('personalization.goalMaxTurnsTitle') }}</span
><span class="settings-row-desc"
>{{ $t('personalization.goalMaxTurnsDesc') }}</span
></span
>
<input
type="number"
class="settings-number-input"
min="1"
max="100"
:value="form.goal_max_turns"
@change="
personalization.updateField({
key: 'goal_max_turns',
value: clampGoalMaxTurns($event.target.value)
})
"
/>
</div>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">{{ $t('personalization.goalTokenLimitTitle') }}</span
><span class="settings-row-desc"
>{{ $t('personalization.goalTokenLimitDesc') }}</span
></span
><input
type="checkbox"
:checked="goalTokenLimitEnabled"
@change="toggleGoalTokenLimit($event.target.checked)" /><FancyCheck :checked="goalTokenLimitEnabled" /></label>
<div v-if="goalTokenLimitEnabled" class="settings-select-row">
<span class="settings-row-copy"
><span class="settings-row-title">{{ $t('personalization.goalTokenLimitValueTitle') }}</span
><span class="settings-row-desc">{{ $t('personalization.goalTokenLimitValueDesc') }}</span></span
>
<input
type="number"
class="settings-number-input"
min="1000"
step="1000"
:value="form.goal_max_tokens || 100000"
@change="
personalization.updateField({
key: 'goal_max_tokens',
value: clampGoalMaxTokens($event.target.value)
})
"
/>
</div>
</section> </section>
</template> </template>

View File

@ -1,9 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject } from 'vue'; import { computed, inject, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import FancyCheck from '@/components/common/FancyCheck.vue'; import FancyCheck from '@/components/common/FancyCheck.vue';
import { useSandboxSetupStore } from '@/stores/sandboxSetup';
defineOptions({ name: 'WorkspaceTab' }); defineOptions({ name: 'WorkspaceTab' });
const { t } = useI18n();
const sandboxSetup = useSandboxSetupStore();
// store
onMounted(() => {
if (!sandboxSetup.status) void sandboxSetup.fetchStatus();
});
const sandboxStatusText = computed(() => {
const s = sandboxSetup.status;
if (!s || sandboxSetup.checking) return t('sandbox.sectionChecking');
if (!s.applicable) return t('sandbox.sectionUnavailable');
return s.state === 'ready' ? t('sandbox.sectionReady') : t('sandbox.sectionMissing');
});
const sandboxShowWizard = computed(() => {
const s = sandboxSetup.status;
return !!s && s.applicable && s.state !== 'ready';
});
/** /**
* 共享上下文由 PersonalizationDrawer.vue 通过 provide 注入 * 共享上下文由 PersonalizationDrawer.vue 通过 provide 注入
* 解构出的名称与主文件 script 顶层绑定一致模板可直接引用 * 解构出的名称与主文件 script 顶层绑定一致模板可直接引用
@ -13,23 +35,17 @@ const {
activeDropdown, activeDropdown,
floatingMenuStyle, floatingMenuStyle,
form, form,
goalTokenLimitEnabled,
permissionModeLabel, permissionModeLabel,
personalization, personalization,
selectDefaultPermissionMode, selectDefaultPermissionMode,
selectDefaultWorkMode, selectDefaultWorkMode,
selectVersioningBackupMode, selectVersioningBackupMode,
toggleDropdown, toggleDropdown,
toggleGoalTokenLimit,
versioningBackupModeLabel, versioningBackupModeLabel,
workModeLabel, workModeLabel,
permissionModeOptions, permissionModeOptions,
workModeOptions, workModeOptions,
// activeTheme drawerContext activeTheme
// clampGoalMaxTurns / clampGoalMaxTokens const drawerContext report.md
activeTheme,
clampGoalMaxTurns,
clampGoalMaxTokens
} = ctx; } = ctx;
</script> </script>
@ -74,7 +90,7 @@ const {
</div> </div>
<div class="settings-select-row"> <div class="settings-select-row">
<span class="settings-row-copy" <span class="settings-row-copy"
><span class="settings-row-title">{{ $t('personalization.defaultRunModeTitle') }}</span ><span class="settings-row-title">{{ $t('personalization.defaultWorkModeTitle') }}</span
><span class="settings-row-desc" ><span class="settings-row-desc"
>{{ $t('personalization.workRunModeDesc') }}</span >{{ $t('personalization.workRunModeDesc') }}</span
></span ></span
@ -111,6 +127,45 @@ const {
</div> </div>
</div> </div>
</div> </div>
<div class="settings-action-row sandbox-env-row">
<span class="settings-row-copy">
<span class="settings-row-title">{{ $t('sandbox.sectionTitle') }}</span>
<span class="settings-row-desc">{{ $t('sandbox.sectionDesc') }}</span>
</span>
<div class="settings-inline-actions">
<span class="settings-mini-status" :class="{ warning: sandboxShowWizard }">{{
sandboxStatusText
}}</span>
<span v-if="sandboxSetup.neverAsk" class="settings-mini-status">{{
$t('sandbox.neverAgainSet')
}}</span>
<button
v-if="sandboxSetup.neverAsk"
type="button"
class="settings-secondary-button"
@click="sandboxSetup.resetNeverAsk()"
>
{{ $t('sandbox.resetNeverAgain') }}
</button>
<button
type="button"
class="settings-secondary-button"
:disabled="sandboxSetup.checking"
@click="sandboxSetup.recheck()"
>
{{ sandboxSetup.checking ? $t('common.refreshing') : $t('sandbox.recheck') }}
</button>
<button
v-if="sandboxShowWizard"
type="button"
class="settings-primary-button"
@click="sandboxSetup.openWizard()"
>
{{ $t('sandbox.openWizard') }}
</button>
</div>
</div>
<label class="settings-toggle-row" <label class="settings-toggle-row"
><span class="settings-row-copy" ><span class="settings-row-copy"
><span class="settings-row-title">{{ $t('personalization.agentsMdInjectTitle') }}</span ><span class="settings-row-title">{{ $t('personalization.agentsMdInjectTitle') }}</span
@ -241,77 +296,18 @@ const {
</div> </div>
</div> </div>
<div class="settings-section-divider">
<span class="settings-section-divider__label">{{ $t('personalization.goalModeDivider') }}</span>
</div>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">{{ $t('personalization.goalReviewActiveTitle') }}</span
><span class="settings-row-desc"
>{{ $t('personalization.goalReviewActiveDesc') }}</span
></span
><input
type="checkbox"
:checked="form.goal_review_mode === 'active'"
@change="
personalization.updateField({
key: 'goal_review_mode',
value: $event.target.checked ? 'active' : 'readonly'
})
" /><FancyCheck :checked="form.goal_review_mode === 'active'" /></label>
<div class="settings-select-row">
<span class="settings-row-copy"
><span class="settings-row-title">{{ $t('personalization.goalMaxTurnsTitle') }}</span
><span class="settings-row-desc"
>{{ $t('personalization.goalMaxTurnsDesc') }}</span
></span
>
<input
type="number"
class="settings-number-input"
min="1"
max="100"
:value="form.goal_max_turns"
@change="
personalization.updateField({
key: 'goal_max_turns',
value: clampGoalMaxTurns($event.target.value)
})
"
/>
</div>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">{{ $t('personalization.goalTokenLimitTitle') }}</span
><span class="settings-row-desc"
>{{ $t('personalization.goalTokenLimitDesc') }}</span
></span
><input
type="checkbox"
:checked="goalTokenLimitEnabled"
@change="toggleGoalTokenLimit($event.target.checked)" /><FancyCheck :checked="goalTokenLimitEnabled" /></label>
<div v-if="goalTokenLimitEnabled" class="settings-select-row">
<span class="settings-row-copy"
><span class="settings-row-title">{{ $t('personalization.goalTokenLimitValueTitle') }}</span
><span class="settings-row-desc">{{ $t('personalization.goalTokenLimitValueDesc') }}</span></span
>
<input
type="number"
class="settings-number-input"
min="1000"
step="1000"
:value="form.goal_max_tokens || 100000"
@change="
personalization.updateField({
key: 'goal_max_tokens',
value: clampGoalMaxTokens($event.target.value)
})
"
/>
</div>
</section> </section>
</template> </template>
<style scoped>
/*
避免默认左右结构中右侧按钮组挤压左侧文案空间 */
.sandbox-env-row {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
.sandbox-env-row .settings-inline-actions {
justify-content: flex-start;
}
</style>

View File

@ -15,7 +15,6 @@ export default {
tabContext: 'Context', tabContext: 'Context',
tabTools: 'Tools & Skills', tabTools: 'Tools & Skills',
tabFiles: 'Files & Images', tabFiles: 'Files & Images',
tabData: 'Data',
tabVoice: 'Voice Model', tabVoice: 'Voice Model',
tabSubAgents: 'Sub-agents', tabSubAgents: 'Sub-agents',
tabReviewAgents: 'Review Agents', tabReviewAgents: 'Review Agents',
@ -82,8 +81,8 @@ export default {
defaultModelTitle: 'Default model', defaultModelTitle: 'Default model',
defaultModelDesc: 'Preferred model for new conversations and the first request after sign-in', defaultModelDesc: 'Preferred model for new conversations and the first request after sign-in',
modelDisabled: 'Disabled by administrator', modelDisabled: 'Disabled by administrator',
defaultRunModeTitle: 'Default run mode', defaultThinkingModeTitle: 'Default thinking mode',
defaultRunModeDesc: 'Initial run mode on sign-in or when starting a new task', defaultThinkingModeDesc: 'Initial thinking mode on sign-in or when starting a new task',
reasoningEffortTitle: 'Default reasoning effort', reasoningEffortTitle: 'Default reasoning effort',
reasoningEffortDesc: 'Reasoning effort level attached to model requests in thinking mode', reasoningEffortDesc: 'Reasoning effort level attached to model requests in thinking mode',
runModeFast: 'Fast mode', runModeFast: 'Fast mode',
@ -166,7 +165,8 @@ export default {
permissionAutoApprovalDesc: 'Writes pass through; high-risk operations are auto-approved by the background review agent', permissionAutoApprovalDesc: 'Writes pass through; high-risk operations are auto-approved by the background review agent',
permissionUnrestricted: 'Unrestricted', permissionUnrestricted: 'Unrestricted',
permissionUnrestrictedDesc: 'Tools execute directly through the normal flow', permissionUnrestrictedDesc: 'Tools execute directly through the normal flow',
workRunModeDesc: 'Default run mode for new conversations; locked to read-only in plan mode', defaultWorkModeTitle: 'Default work mode',
workRunModeDesc: 'Default work mode for new conversations; locked to read-only in plan mode',
workModePlan: 'Plan', workModePlan: 'Plan',
workModePlanDesc: 'Only makes a plan and executes after approval', workModePlanDesc: 'Only makes a plan and executes after approval',
workModeAsk: 'Ask', workModeAsk: 'Ask',
@ -330,6 +330,9 @@ export default {
reviewAgentWorkflowReviewDesc: 'Judges whether stage output meets the bar at workflow review nodes', reviewAgentWorkflowReviewDesc: 'Judges whether stage output meets the bar at workflow review nodes',
reviewModelEmptyDesc: 'Leave empty to use the model library default', reviewModelEmptyDesc: 'Leave empty to use the model library default',
reviewDefaultModelDesc: 'Uses the model library default_model', reviewDefaultModelDesc: 'Uses the model library default_model',
titleModelTitle: 'Title generation model',
titleModelDesc: 'Choose the AI model used to generate conversation titles, shared with sub-agents',
titleDefaultModelDesc: 'Uses the sub-agent model library default',
reviewThinkingDesc: 'Falls back to fast mode automatically when the model does not support thinking', reviewThinkingDesc: 'Falls back to fast mode automatically when the model does not support thinking',
timeoutTitle: 'Review request timeout', timeoutTitle: 'Review request timeout',
timeoutDesc: 'Single model request timeout (5-3600 seconds)', timeoutDesc: 'Single model request timeout (5-3600 seconds)',

View File

@ -16,7 +16,6 @@ export default {
tabContext: '上下文', tabContext: '上下文',
tabTools: '工具与 Skills', tabTools: '工具与 Skills',
tabFiles: '文件与图片', tabFiles: '文件与图片',
tabData: '数据管理',
tabVoice: '语音模型', tabVoice: '语音模型',
tabSubAgents: '子智能体', tabSubAgents: '子智能体',
tabReviewAgents: '审核智能体', tabReviewAgents: '审核智能体',
@ -83,8 +82,8 @@ export default {
defaultModelTitle: '默认模型', defaultModelTitle: '默认模型',
defaultModelDesc: '为新对话/登录后首次请求选择首选模型', defaultModelDesc: '为新对话/登录后首次请求选择首选模型',
modelDisabled: '已被管理员禁用', modelDisabled: '已被管理员禁用',
defaultRunModeTitle: '默认运行模式', defaultThinkingModeTitle: '默认思考模式',
defaultRunModeDesc: '登录或新建任务时的初始运行模式', defaultThinkingModeDesc: '登录或新建任务时的初始思考模式',
reasoningEffortTitle: '默认推理强度', reasoningEffortTitle: '默认推理强度',
reasoningEffortDesc: '思考模式下请求模型时附带的推理强度参数', reasoningEffortDesc: '思考模式下请求模型时附带的推理强度参数',
runModeFast: '快速模式', runModeFast: '快速模式',
@ -167,7 +166,8 @@ export default {
permissionAutoApprovalDesc: '工作区内写入直通;高风险操作由后台审核智能体自动审批', permissionAutoApprovalDesc: '工作区内写入直通;高风险操作由后台审核智能体自动审批',
permissionUnrestricted: '无限制', permissionUnrestricted: '无限制',
permissionUnrestrictedDesc: '工具按常规流程直接执行', permissionUnrestrictedDesc: '工具按常规流程直接执行',
workRunModeDesc: '新建对话时的默认运行模式;计划模式下权限锁定为只读', defaultWorkModeTitle: '默认工作模式',
workRunModeDesc: '新建对话时的默认工作模式;计划模式下权限锁定为只读',
workModePlan: '计划', workModePlan: '计划',
workModePlanDesc: '只制定计划,批准后执行', workModePlanDesc: '只制定计划,批准后执行',
workModeAsk: '询问', workModeAsk: '询问',
@ -331,6 +331,9 @@ export default {
reviewAgentWorkflowReviewDesc: '工作流审核节点判断阶段产出是否达标', reviewAgentWorkflowReviewDesc: '工作流审核节点判断阶段产出是否达标',
reviewModelEmptyDesc: '留空则使用模型库默认模型', reviewModelEmptyDesc: '留空则使用模型库默认模型',
reviewDefaultModelDesc: '使用模型库的 default_model', reviewDefaultModelDesc: '使用模型库的 default_model',
titleModelTitle: '标题生成模型',
titleModelDesc: '选择用于生成对话标题的 AI 模型,与子智能体共用模型库',
titleDefaultModelDesc: '使用子智能体模型库的默认模型',
reviewThinkingDesc: '模型不支持思考时自动回落快速模式', reviewThinkingDesc: '模型不支持思考时自动回落快速模式',
timeoutTitle: '审核请求超时', timeoutTitle: '审核请求超时',
timeoutDesc: '单次模型请求超时5-3600 秒)', timeoutDesc: '单次模型请求超时5-3600 秒)',

View File

@ -94,6 +94,8 @@ interface PersonalForm {
communication_style: CommunicationStyle; communication_style: CommunicationStyle;
conversation_continuity: ConversationContinuity; conversation_continuity: ConversationContinuity;
auto_generate_title: boolean; auto_generate_title: boolean;
/** 标题生成模型(子智能体模型库条目名);留空 = 模型库 default_model */
title_model: string;
recent_conversations_prompt_enabled: boolean; recent_conversations_prompt_enabled: boolean;
recent_conversations_prompt_limit: number | string; recent_conversations_prompt_limit: number | string;
project_memory_inject_limit: number | string | null; project_memory_inject_limit: number | string | null;
@ -300,6 +302,7 @@ const defaultForm = (): PersonalForm => ({
communication_style: 'default', communication_style: 'default',
conversation_continuity: 'medium', conversation_continuity: 'medium',
auto_generate_title: true, auto_generate_title: true,
title_model: '',
recent_conversations_prompt_enabled: false, recent_conversations_prompt_enabled: false,
recent_conversations_prompt_limit: DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT, recent_conversations_prompt_limit: DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT,
project_memory_inject_limit: DEFAULT_PROJECT_MEMORY_INJECT_LIMIT, project_memory_inject_limit: DEFAULT_PROJECT_MEMORY_INJECT_LIMIT,
@ -515,6 +518,7 @@ export const usePersonalizationStore = defineStore('personalization', {
? data.conversation_continuity ? data.conversation_continuity
: 'medium', : 'medium',
auto_generate_title: data.auto_generate_title !== false, auto_generate_title: data.auto_generate_title !== false,
title_model: typeof data.title_model === 'string' ? data.title_model : '',
recent_conversations_prompt_enabled: !!data.recent_conversations_prompt_enabled, recent_conversations_prompt_enabled: !!data.recent_conversations_prompt_enabled,
recent_conversations_prompt_limit: this.normalizeRecentConversationsPromptLimit( recent_conversations_prompt_limit: this.normalizeRecentConversationsPromptLimit(
data.recent_conversations_prompt_limit data.recent_conversations_prompt_limit

View File

@ -477,10 +477,11 @@ const TUTORIAL_STEP_DEFS: TutorialStepDef[] = [
placement: 'right' placement: 'right'
}, },
{ {
// 用量统计已并入「常规」页签,锚点指向 general
id: 'tab-usage', id: 'tab-usage',
titleKey: 'tutorial.tabUsageTitle', titleKey: 'tutorial.tabUsageTitle',
descriptionKey: 'tutorial.tabUsageDesc', descriptionKey: 'tutorial.tabUsageDesc',
target: '[data-tutorial="personal-tab-usage"]', target: '[data-tutorial="personal-tab-general"]',
mode: 'info', mode: 'info',
autoClick: true, autoClick: true,
placement: 'right' placement: 'right'