Compare commits
No commits in common. "a3fa5e59c6c31d177e985c09963cddd6723eeee0" and "55b3ee44b715f7e4776e79fa0483d08ec964b035" have entirely different histories.
a3fa5e59c6
...
55b3ee44b7
10
AGENTS.md
10
AGENTS.md
@ -412,14 +412,10 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
|
||||
|
||||
只读权限的强制由各平台原生机制兜底;`config/limits.py` 的命令文本白名单(`_is_readonly_run_command_allowed`)只是**审批决策的启发式**,不再是安全边界(已知可绕过,如 `find . -delete`;绕过后果只是多走一次审批)。
|
||||
|
||||
- **docker/web 模式 = 非特权 uid 执行角色 + Landlock 进程级只读域**(`modules/docker_readonly_exec.py`):
|
||||
- **docker/web 模式 = 非特权 uid 执行角色**(`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` 可覆盖
|
||||
- 第一层强制力 = 内核 DAC:工作区属主为宿主机 root,非属主无写权;600 权限文件(如 .env)不可读;逃逸需提权(setuid/内核漏洞),无 umount 类捷径
|
||||
- **第二层 = Landlock 只读域**(2026-09 新增,云端实测 kernel 6.8 / ABI V4 / Docker 28 默认 seccomp 放行):纯 DAC 的残留漏洞是工作区内历史遗留的 world-writable(777/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)自动降级纯 DAC,warning 日志标注 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 宿主生效
|
||||
- 强制力 = 内核 DAC:工作区属主为宿主机 root,非属主无写权;600 权限文件(如 .env)不可读;逃逸需提权(setuid/内核漏洞),无 umount 类捷径
|
||||
- 前提:工作区属主与该 uid 不碰撞、无 o+w 文件、容器未挂 docker.sock(云端已验证);macOS Docker Desktop(virtiofs fakeowner)不执行 uid 权限,仅 Linux 宿主生效
|
||||
- 持久终端在 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 不依赖镜像内用户存在,旧镜像直接受益
|
||||
- **macOS 宿主机 = Seatbelt 白名单读模型**(`modules/host_sandbox_runner.py`):
|
||||
|
||||
@ -19,7 +19,7 @@ from modules.host_sandbox_runner import (
|
||||
build_host_sandbox_readonly_plan,
|
||||
host_sandbox_enabled,
|
||||
)
|
||||
from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_wrap_inner
|
||||
from modules.docker_readonly_exec import docker_readonly_exec_args
|
||||
from modules.i18n import tr
|
||||
|
||||
|
||||
@ -225,12 +225,9 @@ class BackgroundCommandManager:
|
||||
if relative:
|
||||
container_workdir = f"{container_workdir}/{relative}"
|
||||
exec_cmd = [docker_bin, "exec"]
|
||||
inner_cmd = ["/bin/bash", "-lc", command]
|
||||
if not sandbox_write_access:
|
||||
# 只读执行:非特权 uid(内核 DAC 强制只读,见 modules/docker_readonly_exec.py)
|
||||
exec_cmd += docker_readonly_exec_args()
|
||||
# Landlock 加固:可用时再以进程级只读域封住工作区写;失败自动降级纯 DAC。
|
||||
inner_cmd = docker_readonly_wrap_inner(container_name, mount_path, inner_cmd, docker_bin)
|
||||
exec_cmd += [
|
||||
"-e",
|
||||
"PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
@ -239,7 +236,9 @@ class BackgroundCommandManager:
|
||||
"-w",
|
||||
container_workdir,
|
||||
container_name,
|
||||
*inner_cmd,
|
||||
"/bin/bash",
|
||||
"-lc",
|
||||
command,
|
||||
]
|
||||
use_shell = False
|
||||
|
||||
|
||||
@ -19,37 +19,11 @@
|
||||
权限检查,本机制仅在 Linux 宿主机(云端/ Linux 桌面)生效,属预期差异;
|
||||
持久终端在 approval/auto_approval 模式下同为只读身份,写入命令请走
|
||||
run_command(审批通过后以 root 重跑)。
|
||||
|
||||
Landlock 加固(2026-09 起):
|
||||
|
||||
- 纯 DAC 的残留漏洞:工作区内历史遗留的 world-writable(777/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
|
||||
|
||||
import logging
|
||||
import os
|
||||
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()
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
def docker_readonly_uid_gid() -> Tuple[str, str]:
|
||||
@ -80,105 +54,3 @@ def docker_readonly_exec_args() -> List[str]:
|
||||
"-e", "GIT_CONFIG_KEY_0=safe.directory",
|
||||
"-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)
|
||||
|
||||
@ -1,210 +0,0 @@
|
||||
#!/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-writable(777/o+w)绕 DAC 漏洞本身;
|
||||
- 读/执行不进入 handled 集合,完全交给 DAC(600 权限敏感文件仍不可读);
|
||||
- 域随 fork/exec 继承且不可自行解除(配合 no_new_privs),子进程同受限。
|
||||
|
||||
注:不能采用「/ 授全量 + ro 路径授空」的交集写法——landlock_add_rule 对
|
||||
allowed_access=0 的规则返回 ENOMSG(errno 42),内核拒绝空授权规则。
|
||||
|
||||
仅依赖 python3 标准库(ctypes 直调 syscall),x86_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 V3:truncate(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()
|
||||
@ -62,7 +62,7 @@ except ImportError:
|
||||
TERMINAL_SANDBOX_REQUIRE,
|
||||
)
|
||||
|
||||
from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_uid_gid, docker_readonly_wrap_inner
|
||||
from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_uid_gid
|
||||
from modules.i18n import tr
|
||||
|
||||
|
||||
@ -268,8 +268,7 @@ class StartMixin:
|
||||
"exec",
|
||||
"-i",
|
||||
]
|
||||
readonly_exec = bool(self.sandbox_options.get("docker_readonly_exec"))
|
||||
if readonly_exec:
|
||||
if self.sandbox_options.get("docker_readonly_exec"):
|
||||
# 只读身份会话:与 run_command 只读执行同一非特权 uid(内核 DAC 强制)
|
||||
cmd += docker_readonly_exec_args()
|
||||
if container_workdir:
|
||||
@ -285,15 +284,10 @@ class StartMixin:
|
||||
for key, value in envs.items():
|
||||
cmd += ["-e", f"{key}={value}"]
|
||||
|
||||
inner_cmd = [shell_path]
|
||||
if shell_path.endswith("sh"):
|
||||
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)
|
||||
cmd.append(shell_path)
|
||||
if shell_path.endswith("sh"):
|
||||
cmd.append("-i")
|
||||
|
||||
env = os.environ.copy()
|
||||
process = subprocess.Popen(
|
||||
|
||||
@ -82,7 +82,6 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
|
||||
"default_permission_mode": "unrestricted",
|
||||
"default_work_mode": "plan", # 默认运行模式:plan / ask / execute
|
||||
"auto_generate_title": True,
|
||||
"title_model": "", # 对话标题生成使用的子智能体模型条目名(空=跟随主对话默认模型)
|
||||
"recent_conversations_prompt_enabled": False,
|
||||
"recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT,
|
||||
"project_memory_inject_limit": PROJECT_MEMORY_INJECT_LIMIT_DEFAULT, # 项目记忆索引最大注入条数:None-无上限 / >=5-该值
|
||||
@ -279,7 +278,6 @@ def sanitize_personalization_payload(
|
||||
else "medium"
|
||||
)
|
||||
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(
|
||||
data.get("recent_conversations_prompt_enabled", base.get("recent_conversations_prompt_enabled", False))
|
||||
)
|
||||
|
||||
@ -18,7 +18,7 @@ from config import DATA_DIR
|
||||
from config.sub_agent import SUB_AGENT_MODELS_CONFIG_FILE
|
||||
from modules.personalization_manager import REVIEW_AGENT_KEYS
|
||||
|
||||
__all__ = ["resolve_review_agent_config", "resolve_sub_agent_model_profile", "REVIEW_AGENT_KEYS"]
|
||||
__all__ = ["resolve_review_agent_config", "REVIEW_AGENT_KEYS"]
|
||||
|
||||
|
||||
def _load_model_entry(model_name: str) -> Dict[str, Any]:
|
||||
@ -52,15 +52,6 @@ def _load_model_entry(model_name: str) -> Dict[str, Any]:
|
||||
return model_map.get(chosen)
|
||||
|
||||
|
||||
def resolve_sub_agent_model_profile(model_name: str) -> Dict[str, Any]:
|
||||
"""按名称从子智能体模型库解析模型 profile(APIClient.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]:
|
||||
"""解析指定审核智能体的完整运行配置。
|
||||
|
||||
|
||||
@ -40,7 +40,7 @@ from modules.host_sandbox_runner import (
|
||||
build_host_sandbox_readonly_plan,
|
||||
host_sandbox_enabled,
|
||||
)
|
||||
from modules.docker_readonly_exec import docker_readonly_exec_args, docker_readonly_wrap_inner
|
||||
from modules.docker_readonly_exec import docker_readonly_exec_args
|
||||
from modules.i18n import tr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -187,15 +187,9 @@ class RunMixin:
|
||||
if relative:
|
||||
container_workdir = f"{container_workdir}/{relative}"
|
||||
exec_cmd = [docker_bin, "exec"]
|
||||
inner_cmd = ["/bin/bash", "-lc", command]
|
||||
if not sandbox_write_access:
|
||||
# 只读执行:非特权 uid(内核 DAC 强制只读,见 modules/docker_readonly_exec.py)
|
||||
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 += [
|
||||
"-e",
|
||||
"PATH=/opt/agent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
@ -204,7 +198,9 @@ class RunMixin:
|
||||
"-w",
|
||||
container_workdir,
|
||||
container_name,
|
||||
*inner_cmd,
|
||||
"/bin/bash",
|
||||
"-lc",
|
||||
command,
|
||||
]
|
||||
use_shell = False
|
||||
|
||||
|
||||
@ -112,7 +112,7 @@ from modules.i18n import tr
|
||||
conversation_bp = Blueprint('conversation', __name__)
|
||||
|
||||
|
||||
def generate_conversation_title_background(web_terminal: WebTerminal, conversation_id: str, user_message: str, username: str, title_model: str = ""):
|
||||
def generate_conversation_title_background(web_terminal: WebTerminal, conversation_id: str, user_message: str, username: str):
|
||||
"""在后台生成对话标题并更新索引、推送给前端。"""
|
||||
return _generate_conversation_title_background(
|
||||
web_terminal=web_terminal,
|
||||
@ -122,7 +122,6 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati
|
||||
socketio_instance=socketio,
|
||||
title_prompt_path=TITLE_PROMPT_PATH,
|
||||
debug_logger=debug_log,
|
||||
title_model=title_model,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@ -10,6 +11,7 @@ from typing import Any, Dict, List, Optional
|
||||
from core.web_terminal import WebTerminal
|
||||
from config import LOGS_DIR
|
||||
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_FILE = TITLE_DEBUG_DIR / "title_generation.log"
|
||||
@ -30,33 +32,44 @@ def _title_debug_log(message: str, **extra: Any) -> None:
|
||||
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(
|
||||
user_message: str,
|
||||
title_prompt_path,
|
||||
debug_logger,
|
||||
model_profile: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[str]:
|
||||
"""使用子智能体模型生成对话标题。
|
||||
|
||||
model_profile 来自个人空间「标题生成模型」配置解析出的子智能体模型库条目
|
||||
(未配置时为模型库 default_model)。个人空间是唯一配置来源:不使用主智能体
|
||||
默认模型,也不支持 AGENT_TITLE_* 环境变量覆盖;profile 缺失或应用失败时
|
||||
直接放弃生成(带日志),不做其他回退。
|
||||
"""
|
||||
"""使用快速模型生成对话标题。"""
|
||||
if not user_message:
|
||||
_title_debug_log("skip_empty_user_message")
|
||||
return None
|
||||
|
||||
client = APIClient(thinking_mode=False, web_mode=True)
|
||||
if not model_profile:
|
||||
_title_debug_log("title_model_profile_missing")
|
||||
return None
|
||||
try:
|
||||
client.apply_profile(model_profile)
|
||||
_title_debug_log("title_model_profile_applied", model_name=model_profile.get("name"))
|
||||
default_model = get_default_model_key()
|
||||
client.model_key = default_model
|
||||
client.apply_profile(get_model_profile(default_model))
|
||||
except Exception as exc:
|
||||
_title_debug_log("title_model_profile_failed", error=str(exc), model_name=model_profile.get("name"))
|
||||
return None
|
||||
_title_debug_log("default_title_model_profile_failed", error=str(exc))
|
||||
title_base = _env_optional("AGENT_TITLE_API_BASE_URL")
|
||||
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(
|
||||
"title_api_config",
|
||||
@ -105,27 +118,13 @@ def generate_conversation_title_background(
|
||||
socketio_instance,
|
||||
title_prompt_path,
|
||||
debug_logger,
|
||||
title_model: str = "",
|
||||
):
|
||||
"""在后台生成对话标题并更新索引、推送给前端。
|
||||
|
||||
title_model 为个人空间配置的子智能体模型条目名(空 = 子智能体模型库
|
||||
default_model)。个人空间是唯一配置来源。
|
||||
"""
|
||||
"""在后台生成对话标题并更新索引、推送给前端。"""
|
||||
if not conversation_id or not user_message:
|
||||
return
|
||||
|
||||
async def _runner():
|
||||
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)
|
||||
title = await _generate_title_async(user_message, title_prompt_path, debug_logger)
|
||||
if not title:
|
||||
_title_debug_log("title_not_generated", conversation_id=conversation_id, username=username)
|
||||
return
|
||||
|
||||
@ -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, title_model: str = ""):
|
||||
def generate_conversation_title_background(web_terminal: WebTerminal, conversation_id: str, user_message: str, username: str):
|
||||
return _generate_conversation_title_background(
|
||||
web_terminal=web_terminal,
|
||||
conversation_id=conversation_id,
|
||||
@ -113,7 +113,6 @@ def generate_conversation_title_background(web_terminal: WebTerminal, conversati
|
||||
socketio_instance=socketio,
|
||||
title_prompt_path=TITLE_PROMPT_PATH,
|
||||
debug_logger=debug_log,
|
||||
title_model=title_model,
|
||||
)
|
||||
|
||||
def detect_malformed_tool_call(text):
|
||||
|
||||
@ -1855,8 +1855,7 @@ async def handle_task_with_sender(
|
||||
web_terminal,
|
||||
conv_id,
|
||||
message,
|
||||
username,
|
||||
personal_config.get("title_model", "")
|
||||
username
|
||||
)
|
||||
|
||||
# 自动深层压缩(用户输入后触发)
|
||||
|
||||
@ -131,6 +131,8 @@
|
||||
|
||||
<FilesTab v-else-if="activeTab === 'files'" key="files" />
|
||||
|
||||
<DataTab v-else-if="activeTab === 'data'" key="data" />
|
||||
|
||||
<VoiceTab v-else-if="activeTab === 'voice'" key="voice" />
|
||||
|
||||
<SubAgentsTab v-else-if="activeTab === 'sub-agents'" key="sub-agents" />
|
||||
@ -172,6 +174,7 @@ import WorkspaceTab from './tabs/WorkspaceTab.vue';
|
||||
import ContextTab from './tabs/ContextTab.vue';
|
||||
import ToolsTab from './tabs/ToolsTab.vue';
|
||||
import FilesTab from './tabs/FilesTab.vue';
|
||||
import DataTab from './tabs/DataTab.vue';
|
||||
import VoiceTab from './tabs/VoiceTab.vue';
|
||||
import SubAgentsTab from './tabs/SubAgentsTab.vue';
|
||||
import ReviewAgentsTab from './tabs/ReviewAgentsTab.vue';
|
||||
@ -248,6 +251,7 @@ type PersonalTab =
|
||||
| 'context'
|
||||
| 'tools'
|
||||
| 'files'
|
||||
| 'data'
|
||||
| 'voice'
|
||||
| 'sub-agents'
|
||||
| 'review-agents'
|
||||
@ -262,6 +266,7 @@ const baseTabs = [
|
||||
{ id: 'context', labelKey: 'personalization.tabContext', icon: 'chatBubble' },
|
||||
{ id: 'tools', labelKey: 'personalization.tabTools', icon: 'wrench' },
|
||||
{ id: 'files', labelKey: 'personalization.tabFiles', icon: 'file' },
|
||||
{ id: 'data', labelKey: 'personalization.tabData', icon: 'layers' },
|
||||
{ id: 'voice', labelKey: 'personalization.tabVoice', icon: 'mic' },
|
||||
{ id: 'sub-agents', labelKey: 'personalization.tabSubAgents', icon: 'bot' },
|
||||
{ id: 'review-agents', labelKey: 'personalization.tabReviewAgents', icon: 'checkbox' }
|
||||
@ -858,6 +863,9 @@ onMounted(async () => {
|
||||
watch(
|
||||
() => [activeTab.value, visible.value],
|
||||
([tab, isVisible]) => {
|
||||
if (isVisible && tab === 'data') {
|
||||
fetchUsageSummary();
|
||||
}
|
||||
if (isVisible && tab === 'sub-agents') {
|
||||
loadSubAgentRoles();
|
||||
loadSubAgentSettings();
|
||||
@ -866,20 +874,14 @@ watch(
|
||||
if (isVisible && tab === 'review-agents') {
|
||||
loadSubAgentModels();
|
||||
}
|
||||
if (isVisible && tab === 'model') {
|
||||
// 「标题生成模型」菜单复用子智能体模型库,需在模型页签加载模型列表
|
||||
loadSubAgentModels();
|
||||
}
|
||||
if (isVisible && tab === 'general') {
|
||||
// 用量统计已并入常规页签,切到时拉取一次
|
||||
fetchUsageSummary();
|
||||
if (
|
||||
isAppShell.value &&
|
||||
!appUpdateInfo.value &&
|
||||
!appUpdateChecking.value
|
||||
) {
|
||||
checkAppUpdate();
|
||||
}
|
||||
if (
|
||||
isVisible &&
|
||||
tab === 'general' &&
|
||||
isAppShell.value &&
|
||||
!appUpdateInfo.value &&
|
||||
!appUpdateChecking.value
|
||||
) {
|
||||
checkAppUpdate();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@ -145,6 +145,21 @@ const {
|
||||
value: $event.target.checked ? 'route' : 'blank'
|
||||
})
|
||||
" /><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"
|
||||
><span class="settings-row-copy"
|
||||
><span class="settings-row-title">{{ $t('personalization.enhancedToolDisplayTitle') }}</span
|
||||
|
||||
182
static/src/components/personalization/tabs/DataTab.vue
Normal file
182
static/src/components/personalization/tabs/DataTab.vue
Normal file
@ -0,0 +1,182 @@
|
||||
<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>
|
||||
@ -1,9 +1,31 @@
|
||||
<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 { useSandboxSetupStore } from '@/stores/sandboxSetup';
|
||||
|
||||
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 注入。
|
||||
* 解构出的名称与主文件 script 顶层绑定一致,模板可直接引用。
|
||||
@ -20,13 +42,7 @@ const {
|
||||
appUpdateStateText,
|
||||
appUpdateChecking,
|
||||
checkAppUpdate,
|
||||
downloadLatestApp,
|
||||
fetchUsageSummary,
|
||||
formatTokenCount,
|
||||
usageError,
|
||||
usageLoading,
|
||||
usageSummary,
|
||||
usageUpdatedText
|
||||
downloadLatestApp
|
||||
} = ctx;
|
||||
</script>
|
||||
|
||||
@ -49,7 +65,6 @@ const {
|
||||
/>
|
||||
<FancyCheck :checked="form.auto_generate_title" />
|
||||
</label>
|
||||
|
||||
<div class="settings-action-row">
|
||||
<span class="settings-row-copy">
|
||||
<span class="settings-row-title">{{ $t('personalization.tutorialTitle') }}</span>
|
||||
@ -91,68 +106,44 @@ const {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用量统计(原「数据管理」页签并入常规) -->
|
||||
<div class="usage-summary-block" 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 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>
|
||||
|
||||
<div class="settings-action-row danger-zone">
|
||||
<span class="settings-row-copy">
|
||||
<span class="settings-row-title">{{ $t('personalization.logoutTitle') }}</span>
|
||||
@ -170,97 +161,14 @@ const {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.usage-summary-block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.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;
|
||||
/* 沙箱环境区块:上下结构(标题描述在上,状态徽标与按钮组整行在下),
|
||||
避免默认左右结构中右侧按钮组挤压左侧文案空间 */
|
||||
.sandbox-env-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
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;
|
||||
.sandbox-env-row .settings-inline-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -12,7 +12,6 @@ const {
|
||||
personalization,
|
||||
form,
|
||||
activeDropdown,
|
||||
closeDropdown,
|
||||
defaultModelLabel,
|
||||
filteredModelOptions,
|
||||
floatingMenuStyle,
|
||||
@ -23,7 +22,6 @@ const {
|
||||
selectDefaultModel,
|
||||
selectDefaultReasoningEffort,
|
||||
selectDefaultRunMode,
|
||||
subAgentModels,
|
||||
toggleDropdown,
|
||||
runModeOptions,
|
||||
reasoningEffortOptions,
|
||||
@ -81,9 +79,9 @@ const {
|
||||
</div>
|
||||
<div class="settings-select-row">
|
||||
<span class="settings-row-copy"
|
||||
><span class="settings-row-title">{{ $t('personalization.defaultThinkingModeTitle') }}</span
|
||||
><span class="settings-row-title">{{ $t('personalization.defaultRunModeTitle') }}</span
|
||||
><span class="settings-row-desc"
|
||||
>{{ $t('personalization.defaultThinkingModeDesc') }}</span
|
||||
>{{ $t('personalization.defaultRunModeDesc') }}</span
|
||||
></span
|
||||
>
|
||||
<div
|
||||
@ -157,49 +155,5 @@ const {
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -20,6 +20,11 @@ const {
|
||||
activeTheme,
|
||||
communicationStyleLabel,
|
||||
conversationContinuityLabel,
|
||||
currentBlockDisplayMode,
|
||||
stackedHideBorders,
|
||||
handleStackedHideBordersChange,
|
||||
minimalExpandHeightLimited,
|
||||
handleMinimalExpandHeightLimitedChange,
|
||||
selectCommunicationStyle,
|
||||
selectConversationContinuity
|
||||
} = ctx;
|
||||
@ -91,21 +96,6 @@ const {
|
||||
@focus="personalization.clearFeedback()"
|
||||
/>
|
||||
</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">
|
||||
<span class="settings-row-title">{{ $t('personalization.toneTitle') }}</span>
|
||||
<div class="settings-input-stack">
|
||||
@ -148,6 +138,36 @@ const {
|
||||
@focus="personalization.clearFeedback()"
|
||||
></textarea>
|
||||
</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">
|
||||
<span class="settings-row-copy">
|
||||
<span class="settings-row-title">{{ $t('personalization.communicationStyleTitle') }}</span>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue';
|
||||
import FancyCheck from '@/components/common/FancyCheck.vue';
|
||||
|
||||
defineOptions({ name: 'ReviewAgentsTab' });
|
||||
|
||||
@ -12,18 +11,12 @@ const ctx = inject<Record<string, any>>('personalizationDrawer')!;
|
||||
const {
|
||||
activeDropdown,
|
||||
activeTheme,
|
||||
clampGoalMaxTokens,
|
||||
clampGoalMaxTurns,
|
||||
closeDropdown,
|
||||
floatingMenuStyle,
|
||||
form,
|
||||
goalTokenLimitEnabled,
|
||||
personalization,
|
||||
reviewAgentDefs,
|
||||
reviewAgentOf,
|
||||
subAgentModels,
|
||||
toggleDropdown,
|
||||
toggleGoalTokenLimit,
|
||||
updateReviewAgent,
|
||||
updateReviewAgentInt
|
||||
} = ctx;
|
||||
@ -173,79 +166,5 @@ const {
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -1,31 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { inject } from 'vue';
|
||||
import FancyCheck from '@/components/common/FancyCheck.vue';
|
||||
import { useSandboxSetupStore } from '@/stores/sandboxSetup';
|
||||
|
||||
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 注入。
|
||||
* 解构出的名称与主文件 script 顶层绑定一致,模板可直接引用。
|
||||
@ -35,17 +13,23 @@ const {
|
||||
activeDropdown,
|
||||
floatingMenuStyle,
|
||||
form,
|
||||
goalTokenLimitEnabled,
|
||||
permissionModeLabel,
|
||||
personalization,
|
||||
selectDefaultPermissionMode,
|
||||
selectDefaultWorkMode,
|
||||
selectVersioningBackupMode,
|
||||
toggleDropdown,
|
||||
toggleGoalTokenLimit,
|
||||
versioningBackupModeLabel,
|
||||
workModeLabel,
|
||||
permissionModeOptions,
|
||||
workModeOptions,
|
||||
activeTheme
|
||||
// 清单外顶层绑定(模板引用):activeTheme 已在 drawerContext 中提供;
|
||||
// clampGoalMaxTurns / clampGoalMaxTokens 为顶层 const 但当前不在 drawerContext 内(详见 report.md)
|
||||
activeTheme,
|
||||
clampGoalMaxTurns,
|
||||
clampGoalMaxTokens
|
||||
} = ctx;
|
||||
</script>
|
||||
|
||||
@ -90,7 +74,7 @@ const {
|
||||
</div>
|
||||
<div class="settings-select-row">
|
||||
<span class="settings-row-copy"
|
||||
><span class="settings-row-title">{{ $t('personalization.defaultWorkModeTitle') }}</span
|
||||
><span class="settings-row-title">{{ $t('personalization.defaultRunModeTitle') }}</span
|
||||
><span class="settings-row-desc"
|
||||
>{{ $t('personalization.workRunModeDesc') }}</span
|
||||
></span
|
||||
@ -127,45 +111,6 @@ const {
|
||||
</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"
|
||||
><span class="settings-row-copy"
|
||||
><span class="settings-row-title">{{ $t('personalization.agentsMdInjectTitle') }}</span
|
||||
@ -296,18 +241,77 @@ const {
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@ -15,6 +15,7 @@ export default {
|
||||
tabContext: 'Context',
|
||||
tabTools: 'Tools & Skills',
|
||||
tabFiles: 'Files & Images',
|
||||
tabData: 'Data',
|
||||
tabVoice: 'Voice Model',
|
||||
tabSubAgents: 'Sub-agents',
|
||||
tabReviewAgents: 'Review Agents',
|
||||
@ -81,8 +82,8 @@ export default {
|
||||
defaultModelTitle: 'Default model',
|
||||
defaultModelDesc: 'Preferred model for new conversations and the first request after sign-in',
|
||||
modelDisabled: 'Disabled by administrator',
|
||||
defaultThinkingModeTitle: 'Default thinking mode',
|
||||
defaultThinkingModeDesc: 'Initial thinking mode on sign-in or when starting a new task',
|
||||
defaultRunModeTitle: 'Default run mode',
|
||||
defaultRunModeDesc: 'Initial run mode on sign-in or when starting a new task',
|
||||
reasoningEffortTitle: 'Default reasoning effort',
|
||||
reasoningEffortDesc: 'Reasoning effort level attached to model requests in thinking mode',
|
||||
runModeFast: 'Fast mode',
|
||||
@ -165,8 +166,7 @@ export default {
|
||||
permissionAutoApprovalDesc: 'Writes pass through; high-risk operations are auto-approved by the background review agent',
|
||||
permissionUnrestricted: 'Unrestricted',
|
||||
permissionUnrestrictedDesc: 'Tools execute directly through the normal flow',
|
||||
defaultWorkModeTitle: 'Default work mode',
|
||||
workRunModeDesc: 'Default work mode for new conversations; locked to read-only in plan mode',
|
||||
workRunModeDesc: 'Default run mode for new conversations; locked to read-only in plan mode',
|
||||
workModePlan: 'Plan',
|
||||
workModePlanDesc: 'Only makes a plan and executes after approval',
|
||||
workModeAsk: 'Ask',
|
||||
@ -330,9 +330,6 @@ export default {
|
||||
reviewAgentWorkflowReviewDesc: 'Judges whether stage output meets the bar at workflow review nodes',
|
||||
reviewModelEmptyDesc: 'Leave empty to use the model library default',
|
||||
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',
|
||||
timeoutTitle: 'Review request timeout',
|
||||
timeoutDesc: 'Single model request timeout (5-3600 seconds)',
|
||||
|
||||
@ -16,6 +16,7 @@ export default {
|
||||
tabContext: '上下文',
|
||||
tabTools: '工具与 Skills',
|
||||
tabFiles: '文件与图片',
|
||||
tabData: '数据管理',
|
||||
tabVoice: '语音模型',
|
||||
tabSubAgents: '子智能体',
|
||||
tabReviewAgents: '审核智能体',
|
||||
@ -82,8 +83,8 @@ export default {
|
||||
defaultModelTitle: '默认模型',
|
||||
defaultModelDesc: '为新对话/登录后首次请求选择首选模型',
|
||||
modelDisabled: '已被管理员禁用',
|
||||
defaultThinkingModeTitle: '默认思考模式',
|
||||
defaultThinkingModeDesc: '登录或新建任务时的初始思考模式',
|
||||
defaultRunModeTitle: '默认运行模式',
|
||||
defaultRunModeDesc: '登录或新建任务时的初始运行模式',
|
||||
reasoningEffortTitle: '默认推理强度',
|
||||
reasoningEffortDesc: '思考模式下请求模型时附带的推理强度参数',
|
||||
runModeFast: '快速模式',
|
||||
@ -166,8 +167,7 @@ export default {
|
||||
permissionAutoApprovalDesc: '工作区内写入直通;高风险操作由后台审核智能体自动审批',
|
||||
permissionUnrestricted: '无限制',
|
||||
permissionUnrestrictedDesc: '工具按常规流程直接执行',
|
||||
defaultWorkModeTitle: '默认工作模式',
|
||||
workRunModeDesc: '新建对话时的默认工作模式;计划模式下权限锁定为只读',
|
||||
workRunModeDesc: '新建对话时的默认运行模式;计划模式下权限锁定为只读',
|
||||
workModePlan: '计划',
|
||||
workModePlanDesc: '只制定计划,批准后执行',
|
||||
workModeAsk: '询问',
|
||||
@ -331,9 +331,6 @@ export default {
|
||||
reviewAgentWorkflowReviewDesc: '工作流审核节点判断阶段产出是否达标',
|
||||
reviewModelEmptyDesc: '留空则使用模型库默认模型',
|
||||
reviewDefaultModelDesc: '使用模型库的 default_model',
|
||||
titleModelTitle: '标题生成模型',
|
||||
titleModelDesc: '选择用于生成对话标题的 AI 模型,与子智能体共用模型库',
|
||||
titleDefaultModelDesc: '使用子智能体模型库的默认模型',
|
||||
reviewThinkingDesc: '模型不支持思考时自动回落快速模式',
|
||||
timeoutTitle: '审核请求超时',
|
||||
timeoutDesc: '单次模型请求超时(5-3600 秒)',
|
||||
|
||||
@ -94,8 +94,6 @@ interface PersonalForm {
|
||||
communication_style: CommunicationStyle;
|
||||
conversation_continuity: ConversationContinuity;
|
||||
auto_generate_title: boolean;
|
||||
/** 标题生成模型(子智能体模型库条目名);留空 = 模型库 default_model */
|
||||
title_model: string;
|
||||
recent_conversations_prompt_enabled: boolean;
|
||||
recent_conversations_prompt_limit: number | string;
|
||||
project_memory_inject_limit: number | string | null;
|
||||
@ -302,7 +300,6 @@ const defaultForm = (): PersonalForm => ({
|
||||
communication_style: 'default',
|
||||
conversation_continuity: 'medium',
|
||||
auto_generate_title: true,
|
||||
title_model: '',
|
||||
recent_conversations_prompt_enabled: false,
|
||||
recent_conversations_prompt_limit: DEFAULT_RECENT_CONVERSATIONS_PROMPT_LIMIT,
|
||||
project_memory_inject_limit: DEFAULT_PROJECT_MEMORY_INJECT_LIMIT,
|
||||
@ -518,7 +515,6 @@ export const usePersonalizationStore = defineStore('personalization', {
|
||||
? data.conversation_continuity
|
||||
: 'medium',
|
||||
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_limit: this.normalizeRecentConversationsPromptLimit(
|
||||
data.recent_conversations_prompt_limit
|
||||
|
||||
@ -477,11 +477,10 @@ const TUTORIAL_STEP_DEFS: TutorialStepDef[] = [
|
||||
placement: 'right'
|
||||
},
|
||||
{
|
||||
// 用量统计已并入「常规」页签,锚点指向 general
|
||||
id: 'tab-usage',
|
||||
titleKey: 'tutorial.tabUsageTitle',
|
||||
descriptionKey: 'tutorial.tabUsageDesc',
|
||||
target: '[data-tutorial="personal-tab-general"]',
|
||||
target: '[data-tutorial="personal-tab-usage"]',
|
||||
mode: 'info',
|
||||
autoClick: true,
|
||||
placement: 'right'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user