feat(v3): T29 收尾 + T31 dsh 功能对齐

T29 流式收尾:
- _stream_partial 每次流式调用前复位(防上次成功置位导致本次失败误抛不回退)

T31 dsh(deepseek-harness)功能对齐:
- OpenAICompatChat 重试退避:传输错误/408/429/5xx 指数退避(max_retries=2),4xx 不重试
- web_fetch 工具:公网 http/https 抓取,SSRF 防护(DNS 后拒绝私网/环回/链路本地/NAT64,
  512KB/15s/12k 上限,二进制嗅探拒绝),agent.allow_net 开关(默认开)
- 原子写入:write_file/edit_file 临时文件 + os.replace(Windows EPERM 退避)
- 慢工具线程卸载:run_command/web_fetch/search_files 独立线程 + asyncio.sleep 轮询
- search_files:os.walk 修剪依赖目录(替代 rglob 全量物化),不跟随符号链接
- run_command:危险命令黑名单独立拦截 + 显式 COMSPEC/sh 解释器执行
- 重复调用提醒:同工具同参数第 3 次起回喂系统提示 + repeat_warning 事件
- 会话重命名:PATCH /agent/sessions/{sid} + 前端 ✎
- 前端:SettingsView 适配密钥打码(留空保留),SPA 重新构建
- 新增 tests/test_agent_features.py(9 项);全量 318 测试通过
This commit is contained in:
tzt
2026-09-02 00:02:24 +08:00
parent ce3ba44de3
commit 747d85c3ba
18 changed files with 356 additions and 26 deletions
+20 -5
View File
@@ -12,6 +12,7 @@
<span>{{ turnsOf(s) }} </span>
<span>·</span>
<span>{{ fmtTime(s.updated_at) }}</span>
<span class="sess-rename" title="重命名会话" @click.stop="renameSession(s.id)"></span>
<span class="sess-del" title="删除会话" @click.stop="removeSession(s.id)"></span>
</div>
</div>
@@ -180,7 +181,8 @@ import {
startAgent, getAgentStatus, watchAgent, approveAgent, getConfig, updateConfig,
getPool,
browseFs, getAgentWorkspaces, openWorkspace,
listAgentSessions, createAgentSession, getAgentSession, deleteAgentSession, cancelAgent,
listAgentSessions, createAgentSession, getAgentSession, deleteAgentSession,
renameAgentSession, cancelAgent,
} from '@/api'
import type { AgentEvent, PoolEntry, FsBrowse } from '@/api'
@@ -305,6 +307,16 @@ async function removeSession(sid: string) {
refreshSessions()
}
async function renameSession(sid: string) {
const cur = sessions.value.find(s => s.id === sid)
const title = prompt('新的会话名称:', cur?.title || '')
if (title === null) return // 取消
const trimmed = title.trim()
if (!trimmed) { alert('名称不能为空'); return }
await renameAgentSession(sid, trimmed)
refreshSessions()
}
// ── 工作区 ──────────────────────────────────────────────────────────────
async function openPicker() {
pickerOpen.value = true
@@ -496,16 +508,19 @@ onMounted(async () => {
margin-top: 3px;
align-items: center;
}
.sess-del {
.sess-del,
.sess-rename {
margin-left: auto;
color: var(--c-caption);
opacity: 0;
cursor: pointer;
padding: 0 3px;
cursor: pointer; padding: 0 3px;
border-radius: 4px;
}
.sess-item:hover .sess-del { opacity: 1; }
.sess-rename { margin-left: 0; }
.sess-item:hover .sess-del,
.sess-item:hover .sess-rename { opacity: 1; }
.sess-del:hover { color: var(--c-err); background: var(--c-err-soft); }
.sess-rename:hover { color: var(--c-accent, #2563eb); }
.sess-empty { color: var(--c-caption); font-size: 12px; text-align: center; padding: 20px 0; }
/* ---------- 对话区 ---------- */
+16 -6
View File
@@ -331,9 +331,10 @@
<!-- API Key -->
<div class="field full-width">
<label>API Key保存在本地 config/settings.json不入代码库优先级高于 .env</label>
<label>API Key{{ form.architect.api_key_set ? '(已设置,留空保留)' : '(保存在本地 config/settings.json,不入代码库;优先级高于 .env)' }}</label>
<input v-model="form.architect.api_key" type="password"
placeholder="sk-xxxxxxxxxxxxxxxx" autocomplete="off" />
:placeholder="form.architect.api_key_set ? '已设置,留空保留原值' : 'sk-xxxxxxxxxxxxxxxx'"
autocomplete="off" />
</div>
<!-- 模型选择 -->
@@ -867,6 +868,15 @@ function _safeAssign(target: any, source: any) {
}
}
/** architect 段专用填充:api_key 服务端打码返回,已设置时清空输入框(留空=保留)。 */
function _applyArchitect(arch: any) {
if (!arch) return
const { api_key, api_key_set, ...rest } = arch
_safeAssign(form.architect, rest)
form.architect.api_key_set = Boolean(api_key_set)
form.architect.api_key = '' // 不把打码值带回表单,防止保存时覆盖真值
}
async function load() {
loadError.value = ''
try {
@@ -880,7 +890,7 @@ async function load() {
// 安全填充表单(防止 API 返回 null 导致响应式丢失)
if (cfg.worker) _safeAssign(form.worker, cfg.worker)
if (cfg.architect) _safeAssign(form.architect, cfg.architect)
if (cfg.architect) _applyArchitect(cfg.architect)
if (cfg.pipeline) _safeAssign(form.pipeline, cfg.pipeline)
// 保存原始快照
@@ -914,9 +924,9 @@ async function save() {
// 空 api_key 不传给后端,保留服务端已有值
if (!patch.architect.api_key) delete patch.architect.api_key
const merged = await updateConfig(patch)
// 用安全方式更新(保留响应式)
// 用安全方式更新(保留响应式)architect 段走打码适配
_safeAssign(form.worker, merged.worker || {})
_safeAssign(form.architect, merged.architect || {})
_applyArchitect(merged.architect || {})
_safeAssign(form.pipeline, merged.pipeline || {})
original.value = JSON.parse(JSON.stringify(form))
saveMsg.value = { ok: true, msg: '✅ 设置已保存,管线已重建' }
@@ -935,7 +945,7 @@ async function reset() {
try {
const merged = await resetConfig()
_safeAssign(form.worker, merged.worker || {})
_safeAssign(form.architect, merged.architect || {})
_applyArchitect(merged.architect || {})
_safeAssign(form.pipeline, merged.pipeline || {})
original.value = JSON.parse(JSON.stringify(form))
workerModels.value = []