- style.css 重写为设计系统:色板/圆角/阴影/统一滚动条/焦点环/卡片质感,main.ts 引入 - App.vue:深色渐变侧边栏替换顶部条(品牌区/图标导航/激活高亮/网关状态脚注) - 六个视图页面背景令牌化,卡片统一阴影;index.html 标题与语言修正 - 功能零改动,纯视觉层
684 lines
22 KiB
Vue
684 lines
22 KiB
Vue
<template>
|
||
<div class="agent-view">
|
||
<!-- 左:工作区文件 -->
|
||
<aside class="ws-panel">
|
||
<div class="ws-head">
|
||
<h3>📁 工作区</h3>
|
||
<button class="btn-refresh" :disabled="loadingWs" @click="refreshWs">
|
||
{{ loadingWs ? '…' : '🔄' }}
|
||
</button>
|
||
</div>
|
||
<div class="ws-root" :title="selectedRoot">{{ rootName(selectedRoot) }}</div>
|
||
<ul class="ws-list">
|
||
<li v-for="f in wsFiles" :key="f.name"
|
||
:class="{ dir: f.type === 'dir' }"
|
||
@click="f.type === 'file' && openFile(f.name)">
|
||
<span class="ws-name">{{ f.name }}</span>
|
||
<span v-if="f.type === 'file'" class="ws-size">{{ fmtSize(f.size) }}</span>
|
||
</li>
|
||
<li v-if="!wsFiles.length && !loadingWs" class="ws-empty">(空)智能体读写的文件会出现在这里</li>
|
||
</ul>
|
||
<div v-if="filePreview" class="file-preview">
|
||
<div class="fp-head">
|
||
<span class="fp-name">{{ filePreview.path }}</span>
|
||
<button class="btn-refresh" @click="filePreview = null">✕</button>
|
||
</div>
|
||
<pre class="fp-body">{{ filePreview.content }}<template v-if="filePreview.truncated">…(已截断)</template></pre>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- 右:任务与过程 -->
|
||
<main class="agent-main">
|
||
<header class="agent-head">
|
||
<h2>🤖 智能体</h2>
|
||
<p class="sub">像编程助手一样操作你选择的工作目录:列目录 / 读 / 写 / 精确编辑 / 搜索 / 执行命令,全程实时可视化。</p>
|
||
</header>
|
||
|
||
<!-- 工作区选择栏 -->
|
||
<div class="ws-bar">
|
||
<span class="ws-current" :title="selectedRoot">📁 {{ rootName(selectedRoot) || '未选择' }}</span>
|
||
<button class="btn-refresh" @click="openPicker">选择目录</button>
|
||
<label class="shell-toggle" title="开启后智能体可执行 shell 命令(run_command),在工作区内运行">
|
||
<input type="checkbox" v-model="allowShell" @change="toggleShell" />
|
||
允许执行命令
|
||
</label>
|
||
</div>
|
||
|
||
<div class="task-bar">
|
||
<select v-model="selectedPoolId" class="model-select">
|
||
<option value="">默认模型(模型池 Agent 角色 / Architect 设置)</option>
|
||
<option v-for="e in agentModels" :key="e.id" :value="e.id">{{ e.name }}({{ e.model }})</option>
|
||
</select>
|
||
<select v-model="selectedExecutorId" class="model-select executor-select"
|
||
title="两级模式:大模型拆解/审查,本地小模型执行工具轮">
|
||
<option value="">单模型模式(规划者全程包办)</option>
|
||
<option v-for="e in executorCandidates" :key="e.id" :value="e.id">
|
||
🔧 执行者:{{ e.name }}({{ e.model }})
|
||
</option>
|
||
</select>
|
||
</div>
|
||
<div class="task-bar">
|
||
<textarea v-model="task" class="task-input" rows="3"
|
||
placeholder="给智能体一个任务,例如:浏览这个项目,找到入口文件并在 README 里补充运行说明"
|
||
:disabled="running" />
|
||
<button class="btn-run" :disabled="running || !task.trim()" @click="run">
|
||
{{ running ? '运行中…' : '▶ 运行' }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="statusLine" :class="['status-line', statusClass]">{{ statusLine }}</div>
|
||
|
||
<!-- 过程时间轴 -->
|
||
<div class="timeline" ref="timelineEl">
|
||
<template v-for="(ev, i) in events" :key="i">
|
||
<div v-if="ev.type === 'round'" class="ev-round">— 第 {{ ev.round }} 轮 —</div>
|
||
|
||
<div v-else-if="ev.type === 'phase'" class="ev-phase" :class="'ph-' + ev.phase">
|
||
{{ phaseLabel(ev) }}
|
||
</div>
|
||
|
||
<div v-else-if="ev.type === 'message'" class="ev-msg">
|
||
<div class="ev-title">
|
||
{{ ev.role === 'planner' ? '🧠 规划者' : '🔧 执行者' }}{{ ev.handoff ? `(第 ${ev.handoff} 轮交接)` : '' }}
|
||
</div>
|
||
<pre class="ev-msgbody" :class="ev.role === 'planner' ? 'msg-planner' : 'msg-executor'">{{ ev.content }}</pre>
|
||
</div>
|
||
|
||
<div v-else-if="ev.type === 'tool_call' && ev.name === 'edit_file'" class="ev-card">
|
||
<div class="ev-title">✏️ 精确编辑 <b>{{ (ev.arguments as any)?.path }}</b></div>
|
||
<pre class="diff-old">- {{ (ev.arguments as any)?.old_string }}</pre>
|
||
<pre class="diff-new">+ {{ (ev.arguments as any)?.new_string }}</pre>
|
||
</div>
|
||
|
||
<div v-else-if="ev.type === 'tool_call' && ev.name === 'run_command'" class="ev-card">
|
||
<div class="ev-title">⌨️ 执行命令</div>
|
||
<pre class="ev-args">$ {{ (ev.arguments as any)?.command }}</pre>
|
||
</div>
|
||
|
||
<div v-else-if="ev.type === 'tool_call'" class="ev-card">
|
||
<div class="ev-title">🛠️ 调用工具 <b>{{ ev.name }}</b></div>
|
||
<pre class="ev-args">{{ pretty(ev.arguments) }}</pre>
|
||
</div>
|
||
|
||
<div v-else-if="ev.type === 'tool_result'" class="ev-card"
|
||
:class="ev.ok ? 'res-ok' : 'res-err'">
|
||
<div class="ev-title">{{ ev.ok ? '✅' : '❌' }} 结果({{ ev.name }})</div>
|
||
<pre class="ev-args">{{ short(ev.preview) }}</pre>
|
||
</div>
|
||
|
||
<div v-else-if="ev.type === 'usage'" class="ev-usage">
|
||
tokens:{{ ev.prompt_tokens }} 入 / {{ ev.completion_tokens }} 出
|
||
</div>
|
||
|
||
<div v-else-if="ev.type === 'final'" class="ev-final">
|
||
<div class="ev-title">
|
||
{{ ev.reason === 'answer' ? '🏁 最终答复' : '⚠️ 结束(' + reasonLabel(ev.reason) + ')' }}
|
||
</div>
|
||
<pre v-if="finalResponse" class="ev-answer">{{ finalResponse }}</pre>
|
||
<div v-if="ev.error" class="ev-error">{{ ev.error }}</div>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-if="running" class="ev-round">⏳ 智能体正在工作…</div>
|
||
<div v-if="!events.length && !running" class="empty-hint">
|
||
选择工作目录,输入任务并运行——每一步工具调用都会实时显示在这里。
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- 目录选择器模态 -->
|
||
<div v-if="pickerOpen" class="modal-mask" @click.self="pickerOpen = false">
|
||
<div class="modal">
|
||
<div class="modal-title">选择工作目录</div>
|
||
<div class="crumb">{{ browse.path || '(此电脑 — 点击选择盘符)' }}</div>
|
||
<div class="dir-list">
|
||
<div v-if="browse.parent" class="dir-item up" @click="gotoDir(browse.parent)">
|
||
↰ 上级:{{ browse.parent }}
|
||
</div>
|
||
<div v-for="d in browse.dirs" :key="d" class="dir-item" @click="gotoDir(joinDir(browse.path, d))">
|
||
📂 {{ d }}
|
||
</div>
|
||
<div v-if="!browse.dirs.length" class="dir-item empty">(无子目录——可直接点下方"打开此目录")</div>
|
||
</div>
|
||
<div class="path-row">
|
||
<input v-model="manualPath" placeholder="或直接输入绝对路径,如 E:\my-project" />
|
||
<label class="create-toggle">
|
||
<input type="checkbox" v-model="createIfMissing" /> 不存在则新建
|
||
</label>
|
||
</div>
|
||
<div v-if="recent.length" class="recent-row">
|
||
<span class="recent-label">最近:</span>
|
||
<button v-for="w in recent" :key="w" class="recent-chip" :title="w"
|
||
@click="chooseDir(w)">{{ rootName(w) }}</button>
|
||
</div>
|
||
<div v-if="pickerMsg" class="picker-msg">{{ pickerMsg }}</div>
|
||
<div class="modal-actions">
|
||
<button class="btn-primary" @click="chooseDir(manualPath.trim() || browse.path)">
|
||
打开此目录
|
||
</button>
|
||
<button class="btn-secondary" @click="pickerOpen = false">取消</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, nextTick, onMounted } from 'vue'
|
||
import {
|
||
startAgent, getAgentStatus, watchAgent,
|
||
listAgentWorkspace, readAgentFile, getPool,
|
||
browseFs, getAgentWorkspaces, openWorkspace, updateConfig,
|
||
} from '@/api'
|
||
import type { AgentEvent, PoolEntry, FsBrowse } from '@/api'
|
||
|
||
const task = ref('')
|
||
const running = ref(false)
|
||
const events = ref<AgentEvent[]>([])
|
||
const statusInfo = ref<{ state: string; error?: string | null } | null>(null)
|
||
const selectedPoolId = ref('')
|
||
const poolEntries = ref<PoolEntry[]>([])
|
||
|
||
// 工作区选择
|
||
const selectedRoot = ref('')
|
||
const recent = ref<string[]>([])
|
||
const pickerOpen = ref(false)
|
||
const pickerMsg = ref('')
|
||
const browse = ref<FsBrowse>({ ok: true, path: '', parent: '', dirs: [] })
|
||
const manualPath = ref('')
|
||
const createIfMissing = ref(false)
|
||
const allowShell = ref(false)
|
||
|
||
// 两级模式
|
||
const selectedExecutorId = ref('')
|
||
|
||
const wsFiles = ref<{ name: string; type: string; size?: number }[]>([])
|
||
const loadingWs = ref(false)
|
||
const filePreview = ref<{ path: string; content: string; truncated: boolean } | null>(null)
|
||
const timelineEl = ref<HTMLElement | null>(null)
|
||
|
||
let _watch: ReturnType<typeof watchAgent> | null = null
|
||
|
||
const agentModels = computed(() => poolEntries.value.filter(e => e.enabled))
|
||
|
||
const executorCandidates = computed(() =>
|
||
poolEntries.value
|
||
.filter(e => e.enabled && e.backend !== 'mock')
|
||
.sort((a, b) => (a.backend === 'llama_server' ? -1 : 0) - (b.backend === 'llama_server' ? -1 : 0)))
|
||
|
||
function phaseLabel(ev: AgentEvent) {
|
||
const m = ev.model ? ` · ${ev.model}` : ''
|
||
if (ev.phase === 'plan') return `🧠 规划者 · 拆解任务${m}`
|
||
if (ev.phase === 'execute') return `🔧 执行者 · 工具执行(第 ${ev.handoff} 轮交接)${m}`
|
||
return `🔍 规划者 · 审查裁决${m}`
|
||
}
|
||
|
||
const finalResponse = computed(() => ((statusInfo.value as any)?.response || ''))
|
||
|
||
const statusLine = computed(() => {
|
||
if (running.value) return '● 运行中'
|
||
const s = statusInfo.value
|
||
if (!s) return ''
|
||
if (s.state === 'done') return '✅ 已完成'
|
||
if (s.state === 'failed') return '❌ 失败' + (s.error ? ':' + s.error : '')
|
||
return ''
|
||
})
|
||
const statusClass = computed(() => {
|
||
if (running.value) return 'st-running'
|
||
return statusInfo.value?.state === 'done' ? 'st-ok' : 'st-err'
|
||
})
|
||
|
||
function reasonLabel(r?: string) {
|
||
return ({ max_rounds: '轮次达上限', token_cap: 'token 熔断', error: '出错' } as Record<string, string>)[r || ''] || r
|
||
}
|
||
function pretty(o: unknown) { return JSON.stringify(o, null, 2) ?? '' }
|
||
function short(s?: string) { return (s || '').length > 600 ? s!.slice(0, 600) + '…' : (s || '') }
|
||
function fmtSize(n?: number) {
|
||
if (n == null) return ''
|
||
if (n >= 1e6) return (n / 1e6).toFixed(1) + ' MB'
|
||
if (n >= 1e3) return (n / 1e3).toFixed(1) + ' KB'
|
||
return n + ' B'
|
||
}
|
||
function rootName(p: string) {
|
||
if (!p) return ''
|
||
const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/)
|
||
return parts[parts.length - 1] || p
|
||
}
|
||
function joinDir(p: string, d: string) {
|
||
if (!p) return d
|
||
const sep = p.includes('\\') ? '\\' : '/'
|
||
return p.endsWith('\\') || p.endsWith('/') ? p + d : p + sep + d
|
||
}
|
||
|
||
function scrollToBottom() {
|
||
nextTick(() => timelineEl.value?.scrollTo({ top: timelineEl.value.scrollHeight }))
|
||
}
|
||
|
||
async function refreshWs() {
|
||
loadingWs.value = true
|
||
try {
|
||
const r = await listAgentWorkspace('', selectedRoot.value)
|
||
wsFiles.value = r.entries || []
|
||
} catch { wsFiles.value = [] } finally { loadingWs.value = false }
|
||
}
|
||
|
||
async function openFile(name: string) {
|
||
try {
|
||
const r = await readAgentFile(name, selectedRoot.value)
|
||
if (r.ok) filePreview.value = { path: name, content: r.content, truncated: r.truncated }
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
// ── 目录选择器 ──────────────────────────────────────────────────────────
|
||
async function openPicker() {
|
||
pickerOpen.value = true
|
||
pickerMsg.value = ''
|
||
manualPath.value = selectedRoot.value
|
||
try {
|
||
browse.value = await browseFs(selectedRoot.value && '')
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function gotoDir(p: string) {
|
||
try {
|
||
const r = await browseFs(p)
|
||
if (r.ok) { browse.value = r; manualPath.value = r.path }
|
||
else pickerMsg.value = r.error || '路径不可用'
|
||
} catch { pickerMsg.value = '浏览失败' }
|
||
}
|
||
|
||
async function chooseDir(p: string) {
|
||
p = (p || '').trim()
|
||
if (!p) { pickerMsg.value = '请先选择或输入目录路径'; return }
|
||
try {
|
||
const r = await openWorkspace(p, createIfMissing.value)
|
||
selectedRoot.value = r.current
|
||
recent.value = r.recent
|
||
pickerOpen.value = false
|
||
refreshWs()
|
||
} catch (e: any) {
|
||
pickerMsg.value = e?.response?.data?.detail || e?.message || String(e)
|
||
}
|
||
}
|
||
|
||
async function toggleShell() {
|
||
try {
|
||
await updateConfig({ agent: { allow_shell: allowShell.value } } as any)
|
||
} catch { allowShell.value = !allowShell.value } // 失败回滚
|
||
}
|
||
|
||
// ── 运行 ────────────────────────────────────────────────────────────────
|
||
async function run() {
|
||
if (!task.value.trim() || running.value) return
|
||
running.value = true
|
||
events.value = []
|
||
statusInfo.value = null
|
||
try {
|
||
const init = await startAgent(task.value.trim(), selectedPoolId.value, selectedRoot.value,
|
||
selectedExecutorId.value)
|
||
if (init.workspace) selectedRoot.value = init.workspace
|
||
_watch = watchAgent(init.request_id)
|
||
_watch.subscribe({
|
||
onEvent: (ev) => {
|
||
events.value.push(ev)
|
||
scrollToBottom()
|
||
},
|
||
onDone: async () => {
|
||
running.value = false
|
||
try { statusInfo.value = await getAgentStatus(init.request_id) } catch { /* ignore */ }
|
||
refreshWs()
|
||
},
|
||
})
|
||
} catch (e: any) {
|
||
running.value = false
|
||
statusInfo.value = { state: 'failed', error: e?.response?.data?.detail || e?.message || String(e) }
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
refreshWs()
|
||
try {
|
||
const [pool, ws] = await Promise.all([getPool(), getAgentWorkspaces()])
|
||
poolEntries.value = pool.entries
|
||
selectedPoolId.value = pool.roles.agent || ''
|
||
selectedRoot.value = ws.current || ''
|
||
recent.value = ws.recent || []
|
||
refreshWs()
|
||
} catch { /* ignore */ }
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.agent-view {
|
||
display: flex;
|
||
height: 100%;
|
||
background: var(--c-bg);
|
||
}
|
||
|
||
/* 左侧工作区 */
|
||
.ws-panel {
|
||
width: 260px;
|
||
flex-shrink: 0;
|
||
border-right: 1px solid #e5e7eb;
|
||
background: #fff;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.ws-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid #f3f4f6;
|
||
}
|
||
.ws-head h3 { font-size: 14px; color: #1f2937; }
|
||
.ws-root {
|
||
padding: 6px 14px;
|
||
font-size: 11px;
|
||
color: #6b7280;
|
||
background: var(--c-bg);
|
||
border-bottom: 1px solid #f3f4f6;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
direction: rtl; /* 长路径优先显示末尾(项目名) */
|
||
text-align: left;
|
||
}
|
||
.ws-list { list-style: none; overflow-y: auto; flex: 1; }
|
||
.ws-list li {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 6px 14px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
color: #374151;
|
||
}
|
||
.ws-list li:hover { background: #eff6ff; }
|
||
.ws-list li.dir { color: #92400e; font-weight: 500; cursor: default; }
|
||
.ws-empty { color: #9ca3af; cursor: default; font-size: 12px; }
|
||
.ws-empty:hover { background: transparent; }
|
||
.ws-size { color: #9ca3af; font-size: 11px; }
|
||
|
||
.file-preview {
|
||
border-top: 1px solid #e5e7eb;
|
||
max-height: 45%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.fp-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8px 14px;
|
||
background: var(--c-bg);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #374151;
|
||
}
|
||
.fp-body {
|
||
margin: 0;
|
||
padding: 10px 14px;
|
||
overflow: auto;
|
||
font-size: 12px;
|
||
font-family: ui-monospace, Consolas, monospace;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
color: #111827;
|
||
}
|
||
|
||
/* 右侧主区 */
|
||
.agent-main { flex: 1; display: flex; flex-direction: column; padding: 20px 24px; min-width: 0; }
|
||
.agent-head { margin-bottom: 10px; }
|
||
.agent-head h2 { font-size: 20px; font-weight: 700; color: #111827; }
|
||
.sub { font-size: 13px; color: #6b7280; margin-top: 2px; }
|
||
|
||
.ws-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 10px;
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
padding: 8px 12px;
|
||
}
|
||
.ws-current {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #1e40af;
|
||
max-width: 420px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.shell-toggle {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: #6b7280;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.task-bar { display: flex; gap: 10px; margin-bottom: 10px; align-items: stretch; }
|
||
.model-select {
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
padding: 7px 10px;
|
||
font-size: 13px;
|
||
background: #fff;
|
||
max-width: 420px;
|
||
}
|
||
.task-input {
|
||
flex: 1;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 8px;
|
||
padding: 10px 12px;
|
||
font-size: 14px;
|
||
font-family: inherit;
|
||
resize: vertical;
|
||
outline: none;
|
||
}
|
||
.task-input:focus { border-color: #2563eb; box-shadow: 0 0 0 2px #2563eb26; }
|
||
.btn-run {
|
||
color: #fff;
|
||
background: #2563eb;
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 0 22px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
align-self: stretch;
|
||
}
|
||
.btn-run:hover:not(:disabled) { background: #1d4ed8; }
|
||
.btn-run:disabled { background: #93c5fd; cursor: not-allowed; }
|
||
|
||
.status-line { font-size: 13px; font-weight: 600; margin-bottom: 10px; }
|
||
.st-running { color: #2563eb; }
|
||
.st-ok { color: #16a34a; }
|
||
.st-err { color: #dc2626; }
|
||
|
||
.timeline {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 10px;
|
||
padding: 16px;
|
||
}
|
||
.ev-round {
|
||
text-align: center;
|
||
color: #9ca3af;
|
||
font-size: 12px;
|
||
margin: 10px 0;
|
||
}
|
||
.ev-phase {
|
||
text-align: center;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
border-radius: 12px;
|
||
padding: 4px 14px;
|
||
margin: 12px auto 8px;
|
||
width: fit-content;
|
||
max-width: 90%;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.ph-plan { color: #1e40af; background: #dbeafe; }
|
||
.ph-execute { color: #166534; background: #dcfce7; }
|
||
.ph-review { color: #7c2d12; background: #ffedd5; }
|
||
.ev-msg { margin-bottom: 8px; }
|
||
.ev-msgbody {
|
||
margin: 4px 0 0;
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
max-height: 260px;
|
||
overflow-y: auto;
|
||
}
|
||
.msg-planner { background: #eff6ff; color: #1e3a8a; border: 1px solid #bfdbfe; }
|
||
.msg-executor { background: #f0fdf4; color: #14532d; border: 1px solid #bbf7d0; }
|
||
.executor-select { flex: 1; max-width: 340px; }
|
||
.ev-card {
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
padding: 10px 12px;
|
||
margin-bottom: 8px;
|
||
background: #fafafa;
|
||
}
|
||
.ev-card.res-ok { border-left: 3px solid #16a34a; }
|
||
.ev-card.res-err { border-left: 3px solid #dc2626; }
|
||
.ev-title { font-size: 13px; color: #1f2937; margin-bottom: 6px; }
|
||
.ev-args {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
font-family: ui-monospace, Consolas, monospace;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
color: #374151;
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
}
|
||
.diff-old, .diff-new {
|
||
margin: 0 0 4px;
|
||
padding: 6px 10px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-family: ui-monospace, Consolas, monospace;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
max-height: 140px;
|
||
overflow-y: auto;
|
||
}
|
||
.diff-old { background: #fef2f2; color: #b91c1c; }
|
||
.diff-new { background: #f0fdf4; color: #15803d; }
|
||
.ev-usage { text-align: right; color: #9ca3af; font-size: 11px; margin: 4px 0; }
|
||
.ev-final {
|
||
border: 1px solid #bfdbfe;
|
||
background: #eff6ff;
|
||
border-radius: 8px;
|
||
padding: 12px 14px;
|
||
margin-top: 10px;
|
||
}
|
||
.ev-answer {
|
||
margin: 6px 0 0;
|
||
font-size: 14px;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
color: #111827;
|
||
font-family: inherit;
|
||
}
|
||
.ev-error { color: #dc2626; font-size: 13px; margin-top: 6px; }
|
||
.empty-hint { text-align: center; color: #9ca3af; font-size: 13px; margin-top: 40px; }
|
||
|
||
/* 目录选择器模态 */
|
||
.modal-mask {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: #0006;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 100;
|
||
}
|
||
.modal {
|
||
width: 620px;
|
||
max-width: 92vw;
|
||
max-height: 80vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
padding: 18px;
|
||
box-shadow: 0 10px 40px #0003;
|
||
}
|
||
.modal-title { font-size: 16px; font-weight: 700; color: #111827; margin-bottom: 10px; }
|
||
.crumb {
|
||
font-size: 12px;
|
||
color: #6b7280;
|
||
background: var(--c-bg);
|
||
border: 1px solid #f3f4f6;
|
||
border-radius: 6px;
|
||
padding: 6px 10px;
|
||
margin-bottom: 8px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.dir-list {
|
||
flex: 1;
|
||
min-height: 200px;
|
||
max-height: 320px;
|
||
overflow-y: auto;
|
||
border: 1px solid #f3f4f6;
|
||
border-radius: 6px;
|
||
}
|
||
.dir-item {
|
||
padding: 7px 12px;
|
||
font-size: 13px;
|
||
color: #374151;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.dir-item:hover { background: #eff6ff; }
|
||
.dir-item.up { color: #2563eb; font-weight: 500; }
|
||
.dir-item.empty { color: #9ca3af; cursor: default; }
|
||
.dir-item.empty:hover { background: transparent; }
|
||
.path-row { display: flex; gap: 10px; align-items: center; margin-top: 10px; }
|
||
.path-row input[type="text"], .path-row input:not([type]) {
|
||
flex: 1;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
padding: 7px 10px;
|
||
font-size: 13px;
|
||
}
|
||
.create-toggle { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #6b7280; cursor: pointer; white-space: nowrap; }
|
||
.recent-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 10px; }
|
||
.recent-label { font-size: 12px; color: #9ca3af; }
|
||
.recent-chip {
|
||
border: 1px solid #e5e7eb;
|
||
background: var(--c-bg);
|
||
border-radius: 12px;
|
||
padding: 2px 10px;
|
||
font-size: 12px;
|
||
color: #374151;
|
||
cursor: pointer;
|
||
max-width: 180px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.recent-chip:hover { background: #eff6ff; border-color: #bfdbfe; }
|
||
.picker-msg { color: #d97706; font-size: 12px; margin-top: 8px; }
|
||
.modal-actions { display: flex; gap: 10px; margin-top: 14px; }
|
||
</style>
|