feat(frontend): 优化聊天界面与监控交互体验

This commit is contained in:
JOJO 2026-04-10 14:34:18 +08:00
parent 8be1c37b6a
commit 4124faa040
55 changed files with 4128 additions and 2018 deletions

View File

@ -116,11 +116,7 @@
:format-rate="formatRate" :format-rate="formatRate"
/> />
<div <div v-if="titleRibbonVisible" class="conversation-ribbon" ref="titleRibbon">
v-if="titleRibbonVisible"
class="conversation-ribbon"
ref="titleRibbon"
>
<button <button
type="button" type="button"
class="conversation-ribbon__selector" class="conversation-ribbon__selector"
@ -135,18 +131,23 @@
<span class="selector-mode">{{ headerRunModeLabel }}</span> <span class="selector-mode">{{ headerRunModeLabel }}</span>
</span> </span>
<svg viewBox="0 0 24 24" aria-hidden="true" class="selector-caret"> <svg viewBox="0 0 24 24" aria-hidden="true" class="selector-caret">
<path d="M7 10l5 5 5-5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /> <path
d="M7 10l5 5 5-5"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
</button> </button>
<span class="conversation-ribbon__text">{{ titleTypingText || currentConversationTitle }}</span> <span class="conversation-ribbon__text">{{
titleTypingText || currentConversationTitle
}}</span>
<transition name="header-menu"> <transition name="header-menu">
<div <div v-if="headerMenuOpen" class="model-mode-dropdown" ref="headerMenu">
v-if="headerMenuOpen"
class="model-mode-dropdown"
ref="headerMenu"
>
<div class="dropdown-column" data-tutorial="header-model-options"> <div class="dropdown-column" data-tutorial="header-model-options">
<div class="dropdown-title">模型</div> <div class="dropdown-title">模型</div>
<div class="dropdown-list dropdown-list--models"> <div class="dropdown-list dropdown-list--models">
@ -204,7 +205,7 @@
:format-search-time="formatSearchTime" :format-search-time="formatSearchTime"
:format-search-domains="formatSearchDomains" :format-search-domains="formatSearchDomains"
/> />
<VirtualMonitorSurface v-show="chatDisplayMode === 'monitor'" /> <VirtualMonitorSurface v-if="chatDisplayMode === 'monitor'" />
<div v-if="blankHeroActive" class="blank-hero-overlay"> <div v-if="blankHeroActive" class="blank-hero-overlay">
<span class="icon icon-lg" :style="iconStyle('bot')" aria-hidden="true"></span> <span class="icon icon-lg" :style="iconStyle('bot')" aria-hidden="true"></span>
@ -227,7 +228,13 @@
<path d="M8 11h8a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z" /> <path d="M8 11h8a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z" />
<path d="M9 11V8a3 3 0 0 1 6 0v3" /> <path d="M9 11V8a3 3 0 0 1 6 0v3" />
</svg> </svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true" stroke-linecap="round" stroke-linejoin="round"> <svg
v-else
viewBox="0 0 24 24"
aria-hidden="true"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 5v12" /> <path d="M12 5v12" />
<path d="M7 13l5 5 5-5" /> <path d="M7 13l5 5 5-5" />
</svg> </svg>
@ -298,10 +305,14 @@
</div> </div>
</main> </main>
<div v-if="!isMobileViewport" class="resize-handle" @mousedown="startResize('right', $event)"></div> <div
v-if="!isMobileViewport"
class="resize-handle"
@mousedown="startResize('right', $event)"
></div>
</div> </div>
<PersonalizationDrawer /> <PersonalizationDrawer v-if="personalizationStore.visible" />
<transition name="overlay-fade"> <transition name="overlay-fade">
<ImagePicker <ImagePicker
v-if="imagePickerOpen" v-if="imagePickerOpen"
@ -354,7 +365,7 @@
</transition> </transition>
<SubAgentActivityDialog /> <SubAgentActivityDialog />
<BackgroundCommandDialog /> <BackgroundCommandDialog />
<TutorialOverlay /> <TutorialOverlay v-if="tutorialStore.running" />
<NewUserTutorialPrompt <NewUserTutorialPrompt
:visible="tutorialPromptVisible" :visible="tutorialPromptVisible"
:username="tutorialPromptUsername" :username="tutorialPromptUsername"
@ -370,7 +381,13 @@
ref="mobilePanelTrigger" ref="mobilePanelTrigger"
> >
<div class="mobile-panel-topbar"> <div class="mobile-panel-topbar">
<button type="button" class="mobile-panel-fab" data-tutorial="mobile-menu-trigger" aria-label="切换工作区" @click="toggleMobileOverlayMenu"> <button
type="button"
class="mobile-panel-fab"
data-tutorial="mobile-menu-trigger"
aria-label="切换工作区"
@click="toggleMobileOverlayMenu"
>
<img :src="mobilePanelIcon" alt="" aria-hidden="true" /> <img :src="mobilePanelIcon" alt="" aria-hidden="true" />
</button> </button>
<button <button
@ -409,8 +426,18 @@
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
/> />
<path d="M8 8.5h8" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" /> <path
<path d="M8 11.5h5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" /> d="M8 8.5h8"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
/>
<path
d="M8 11.5h5"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
/>
</svg> </svg>
<div class="item-label">对话记录</div> <div class="item-label">对话记录</div>
</button> </button>
@ -421,7 +448,12 @@
aria-label="工作文件" aria-label="工作文件"
@click="openMobileOverlay('workspace')" @click="openMobileOverlay('workspace')"
> >
<img class="mobile-menu-icon" :src="mobileMenuIcons.workspace" alt="" aria-hidden="true" /> <img
class="mobile-menu-icon"
:src="mobileMenuIcons.workspace"
alt=""
aria-hidden="true"
/>
<div class="item-label">工作文件</div> <div class="item-label">工作文件</div>
</button> </button>
<button <button
@ -431,7 +463,12 @@
aria-label="个人空间" aria-label="个人空间"
@click="handleMobilePersonalClick" @click="handleMobilePersonalClick"
> >
<img class="mobile-menu-icon" :src="mobileMenuIcons.personal" alt="" aria-hidden="true" /> <img
class="mobile-menu-icon"
:src="mobileMenuIcons.personal"
alt=""
aria-hidden="true"
/>
<div class="item-label">个人空间</div> <div class="item-label">个人空间</div>
</button> </button>
<button <button
@ -441,7 +478,12 @@
aria-label="新建对话" aria-label="新建对话"
@click="createNewConversation" @click="createNewConversation"
> >
<img class="mobile-menu-icon" :src="mobileMenuIcons.newChat" alt="" aria-hidden="true" /> <img
class="mobile-menu-icon"
:src="mobileMenuIcons.newChat"
alt=""
aria-hidden="true"
/>
<div class="item-label">新建对话</div> <div class="item-label">新建对话</div>
</button> </button>
<button <button
@ -451,7 +493,12 @@
aria-label="刷新页面" aria-label="刷新页面"
@click="handleMobileRefreshClick" @click="handleMobileRefreshClick"
> >
<img class="mobile-menu-icon" :src="mobileMenuIcons.refresh" alt="" aria-hidden="true" /> <img
class="mobile-menu-icon"
:src="mobileMenuIcons.refresh"
alt=""
aria-hidden="true"
/>
<div class="item-label">刷新页面</div> <div class="item-label">刷新页面</div>
</button> </button>
</div> </div>
@ -537,7 +584,13 @@
@click.self="closeMobileOverlay" @click.self="closeMobileOverlay"
> >
<div class="mobile-panel-sheet mobile-panel-sheet--workspace"> <div class="mobile-panel-sheet mobile-panel-sheet--workspace">
<button type="button" class="mobile-overlay-close" data-tutorial="mobile-workspace-close" aria-label="关闭面板" @click="closeMobileOverlay"> <button
type="button"
class="mobile-overlay-close"
data-tutorial="mobile-workspace-close"
aria-label="关闭面板"
@click="closeMobileOverlay"
>
× ×
</button> </button>
<LeftPanel <LeftPanel
@ -561,14 +614,23 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineAsyncComponent } from 'vue';
import appOptions from './app'; import appOptions from './app';
import VirtualMonitorSurface from './components/chat/VirtualMonitorSurface.vue';
import VideoPicker from './components/overlay/VideoPicker.vue'; import VideoPicker from './components/overlay/VideoPicker.vue';
import { usePersonalizationStore } from './stores/personalization';
import { useTutorialStore } from './stores/tutorial';
const VirtualMonitorSurface = defineAsyncComponent(
() => import('./components/chat/VirtualMonitorSurface.vue')
);
const personalizationStore = usePersonalizationStore();
const tutorialStore = useTutorialStore();
const mobilePanelIcon = new URL('../icons/align-left.svg', import.meta.url).href; const mobilePanelIcon = new URL('../icons/align-left.svg', import.meta.url).href;
const isAppShell = const isAppShell =
typeof window !== 'undefined' && typeof window !== 'undefined' &&
(new URLSearchParams(window.location.search).has('app_shell') || Boolean((window as any)?.AndroidThemeBridge)); (new URLSearchParams(window.location.search).has('app_shell') ||
Boolean((window as any)?.AndroidThemeBridge));
const mobileMenuIcons = { const mobileMenuIcons = {
workspace: new URL('../icons/folder.svg', import.meta.url).href, workspace: new URL('../icons/folder.svg', import.meta.url).href,
personal: new URL('../icons/user.svg', import.meta.url).href, personal: new URL('../icons/user.svg', import.meta.url).href,

View File

@ -20,7 +20,14 @@
<button type="button" :disabled="secondaryLoading" @click="handleVerifySecondary"> <button type="button" :disabled="secondaryLoading" @click="handleVerifySecondary">
{{ secondaryLoading ? '校验中...' : '确认进入' }} {{ secondaryLoading ? '校验中...' : '确认进入' }}
</button> </button>
<button type="button" class="ghost-btn" :disabled="secondaryLoading" @click="checkSecondary">重新检测</button> <button
type="button"
class="ghost-btn"
:disabled="secondaryLoading"
@click="checkSecondary"
>
重新检测
</button>
</div> </div>
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p> <p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
</div> </div>
@ -31,9 +38,7 @@
<p>最近更新{{ timeAgo(snapshot?.generated_at || overview.generated_at) }}</p> <p>最近更新{{ timeAgo(snapshot?.generated_at || overview.generated_at) }}</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<label> <label> <input type="checkbox" v-model="autoRefresh" /> 自动刷新 </label>
<input type="checkbox" v-model="autoRefresh" /> 自动刷新
</label>
<button type="button" :disabled="refreshing" @click="handleManualRefresh"> <button type="button" :disabled="refreshing" @click="handleManualRefresh">
{{ refreshing ? '刷新中...' : '立即刷新' }} {{ refreshing ? '刷新中...' : '立即刷新' }}
</button> </button>
@ -215,7 +220,10 @@
<td>{{ formatPercentNumber(item.stats?.memory?.percent) }}</td> <td>{{ formatPercentNumber(item.stats?.memory?.percent) }}</td>
<td>{{ formatBytes(item.stats?.memory?.used_bytes) }}</td> <td>{{ formatBytes(item.stats?.memory?.used_bytes) }}</td>
<td>{{ formatBytes(item.stats?.memory?.limit_bytes) }}</td> <td>{{ formatBytes(item.stats?.memory?.limit_bytes) }}</td>
<td>{{ formatBytes(item.stats?.net_io?.rx_bytes) }} / {{ formatBytes(item.stats?.net_io?.tx_bytes) }}</td> <td>
{{ formatBytes(item.stats?.net_io?.rx_bytes) }} /
{{ formatBytes(item.stats?.net_io?.tx_bytes) }}
</td>
<td> <td>
<span :class="['status-badge', containerStatusClass(item)]"> <span :class="['status-badge', containerStatusClass(item)]">
{{ item.state?.status || (item.state?.running ? '运行中' : '未知') }} {{ item.state?.status || (item.state?.running ? '运行中' : '未知') }}
@ -254,12 +262,14 @@
</div> </div>
<div class="upload-meta"> <div class="upload-meta">
<span>{{ timeAgo(upload.timestamp) }}</span> <span>{{ timeAgo(upload.timestamp) }}</span>
<span :class="['status-badge', upload.accepted ? 'online' : 'danger']"> <span :class="['status-badge', upload.accepted ? 'online' : 'danger']">
{{ upload.accepted ? '通过' : '阻断' }} {{ upload.accepted ? '通过' : '阻断' }}
</span> </span>
<span v-if="upload.error?.message" class="status-badge danger">{{ upload.error.message }}</span> <span v-if="upload.error?.message" class="status-badge danger">{{
</div> upload.error.message
</li> }}</span>
</div>
</li>
</ul> </ul>
</section> </section>
<section v-else-if="activeSection === 'balance'" key="balance" class="panel"> <section v-else-if="activeSection === 'balance'" key="balance" class="panel">
@ -269,7 +279,9 @@
<p class="muted">手动刷新查看 Kimi / DeepSeek / Qwen 余额</p> <p class="muted">手动刷新查看 Kimi / DeepSeek / Qwen 余额</p>
</div> </div>
<div class="balance-actions"> <div class="balance-actions">
<span class="muted" v-if="balanceUpdatedAt">上次刷新{{ timeAgo(balanceUpdatedAt) }}</span> <span class="muted" v-if="balanceUpdatedAt"
>上次刷新{{ timeAgo(balanceUpdatedAt) }}</span
>
<button type="button" :disabled="balanceLoading" @click="fetchBalance"> <button type="button" :disabled="balanceLoading" @click="fetchBalance">
{{ balanceLoading ? '查询中...' : '手动刷新' }} {{ balanceLoading ? '查询中...' : '手动刷新' }}
</button> </button>
@ -280,7 +292,12 @@
<span>{{ balanceError }}</span> <span>{{ balanceError }}</span>
</section> </section>
<div class="balance-grid"> <div class="balance-grid">
<div v-for="card in balanceCards" :key="card.key" class="balance-card" :class="{ error: !card.success }"> <div
v-for="card in balanceCards"
:key="card.key"
class="balance-card"
:class="{ error: !card.success }"
>
<div class="balance-card-head"> <div class="balance-card-head">
<h3>{{ card.label }}</h3> <h3>{{ card.label }}</h3>
<span v-if="card.success" class="status-badge online">正常</span> <span v-if="card.success" class="status-badge online">正常</span>
@ -295,7 +312,9 @@
<li v-if="card.extras.voucher !== undefined">{{ card.extras.voucher }}</li> <li v-if="card.extras.voucher !== undefined">{{ card.extras.voucher }}</li>
<li v-if="card.extras.cash !== undefined">现金{{ card.extras.cash }}</li> <li v-if="card.extras.cash !== undefined">现金{{ card.extras.cash }}</li>
<li v-if="card.extras.granted !== undefined">赠送{{ card.extras.granted }}</li> <li v-if="card.extras.granted !== undefined">赠送{{ card.extras.granted }}</li>
<li v-if="card.extras.topped_up !== undefined">充值{{ card.extras.topped_up }}</li> <li v-if="card.extras.topped_up !== undefined">
充值{{ card.extras.topped_up }}
</li>
<li v-if="card.extras.rate">汇率 USDCNY{{ card.extras.rate }}</li> <li v-if="card.extras.rate">汇率 USDCNY{{ card.extras.rate }}</li>
</ul> </ul>
<p v-else class="muted small">{{ card.error || '未知错误' }}</p> <p v-else class="muted small">{{ card.error || '未知错误' }}</p>
@ -341,10 +360,20 @@
<h4>{{ code.code }}</h4> <h4>{{ code.code }}</h4>
<span>{{ inviteStatus(code.remaining) }}</span> <span>{{ inviteStatus(code.remaining) }}</span>
<div class="invite-actions"> <div class="invite-actions">
<button type="button" class="mini-btn" :disabled="inviteSubmitting" @click="editInvite(code)"> <button
type="button"
class="mini-btn"
:disabled="inviteSubmitting"
@click="editInvite(code)"
>
调整次数 调整次数
</button> </button>
<button type="button" class="mini-btn danger" :disabled="inviteSubmitting" @click="removeInvite(code.code)"> <button
type="button"
class="mini-btn danger"
:disabled="inviteSubmitting"
@click="removeInvite(code.code)"
>
删除 删除
</button> </button>
</div> </div>
@ -371,7 +400,13 @@ type Snapshot = Record<string, any> | null;
type SectionId = 'overview' | 'usage' | 'users' | 'containers' | 'uploads' | 'balance' | 'invites'; type SectionId = 'overview' | 'usage' | 'users' | 'containers' | 'uploads' | 'balance' | 'invites';
const { verified: secondaryVerified, loading: secondaryLoading, error: secondaryError, check: checkSecondary, verify: verifySecondary } = useSecondaryPass(); const {
verified: secondaryVerified,
loading: secondaryLoading,
error: secondaryError,
check: checkSecondary,
verify: verifySecondary
} = useSecondaryPass();
const secondaryPassword = ref(''); const secondaryPassword = ref('');
const loading = ref(true); const loading = ref(true);
@ -474,8 +509,17 @@ const fetchCsrfToken = async (): Promise<string> => {
}; };
const parseInviteRemaining = (raw: unknown): number | null => { const parseInviteRemaining = (raw: unknown): number | null => {
const text = String(raw ?? '').trim().toLowerCase(); const text = String(raw ?? '')
if (!text || text === 'unlimited' || text === 'null' || text === 'none' || text === '不限' || text === '无限制') { .trim()
.toLowerCase();
if (
!text ||
text === 'unlimited' ||
text === 'null' ||
text === 'none' ||
text === '不限' ||
text === '无限制'
) {
return null; return null;
} }
const parsed = Number(text); const parsed = Number(text);
@ -494,16 +538,18 @@ const createInvite = async () => {
inviteSubmitting.value = true; inviteSubmitting.value = true;
inviteError.value = null; inviteError.value = null;
try { try {
const remaining = newInviteUnlimited.value ? null : parseInviteRemaining(newInviteRemaining.value); const remaining = newInviteUnlimited.value
? null
: parseInviteRemaining(newInviteRemaining.value);
const csrfToken = await fetchCsrfToken(); const csrfToken = await fetchCsrfToken();
const resp = await fetch('/api/admin/invites', { const resp = await fetch('/api/admin/invites', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken, 'X-CSRF-Token': csrfToken
}, },
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify({ code, remaining }), body: JSON.stringify({ code, remaining })
}); });
const payload = await resp.json(); const payload = await resp.json();
if (!resp.ok || !payload.success) { if (!resp.ok || !payload.success) {
@ -534,10 +580,10 @@ const editInvite = async (item: any) => {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken, 'X-CSRF-Token': csrfToken
}, },
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify({ code: item?.code, remaining }), body: JSON.stringify({ code: item?.code, remaining })
}); });
const payload = await resp.json(); const payload = await resp.json();
if (!resp.ok || !payload.success) { if (!resp.ok || !payload.success) {
@ -562,10 +608,10 @@ const removeInvite = async (code: string) => {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken, 'X-CSRF-Token': csrfToken
}, },
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify({ code }), body: JSON.stringify({ code })
}); });
const payload = await resp.json(); const payload = await resp.json();
if (!resp.ok || !payload.success) { if (!resp.ok || !payload.success) {
@ -693,9 +739,11 @@ const balanceCards = computed(() => {
success, success,
currency, currency,
amount, amount,
amountCny: data.available_cny ?? (currency === 'USD' && usdToCnyRate.value && amount != null amountCny:
? Math.round(amount * usdToCnyRate.value * 100) / 100 data.available_cny ??
: null), (currency === 'USD' && usdToCnyRate.value && amount != null
? Math.round(amount * usdToCnyRate.value * 100) / 100
: null),
extras: data, extras: data,
error: success ? null : data.error error: success ? null : data.error
}; };
@ -717,7 +765,8 @@ const metricCards = computed(() => [
title: '容器使用', title: '容器使用',
value: `${containerSummary.value.active || 0}/${containerSummary.value.max_containers ?? '∞'}`, value: `${containerSummary.value.active || 0}/${containerSummary.value.max_containers ?? '∞'}`,
sub: sub:
containerSummary.value.available_slots === null || containerSummary.value.available_slots === undefined containerSummary.value.available_slots === null ||
containerSummary.value.available_slots === undefined
? '无限制' ? '无限制'
: `${containerSummary.value.available_slots} 个空闲` : `${containerSummary.value.available_slots} 个空闲`
}, },
@ -819,7 +868,7 @@ const inviteStatus = (remaining: number | null | undefined) => {
:global(body) { :global(body) {
margin: 0; margin: 0;
background: #f7f3ea; background: #f7f3ea;
font-family: 'Iowan Old Style', ui-serif, Georgia, Cambria, "Times New Roman", serif; font-family: 'Iowan Old Style', ui-serif, Georgia, Cambria, 'Times New Roman', serif;
color: #2a2013; color: #2a2013;
overflow: hidden; overflow: hidden;
} }
@ -896,7 +945,10 @@ button {
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
box-shadow: inset 0 -2px 0 rgba(189, 93, 58, 0.25); box-shadow: inset 0 -2px 0 rgba(189, 93, 58, 0.25);
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.2s ease; transition:
transform 0.15s ease,
box-shadow 0.15s ease,
background 0.2s ease;
} }
button:disabled { button:disabled {

View File

@ -16,7 +16,14 @@
<button type="button" :disabled="secondaryLoading" @click="handleVerifySecondary"> <button type="button" :disabled="secondaryLoading" @click="handleVerifySecondary">
{{ secondaryLoading ? '校验中...' : '确认' }} {{ secondaryLoading ? '校验中...' : '确认' }}
</button> </button>
<button type="button" class="ghost-btn" :disabled="secondaryLoading" @click="checkSecondary">重新检测</button> <button
type="button"
class="ghost-btn"
:disabled="secondaryLoading"
@click="checkSecondary"
>
重新检测
</button>
</div> </div>
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p> <p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
</div> </div>
@ -49,12 +56,17 @@
<div class="metric-card"> <div class="metric-card">
<p>累计 Token</p> <p>累计 Token</p>
<strong>{{ formatNumber(overview.token_totals?.total_tokens || 0) }}</strong> <strong>{{ formatNumber(overview.token_totals?.total_tokens || 0) }}</strong>
<span class="muted">输入 {{ formatNumber(overview.token_totals?.input_tokens || 0) }} / 输出 {{ formatNumber(overview.token_totals?.output_tokens || 0) }}</span> <span class="muted"
>输入 {{ formatNumber(overview.token_totals?.input_tokens || 0) }} / 输出
{{ formatNumber(overview.token_totals?.output_tokens || 0) }}</span
>
</div> </div>
<div class="metric-card"> <div class="metric-card">
<p>活动容器</p> <p>活动容器</p>
<strong>{{ overview.totals?.containers_active || 0 }}</strong> <strong>{{ overview.totals?.containers_active || 0 }}</strong>
<span class="muted">可用槽位 {{ overview.totals?.available_container_slots ?? '—' }}</span> <span class="muted"
>可用槽位 {{ overview.totals?.available_container_slots ?? '—' }}</span
>
</div> </div>
</div> </div>
</section> </section>
@ -91,7 +103,9 @@
<td colspan="10">暂无 API 账号</td> <td colspan="10">暂无 API 账号</td>
</tr> </tr>
<tr v-for="u in users" :key="u.username"> <tr v-for="u in users" :key="u.username">
<td><strong>{{ u.username }}</strong></td> <td>
<strong>{{ u.username }}</strong>
</td>
<td>{{ u.note || '—' }}</td> <td>{{ u.note || '—' }}</td>
<td>{{ formatTime(u.created_at) }}</td> <td>{{ formatTime(u.created_at) }}</td>
<td>{{ formatNumber(u.usage?.total_requests || 0) }}</td> <td>{{ formatNumber(u.usage?.total_requests || 0) }}</td>
@ -104,9 +118,17 @@
</td> </td>
<td class="token-cell"> <td class="token-cell">
<span class="token-text"> <span class="token-text">
{{ revealTokens[u.username]?.visible ? (revealTokens[u.username]?.token || '—') : '••••••••••' }} {{
revealTokens[u.username]?.visible
? revealTokens[u.username]?.token || '—'
: '••••••••••'
}}
</span> </span>
<button class="icon-btn" @click="toggleReveal(u.username)" :disabled="revealLoading === u.username"> <button
class="icon-btn"
@click="toggleReveal(u.username)"
:disabled="revealLoading === u.username"
>
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"> <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path <path
d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12Zm11 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12Zm11 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
@ -116,12 +138,22 @@
/> />
</svg> </svg>
</button> </button>
<button class="icon-btn" @click="copyToken(u.username)" :disabled="revealLoading === u.username"> <button
class="icon-btn"
@click="copyToken(u.username)"
:disabled="revealLoading === u.username"
>
📋 📋
</button> </button>
</td> </td>
<td> <td>
<button class="link danger" @click="deleteUser(u.username)" :disabled="deleting === u.username">删除</button> <button
class="link danger"
@click="deleteUser(u.username)"
:disabled="deleting === u.username"
>
删除
</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -157,7 +189,10 @@
<td>{{ formatPercentNumber(c.stats?.cpu_percent) }}</td> <td>{{ formatPercentNumber(c.stats?.cpu_percent) }}</td>
<td>{{ formatPercentNumber(c.stats?.memory?.percent) }}</td> <td>{{ formatPercentNumber(c.stats?.memory?.percent) }}</td>
<td>{{ formatBytes(c.stats?.memory?.used_bytes) }}</td> <td>{{ formatBytes(c.stats?.memory?.used_bytes) }}</td>
<td>{{ formatBytes(c.stats?.net_io?.rx_bytes) }} / {{ formatBytes(c.stats?.net_io?.tx_bytes) }}</td> <td>
{{ formatBytes(c.stats?.net_io?.rx_bytes) }} /
{{ formatBytes(c.stats?.net_io?.tx_bytes) }}
</td>
<td> <td>
<span :class="['status-badge', c.running ? 'online' : 'offline']"> <span :class="['status-badge', c.running ? 'online' : 'offline']">
{{ c.running ? '运行中' : '未运行' }} {{ c.running ? '运行中' : '未运行' }}
@ -179,7 +214,8 @@
<div> <div>
<strong>{{ item.original_name || '未命名文件' }}</strong> <strong>{{ item.original_name || '未命名文件' }}</strong>
<div class="muted small"> <div class="muted small">
用户 {{ item.username }} · {{ formatBytes(item.size) }} · {{ item.source || 'unknown' }} 用户 {{ item.username }} · {{ formatBytes(item.size) }} ·
{{ item.source || 'unknown' }}
</div> </div>
</div> </div>
<div class="muted small"> <div class="muted small">
@ -204,7 +240,12 @@
</button> </button>
</div> </div>
<div class="balance-grid"> <div class="balance-grid">
<div v-for="card in balanceCards" :key="card.key" class="balance-card" :class="{ error: !card.success }"> <div
v-for="card in balanceCards"
:key="card.key"
class="balance-card"
:class="{ error: !card.success }"
>
<div class="balance-card-head"> <div class="balance-card-head">
<h4>{{ card.label }}</h4> <h4>{{ card.label }}</h4>
<span :class="['status-badge', card.success ? 'online' : 'danger']"> <span :class="['status-badge', card.success ? 'online' : 'danger']">
@ -224,7 +265,13 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useSecondaryPass } from './useSecondaryPass'; import { useSecondaryPass } from './useSecondaryPass';
const { verified: secondaryVerified, loading: secondaryLoading, error: secondaryError, check: checkSecondary, verify: verifySecondary } = useSecondaryPass(); const {
verified: secondaryVerified,
loading: secondaryLoading,
error: secondaryError,
check: checkSecondary,
verify: verifySecondary
} = useSecondaryPass();
const secondaryPassword = ref(''); const secondaryPassword = ref('');
const loading = ref(false); const loading = ref(false);
@ -250,7 +297,10 @@ const containerItems = computed(() => {
return Object.keys(items).map((key) => ({ key, ...(items[key] || {}) })); return Object.keys(items).map((key) => ({ key, ...(items[key] || {}) }));
}); });
const totalRequests = computed(() => const totalRequests = computed(() =>
users.value.reduce((sum, u) => sum + ((u.usage && u.usage.total_requests) ? u.usage.total_requests : 0), 0) users.value.reduce(
(sum, u) => sum + (u.usage && u.usage.total_requests ? u.usage.total_requests : 0),
0
)
); );
const balanceCards = computed(() => { const balanceCards = computed(() => {
@ -264,7 +314,7 @@ const balanceCards = computed(() => {
amount: data.available ?? data.total ?? null, amount: data.available ?? data.total ?? null,
amountCny: data.amount_cny ?? data.amountCny ?? null, amountCny: data.amount_cny ?? data.amountCny ?? null,
currency: data.currency || '', currency: data.currency || '',
error: data.error, error: data.error
}; };
}); });
}); });
@ -339,7 +389,7 @@ const fetchUsers = async () => {
...u, ...u,
usage: u.usage || { total_requests: 0, endpoints: {}, last_request_at: null }, usage: u.usage || { total_requests: 0, endpoints: {}, last_request_at: null },
tokens: u.tokens || { total_tokens: 0, input_tokens: 0, output_tokens: 0 }, tokens: u.tokens || { total_tokens: 0, input_tokens: 0, output_tokens: 0 },
storage: u.storage || { total_bytes: 0 }, storage: u.storage || { total_bytes: 0 }
})); }));
users.value.forEach((u) => { users.value.forEach((u) => {
map[u.username] = revealTokens.value[u.username] || { token: '', visible: false }; map[u.username] = revealTokens.value[u.username] || { token: '', visible: false };
@ -383,7 +433,7 @@ const createUser = async () => {
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, note: createForm.value.note }), body: JSON.stringify({ username, note: createForm.value.note })
}); });
const data = await resp.json(); const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || '创建失败'); if (!resp.ok || !data.success) throw new Error(data.error || '创建失败');
@ -404,7 +454,7 @@ const deleteUser = async (username: string) => {
try { try {
const resp = await fetch(`/api/admin/api-users/${encodeURIComponent(username)}`, { const resp = await fetch(`/api/admin/api-users/${encodeURIComponent(username)}`, {
method: 'DELETE', method: 'DELETE',
credentials: 'same-origin', credentials: 'same-origin'
}); });
const data = await resp.json(); const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || '删除失败'); if (!resp.ok || !data.success) throw new Error(data.error || '删除失败');
@ -425,7 +475,7 @@ const toggleReveal = async (username: string) => {
revealLoading.value = username; revealLoading.value = username;
try { try {
const resp = await fetch(`/api/admin/api-users/${encodeURIComponent(username)}/token`, { const resp = await fetch(`/api/admin/api-users/${encodeURIComponent(username)}/token`, {
credentials: 'same-origin', credentials: 'same-origin'
}); });
const data = await resp.json(); const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || '获取失败'); if (!resp.ok || !data.success) throw new Error(data.error || '获取失败');
@ -503,7 +553,7 @@ onBeforeUnmount(() => {
:global(body) { :global(body) {
margin: 0; margin: 0;
background: #f7f3ea; background: #f7f3ea;
font-family: 'Iowan Old Style', ui-serif, Georgia, Cambria, "Times New Roman", serif; font-family: 'Iowan Old Style', ui-serif, Georgia, Cambria, 'Times New Roman', serif;
color: #2a2013; color: #2a2013;
} }
@ -549,11 +599,11 @@ onBeforeUnmount(() => {
} }
.metric-card { .metric-card {
background: rgba(255,255,255,0.94); background: rgba(255, 255, 255, 0.94);
border-radius: 16px; border-radius: 16px;
padding: 14px; padding: 14px;
border: 1px solid rgba(44,32,19,0.12); border: 1px solid rgba(44, 32, 19, 0.12);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.6); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
} }
.metric-card p { .metric-card p {
@ -594,7 +644,7 @@ onBeforeUnmount(() => {
.create-form input { .create-form input {
padding: 10px 12px; padding: 10px 12px;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(0,0,0,0.12); border: 1px solid rgba(0, 0, 0, 0.12);
} }
button { button {
@ -626,9 +676,10 @@ table {
border-collapse: collapse; border-collapse: collapse;
} }
th, td { th,
td {
padding: 10px; padding: 10px;
border-bottom: 1px solid rgba(0,0,0,0.08); border-bottom: 1px solid rgba(0, 0, 0, 0.08);
text-align: left; text-align: left;
font-size: 14px; font-size: 14px;
} }
@ -640,8 +691,8 @@ th, td {
} }
.icon-btn { .icon-btn {
background: rgba(255,255,255,0.9); background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0,0,0,0.12); border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px; border-radius: 8px;
padding: 6px; padding: 6px;
cursor: pointer; cursor: pointer;
@ -667,7 +718,7 @@ th, td {
} }
.status-badge.offline { .status-badge.offline {
background: rgba(0,0,0,0.06); background: rgba(0, 0, 0, 0.06);
color: #5f5140; color: #5f5140;
} }
@ -693,8 +744,8 @@ th, td {
.upload-item { .upload-item {
padding: 10px; padding: 10px;
border-radius: 12px; border-radius: 12px;
background: rgba(255,255,255,0.92); background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(0,0,0,0.06); border: 1px solid rgba(0, 0, 0, 0.06);
} }
.grid { .grid {
@ -718,8 +769,8 @@ th, td {
} }
.balance-card { .balance-card {
background: rgba(255,255,255,0.92); background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(44,32,19,0.12); border: 1px solid rgba(44, 32, 19, 0.12);
border-radius: 14px; border-radius: 14px;
padding: 12px; padding: 12px;
} }

View File

@ -13,10 +13,17 @@
@keyup.enter="handleVerifySecondary" @keyup.enter="handleVerifySecondary"
/> />
<div class="secondary-actions"> <div class="secondary-actions">
<button type="button" class="primary" :disabled="secondaryLoading" @click="handleVerifySecondary"> <button
type="button"
class="primary"
:disabled="secondaryLoading"
@click="handleVerifySecondary"
>
{{ secondaryLoading ? '校验中...' : '确认' }} {{ secondaryLoading ? '校验中...' : '确认' }}
</button> </button>
<button type="button" class="ghost" :disabled="secondaryLoading" @click="checkSecondary">重新检测</button> <button type="button" class="ghost" :disabled="secondaryLoading" @click="checkSecondary">
重新检测
</button>
</div> </div>
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p> <p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
</div> </div>
@ -29,12 +36,16 @@
<p>每个工具一个文件夹三层文件独立存放仅管理员可见并可调用</p> <p>每个工具一个文件夹三层文件独立存放仅管理员可见并可调用</p>
</div> </div>
<div class="actions"> <div class="actions">
<button type="button" class="primary" @click="openCreateModal">新建工具</button> <button type="button" class="primary" @click="openCreateModal">新建工具</button>
<button type="button" class="ghost" @click="refresh" :disabled="loading">{{ loading ? '刷新中...' : '刷新列表' }}</button> <button type="button" class="ghost" @click="refresh" :disabled="loading">
<a class="ghost" href="/static/custom_tools/guide.html" target="_blank" rel="noopener">查看开发指南</a> {{ loading ? '刷新中...' : '刷新列表' }}
</button>
<a class="ghost" href="/static/custom_tools/guide.html" target="_blank" rel="noopener"
>查看开发指南</a
>
<a class="ghost" href="/admin/monitor" target="_blank" rel="noopener">返回监控</a> <a class="ghost" href="/admin/monitor" target="_blank" rel="noopener">返回监控</a>
<a class="ghost" href="/admin/policy" target="_blank" rel="noopener">策略配置</a> <a class="ghost" href="/admin/policy" target="_blank" rel="noopener">策略配置</a>
</div> </div>
</header> </header>
<section class="panel" v-if="error"> <section class="panel" v-if="error">
@ -84,28 +95,49 @@
<p>{{ activeTool.description || '未填写描述' }}</p> <p>{{ activeTool.description || '未填写描述' }}</p>
</div> </div>
<div class="drawer-actions"> <div class="drawer-actions">
<button type="button" class="primary" @click="saveAll" :disabled="saving">{{ saving ? '保存中...' : '保存' }}</button> <button type="button" class="primary" @click="saveAll" :disabled="saving">
{{ saving ? '保存中...' : '保存' }}
</button>
<button type="button" class="danger" @click="openDeleteConfirm()">删除</button> <button type="button" class="danger" @click="openDeleteConfirm()">删除</button>
<button type="button" class="ghost" @click="closeEditor">关闭</button> <button type="button" class="ghost" @click="closeEditor">关闭</button>
</div> </div>
</header> </header>
<div class="tabs"> <div class="tabs">
<button :class="{active: tab==='definition'}" @click="tab='definition'">definition.json</button> <button :class="{ active: tab === 'definition' }" @click="tab = 'definition'">
<button :class="{active: tab==='execution'}" @click="tab='execution'">execution.py</button> definition.json
<button :class="{active: tab==='return'}" @click="tab='return'">return.json</button> </button>
<button :class="{active: tab==='meta'}" @click="tab='meta'">meta.json</button> <button :class="{ active: tab === 'execution' }" @click="tab = 'execution'">
execution.py
</button>
<button :class="{ active: tab === 'return' }" @click="tab = 'return'">return.json</button>
<button :class="{ active: tab === 'meta' }" @click="tab = 'meta'">meta.json</button>
</div> </div>
<div class="editor"> <div class="editor">
<textarea v-if="tab==='definition'" v-model="buffers.definition" spellcheck="false"></textarea> <textarea
<textarea v-else-if="tab==='execution'" v-model="buffers.execution" spellcheck="false"></textarea> v-if="tab === 'definition'"
<textarea v-else-if="tab==='return'" v-model="buffers.return" spellcheck="false"></textarea> v-model="buffers.definition"
spellcheck="false"
></textarea>
<textarea
v-else-if="tab === 'execution'"
v-model="buffers.execution"
spellcheck="false"
></textarea>
<textarea
v-else-if="tab === 'return'"
v-model="buffers.return"
spellcheck="false"
></textarea>
<textarea v-else v-model="buffers.meta" spellcheck="false"></textarea> <textarea v-else v-model="buffers.meta" spellcheck="false"></textarea>
</div> </div>
<div class="hint"> <div class="hint">
<p>提示execution.py 中的字典/集合需要用 <code v-pre>{{ ... }}</code> 包裹避免被模板替换</p> <p>
提示execution.py 中的字典/集合需要用
<code v-pre>{{ ... }}</code> 包裹避免被模板替换
</p>
<p>保存后无需重启系统会自动 reload 自定义工具</p> <p>保存后无需重启系统会自动 reload 自定义工具</p>
</div> </div>
</div> </div>
@ -121,8 +153,10 @@
<label>工具 ID小写/下划线<input v-model="createForm.id" /></label> <label>工具 ID小写/下划线<input v-model="createForm.id" /></label>
<label>描述<input v-model="createForm.description" /></label> <label>描述<input v-model="createForm.description" /></label>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="ghost" @click="createModal=false">取消</button> <button type="button" class="ghost" @click="createModal = false">取消</button>
<button type="button" class="primary" @click="createTool" :disabled="creating">{{ creating ? '创建中...' : '创建' }}</button> <button type="button" class="primary" @click="createTool" :disabled="creating">
{{ creating ? '创建中...' : '创建' }}
</button>
</div> </div>
</div> </div>
</div> </div>
@ -133,10 +167,14 @@
<div v-if="confirmDeleteModal" class="modal-backdrop"> <div v-if="confirmDeleteModal" class="modal-backdrop">
<div class="modal"> <div class="modal">
<h3>确认删除</h3> <h3>确认删除</h3>
<p class="delete-tip">确定删除工具 <strong>{{ deleteTargetId }}</strong> 该操作不可恢复</p> <p class="delete-tip">
确定删除工具 <strong>{{ deleteTargetId }}</strong> 该操作不可恢复
</p>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="ghost" @click="confirmDeleteModal=false">取消</button> <button type="button" class="ghost" @click="confirmDeleteModal = false">取消</button>
<button type="button" class="danger" @click="performDelete" :disabled="deleting">{{ deleting ? '删除中...' : '确认删除' }}</button> <button type="button" class="danger" @click="performDelete" :disabled="deleting">
{{ deleting ? '删除中...' : '确认删除' }}
</button>
</div> </div>
</div> </div>
</div> </div>
@ -159,7 +197,13 @@ interface CustomTool {
return_file?: string; return_file?: string;
} }
const { verified: secondaryVerified, loading: secondaryLoading, error: secondaryError, check: checkSecondary, verify: verifySecondary } = useSecondaryPass(); const {
verified: secondaryVerified,
loading: secondaryLoading,
error: secondaryError,
check: checkSecondary,
verify: verifySecondary
} = useSecondaryPass();
const secondaryPassword = ref(''); const secondaryPassword = ref('');
const tools = ref<CustomTool[]>([]); const tools = ref<CustomTool[]>([]);
@ -221,10 +265,20 @@ const openEditorPage = (tool: CustomTool) => {
const loadBuffers = async (id: string) => { const loadBuffers = async (id: string) => {
if (!secondaryVerified.value) return; if (!secondaryVerified.value) return;
try { try {
buffers.definition = await fetchText(`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=definition.json`); buffers.definition = await fetchText(
buffers.execution = await fetchText(`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=execution.py`); `/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=definition.json`
buffers.return = await fetchText(`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=return.json`, ''); );
buffers.meta = await fetchText(`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=meta.json`, ''); buffers.execution = await fetchText(
`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=execution.py`
);
buffers.return = await fetchText(
`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=return.json`,
''
);
buffers.meta = await fetchText(
`/api/admin/custom-tools/file?id=${encodeURIComponent(id)}&name=meta.json`,
''
);
} catch (e: any) { } catch (e: any) {
error.value = e.message; error.value = e.message;
} }
@ -270,11 +324,6 @@ const reloadRegistry = async () => {
await fetch('/api/admin/custom-tools/reload', { method: 'POST', credentials: 'same-origin' }); await fetch('/api/admin/custom-tools/reload', { method: 'POST', credentials: 'same-origin' });
}; };
const confirmDelete = async () => {
if (!activeTool.value) return;
openDeleteConfirm(activeTool.value);
};
const openDeleteConfirm = (tool?: CustomTool) => { const openDeleteConfirm = (tool?: CustomTool) => {
const target = tool || activeTool.value; const target = tool || activeTool.value;
if (!target) return; if (!target) return;
@ -287,7 +336,10 @@ const performDelete = async () => {
if (!deleteTargetId.value) return; if (!deleteTargetId.value) return;
deleting.value = true; deleting.value = true;
try { try {
const res = await fetch(`/api/admin/custom-tools?id=${encodeURIComponent(deleteTargetId.value)}`, { method: 'DELETE', credentials: 'same-origin' }); const res = await fetch(
`/api/admin/custom-tools?id=${encodeURIComponent(deleteTargetId.value)}`,
{ method: 'DELETE', credentials: 'same-origin' }
);
const data = await res.json(); const data = await res.json();
if (!data.success) throw new Error(data.error || '删除失败'); if (!data.success) throw new Error(data.error || '删除失败');
activeTool.value = null; activeTool.value = null;
@ -387,9 +439,9 @@ watch(secondaryVerified, async (val) => {
/* Claude 经典:米色+白色+深棕的柔和高对比配色 */ /* Claude 经典:米色+白色+深棕的柔和高对比配色 */
:global(html, body) { :global(html, body) {
min-height: 100%; min-height: 100%;
background: radial-gradient(140% 120% at 20% 20%, rgba(239, 229, 214, 0.9), transparent), background:
radial-gradient(120% 120% at 80% 0%, rgba(255, 255, 255, 0.9), transparent), radial-gradient(140% 120% at 20% 20%, rgba(239, 229, 214, 0.9), transparent),
#f8f3e8; radial-gradient(120% 120% at 80% 0%, rgba(255, 255, 255, 0.9), transparent), #f8f3e8;
} }
:global(#custom-tools-app) { :global(#custom-tools-app) {
min-height: 100vh; min-height: 100vh;
@ -411,7 +463,15 @@ watch(secondaryVerified, async (val) => {
max-width: 1180px; max-width: 1180px;
margin: 0 auto; margin: 0 auto;
padding: 28px; padding: 28px;
font-family: 'Space Grotesk', 'Noto Sans SC', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family:
'Space Grotesk',
'Noto Sans SC',
'Inter',
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
color: var(--ink); color: var(--ink);
background: transparent; background: transparent;
} }
@ -465,7 +525,11 @@ watch(secondaryVerified, async (val) => {
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 14px; padding: 14px;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.1s, background 0.2s; transition:
border-color 0.2s,
box-shadow 0.2s,
transform 0.1s,
background 0.2s;
background: linear-gradient(180deg, #fffdf8 0%, #ffffff 50%, #fbf6ed 100%); background: linear-gradient(180deg, #fffdf8 0%, #ffffff 50%, #fbf6ed 100%);
} }
.tool-card:hover { .tool-card:hover {
@ -643,8 +707,13 @@ watch(secondaryVerified, async (val) => {
margin: 4px 0 8px; margin: 4px 0 8px;
color: var(--muted); color: var(--muted);
} }
.error { color: #dc2626; } .error {
.empty { color: var(--muted); padding: 12px; } color: #dc2626;
}
.empty {
color: var(--muted);
padding: 12px;
}
/* 按钮样式与主站一致风格 */ /* 按钮样式与主站一致风格 */
.primary, .primary,
@ -658,7 +727,11 @@ watch(secondaryVerified, async (val) => {
padding: 10px 18px; padding: 10px 18px;
} }
.primary { .primary {
background: linear-gradient(135deg, var(--claude-accent, #da7756) 0%, var(--claude-accent-strong, #bd5d3a) 100%); background: linear-gradient(
135deg,
var(--claude-accent, #da7756) 0%,
var(--claude-accent-strong, #bd5d3a) 100%
);
color: #fff; color: #fff;
border: none; border: none;
cursor: pointer; cursor: pointer;

View File

@ -42,9 +42,9 @@ onMounted(() => {
<style scoped> <style scoped>
:global(html, body) { :global(html, body) {
min-height: 100%; min-height: 100%;
background: radial-gradient(140% 120% at 20% 20%, rgba(239, 229, 214, 0.9), transparent), background:
radial-gradient(120% 120% at 80% 0%, rgba(255, 255, 255, 0.9), transparent), radial-gradient(140% 120% at 20% 20%, rgba(239, 229, 214, 0.9), transparent),
#f8f3e8; radial-gradient(120% 120% at 80% 0%, rgba(255, 255, 255, 0.9), transparent), #f8f3e8;
} }
:global(#custom-tools-guide) { :global(#custom-tools-guide) {
min-height: 100vh; min-height: 100vh;
@ -61,7 +61,15 @@ onMounted(() => {
max-width: 1080px; max-width: 1080px;
margin: 0 auto; margin: 0 auto;
padding: 24px 16px 32px; padding: 24px 16px 32px;
font-family: 'Space Grotesk', 'Noto Sans SC', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family:
'Space Grotesk',
'Noto Sans SC',
'Inter',
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
color: var(--ink); color: var(--ink);
} }
.guide-header { .guide-header {

View File

@ -13,10 +13,22 @@
@keyup.enter="handleVerifySecondary" @keyup.enter="handleVerifySecondary"
/> />
<div class="secondary-actions"> <div class="secondary-actions">
<button type="button" class="primary" :disabled="secondaryLoading" @click="handleVerifySecondary"> <button
type="button"
class="primary"
:disabled="secondaryLoading"
@click="handleVerifySecondary"
>
{{ secondaryLoading ? '校验中...' : '确认' }} {{ secondaryLoading ? '校验中...' : '确认' }}
</button> </button>
<button type="button" class="ghost-btn" :disabled="secondaryLoading" @click="checkSecondary">重新检测</button> <button
type="button"
class="ghost-btn"
:disabled="secondaryLoading"
@click="checkSecondary"
>
重新检测
</button>
</div> </div>
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p> <p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
</div> </div>
@ -33,7 +45,12 @@
<span class="caret"></span> <span class="caret"></span>
</button> </button>
<div class="dropdown-menu" v-if="targetMenuOpen"> <div class="dropdown-menu" v-if="targetMenuOpen">
<button v-for="opt in targetOptions" :key="opt.value" type="button" @click="pickTargetType(opt.value)"> <button
v-for="opt in targetOptions"
:key="opt.value"
type="button"
@click="pickTargetType(opt.value)"
>
{{ opt.label }} {{ opt.label }}
</button> </button>
</div> </div>
@ -89,7 +106,7 @@
<div class="tool-select-search"> <div class="tool-select-search">
<input v-model="toolSearch" type="text" placeholder="搜索或添加工具 ID" /> <input v-model="toolSearch" type="text" placeholder="搜索或添加工具 ID" />
</div> </div>
<div class="tool-select-options"> <div class="tool-select-options">
<label v-for="tool in filteredToolOptions(cat)" :key="tool"> <label v-for="tool in filteredToolOptions(cat)" :key="tool">
<input <input
type="checkbox" type="checkbox"
@ -111,7 +128,11 @@
</div> </div>
</div> </div>
<label class="toggle-row compact"> <label class="toggle-row compact">
<input type="checkbox" :checked="getCategoryDefault(cat.id)" @change="setCategoryDefault(cat.id, $event.target.checked)" /> <input
type="checkbox"
:checked="getCategoryDefault(cat.id)"
@change="setCategoryDefault(cat.id, $event.target.checked)"
/>
<span class="fancy-check" aria-hidden="true"> <span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64"> <svg viewBox="0 0 64 64">
<path <path
@ -145,11 +166,7 @@
<h2>模型禁用</h2> <h2>模型禁用</h2>
</div> </div>
<div class="toggle-grid"> <div class="toggle-grid">
<label <label v-for="model in defaults.models" :key="model" class="toggle-row">
v-for="model in defaults.models"
:key="model"
class="toggle-row"
>
<input type="checkbox" :checked="isModelDisabled(model)" @change="toggleModel(model)" /> <input type="checkbox" :checked="isModelDisabled(model)" @change="toggleModel(model)" />
<span class="fancy-check" aria-hidden="true"> <span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64"> <svg viewBox="0 0 64 64">
@ -169,12 +186,12 @@
<h2>前端禁用项</h2> <h2>前端禁用项</h2>
</div> </div>
<div class="toggle-grid"> <div class="toggle-grid">
<label <label v-for="key in defaults.ui_block_keys" :key="key" class="toggle-row">
v-for="key in defaults.ui_block_keys" <input
:key="key" type="checkbox"
class="toggle-row" :checked="!!form.config.ui_blocks[key]"
> @change="toggleUiBlock(key, $event)"
<input type="checkbox" :checked="!!form.config.ui_blocks[key]" @change="toggleUiBlock(key, $event)" /> />
<span class="fancy-check" aria-hidden="true"> <span class="fancy-check" aria-hidden="true">
<svg viewBox="0 0 64 64"> <svg viewBox="0 0 64 64">
<path <path
@ -242,7 +259,13 @@ const defaults = reactive({
ui_block_keys: [] as string[] ui_block_keys: [] as string[]
}); });
const { verified: secondaryVerified, loading: secondaryLoading, error: secondaryError, check: checkSecondary, verify: verifySecondary } = useSecondaryPass(); const {
verified: secondaryVerified,
loading: secondaryLoading,
error: secondaryError,
check: checkSecondary,
verify: verifySecondary
} = useSecondaryPass();
const secondaryPassword = ref(''); const secondaryPassword = ref('');
const form = reactive({ const form = reactive({
@ -275,7 +298,9 @@ const targetOptions = [
{ value: 'invite', label: '邀请码' } { value: 'invite', label: '邀请码' }
] as const; ] as const;
const targetTypeLabel = computed(() => targetOptions.find(o => o.value === form.target_type)?.label || '全局'); const targetTypeLabel = computed(
() => targetOptions.find((o) => o.value === form.target_type)?.label || '全局'
);
const targetMenuOpen = ref(false); const targetMenuOpen = ref(false);
const openForceMenu = ref<string | null>(null); const openForceMenu = ref<string | null>(null);
const openToolMenu = ref<string | null>(null); const openToolMenu = ref<string | null>(null);
@ -497,9 +522,7 @@ function rebuildCategoryOverrides() {
if (!item.id) return; if (!item.id) return;
map[item.id] = { map[item.id] = {
label: item.label || item.id, label: item.label || item.id,
tools: (item.tools || []) tools: (item.tools || []).map((s) => (typeof s === 'string' ? s.trim() : '')).filter(Boolean),
.map((s) => (typeof s === 'string' ? s.trim() : ''))
.filter(Boolean),
default_enabled: !!item.default_enabled default_enabled: !!item.default_enabled
}; };
if (item.forced === true || item.forced === false) { if (item.forced === true || item.forced === false) {
@ -642,7 +665,7 @@ onBeforeUnmount(() => {
:global(body) { :global(body) {
margin: 0; margin: 0;
background: #f7f3ea; background: #f7f3ea;
font-family: 'Iowan Old Style', ui-serif, Georgia, Cambria, "Times New Roman", serif; font-family: 'Iowan Old Style', ui-serif, Georgia, Cambria, 'Times New Roman', serif;
color: #2a2013; color: #2a2013;
} }
@ -680,7 +703,7 @@ onBeforeUnmount(() => {
.ghost-btn { .ghost-btn {
padding: 10px 12px; padding: 10px 12px;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(0,0,0,0.12); border: 1px solid rgba(0, 0, 0, 0.12);
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
color: #2a2013; color: #2a2013;
display: inline-flex; display: inline-flex;
@ -700,9 +723,9 @@ onBeforeUnmount(() => {
left: 0; left: 0;
min-width: 160px; min-width: 160px;
background: #fff; background: #fff;
border: 1px solid rgba(0,0,0,0.12); border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 12px 30px rgba(0,0,0,0.12); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.12);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@ -719,13 +742,13 @@ onBeforeUnmount(() => {
} }
.dropdown-menu button:hover { .dropdown-menu button:hover {
background: rgba(0,0,0,0.04); background: rgba(0, 0, 0, 0.04);
} }
.text-field input { .text-field input {
padding: 10px 12px; padding: 10px 12px;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(0,0,0,0.12); border: 1px solid rgba(0, 0, 0, 0.12);
background: #fff; background: #fff;
} }
@ -737,7 +760,7 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
border: 1px solid rgba(0,0,0,0.08); border: 1px solid rgba(0, 0, 0, 0.08);
} }
.banner.success { .banner.success {
@ -796,12 +819,14 @@ onBeforeUnmount(() => {
.category-row select { .category-row select {
padding: 8px 10px; padding: 8px 10px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(0,0,0,0.12); border: 1px solid rgba(0, 0, 0, 0.12);
background: #fff; background: #fff;
} }
.id-input { .id-input {
font-family: "SFMono-Regular", ui-monospace, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family:
'SFMono-Regular', ui-monospace, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
} }
.checkbox-cell { .checkbox-cell {
@ -834,8 +859,8 @@ onBeforeUnmount(() => {
gap: 8px; gap: 8px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 14px; border-radius: 14px;
border: 1px solid rgba(0,0,0,0.12); border: 1px solid rgba(0, 0, 0, 0.12);
background: rgba(255,255,255,0.92); background: rgba(255, 255, 255, 0.92);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
@ -848,7 +873,7 @@ onBeforeUnmount(() => {
width: 34px; width: 34px;
height: 18px; height: 18px;
border-radius: 999px; border-radius: 999px;
background: rgba(0,0,0,0.12); background: rgba(0, 0, 0, 0.12);
position: relative; position: relative;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
@ -862,7 +887,7 @@ onBeforeUnmount(() => {
height: 14px; height: 14px;
border-radius: 50%; border-radius: 50%;
background: #fff; background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.2); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: all 0.2s ease; transition: all 0.2s ease;
} }
@ -916,7 +941,9 @@ onBeforeUnmount(() => {
cursor: pointer; cursor: pointer;
padding: 6px 10px; padding: 6px 10px;
border-radius: 10px; border-radius: 10px;
transition: background 0.15s ease, border-color 0.15s ease; transition:
background 0.15s ease,
border-color 0.15s ease;
} }
.link.danger { .link.danger {
@ -933,7 +960,7 @@ onBeforeUnmount(() => {
button { button {
padding: 8px 12px; padding: 8px 12px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(0,0,0,0.12); border: 1px solid rgba(0, 0, 0, 0.12);
background: #fff; background: #fff;
cursor: pointer; cursor: pointer;
} }
@ -960,7 +987,10 @@ button:disabled {
border: 1px solid var(--theme-control-border, rgba(118, 103, 84, 0.25)); border: 1px solid var(--theme-control-border, rgba(118, 103, 84, 0.25));
background: var(--theme-surface-muted, rgba(255, 255, 255, 0.85)); background: var(--theme-surface-muted, rgba(255, 255, 255, 0.85));
cursor: pointer; cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease;
} }
.toggle-row.compact { .toggle-row.compact {
@ -1001,7 +1031,10 @@ button:disabled {
stroke-width: 5; stroke-width: 5;
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: round; stroke-linejoin: round;
transition: stroke-dasharray 0.5s ease, stroke-dashoffset 0.5s ease, stroke 0.2s ease; transition:
stroke-dasharray 0.5s ease,
stroke-dashoffset 0.5s ease,
stroke 0.2s ease;
stroke-dasharray: 241 9999999; stroke-dasharray: 241 9999999;
stroke-dashoffset: 0; stroke-dashoffset: 0;
} }
@ -1029,7 +1062,7 @@ button:disabled {
gap: 8px; gap: 8px;
padding: 9px 10px; padding: 9px 10px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(0,0,0,0.12); border: 1px solid rgba(0, 0, 0, 0.12);
background: #fff; background: #fff;
cursor: pointer; cursor: pointer;
min-height: 40px; min-height: 40px;
@ -1037,7 +1070,7 @@ button:disabled {
.tool-select.open .tool-select-trigger { .tool-select.open .tool-select-trigger {
border-color: rgba(118, 103, 84, 0.4); border-color: rgba(118, 103, 84, 0.4);
box-shadow: 0 10px 24px rgba(0,0,0,0.08); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
} }
.tool-badges { .tool-badges {
@ -1068,7 +1101,7 @@ button:disabled {
background: #fffaf4; background: #fffaf4;
border: 1px solid rgba(118, 103, 84, 0.2); border: 1px solid rgba(118, 103, 84, 0.2);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 16px 36px rgba(0,0,0,0.12); box-shadow: 0 16px 36px rgba(0, 0, 0, 0.12);
padding: 10px; padding: 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -1080,7 +1113,7 @@ button:disabled {
width: 100%; width: 100%;
padding: 8px 10px; padding: 8px 10px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(0,0,0,0.12); border: 1px solid rgba(0, 0, 0, 0.12);
background: #fff; background: #fff;
} }
@ -1104,8 +1137,8 @@ button:disabled {
gap: 6px; gap: 6px;
padding: 6px 8px; padding: 6px 8px;
border-radius: 8px; border-radius: 8px;
background: rgba(255,255,255,0.85); background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(0,0,0,0.06); border: 1px solid rgba(0, 0, 0, 0.06);
cursor: pointer; cursor: pointer;
} }

View File

@ -28,7 +28,7 @@ export function useSecondaryPass() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify({ password }), body: JSON.stringify({ password })
}); });
const data = await resp.json(); const data = await resp.json();
if (!resp.ok || !data.success) { if (!resp.ok || !data.success) {
@ -48,7 +48,7 @@ export function useSecondaryPass() {
loading, loading,
error, error,
check, check,
verify, verify
}; };
} }

View File

@ -19,12 +19,20 @@
<div v-if="msg.content" class="bubble-text">{{ msg.content }}</div> <div v-if="msg.content" class="bubble-text">{{ msg.content }}</div>
<div v-if="msg.images && msg.images.length" class="image-inline-row"> <div v-if="msg.images && msg.images.length" class="image-inline-row">
<div class="image-thumbnail-wrapper" v-for="img in msg.images" :key="img"> <div class="image-thumbnail-wrapper" v-for="img in msg.images" :key="img">
<img :src="getPreviewUrl(img)" :alt="formatImageName(img)" class="image-thumbnail" /> <img
:src="getPreviewUrl(img)"
:alt="formatImageName(img)"
class="image-thumbnail"
/>
</div> </div>
</div> </div>
<div v-if="msg.videos && msg.videos.length" class="image-inline-row video-inline-row"> <div v-if="msg.videos && msg.videos.length" class="image-inline-row video-inline-row">
<div class="image-thumbnail-wrapper" v-for="video in msg.videos" :key="video"> <div class="image-thumbnail-wrapper" v-for="video in msg.videos" :key="video">
<img :src="getPreviewUrl(video)" :alt="formatImageName(video)" class="image-thumbnail" /> <img
:src="getPreviewUrl(video)"
:alt="formatImageName(video)"
class="image-thumbnail"
/>
</div> </div>
</div> </div>
</div> </div>
@ -32,10 +40,19 @@
<div v-else-if="msg.role === 'assistant'" class="assistant-message"> <div v-else-if="msg.role === 'assistant'" class="assistant-message">
<!-- assistant 消息前显示 AI Assistant 头部 --> <!-- assistant 消息前显示 AI Assistant 头部 -->
<!-- 只有当前一条消息是 user 且当前消息有内容时才显示 --> <!-- 只有当前一条消息是 user 且当前消息有内容时才显示 -->
<div v-if="index > 0 && filteredMessages[index - 1].role === 'user' && (hasRenderableAssistantActions(msg.actions || []) || msg.awaitingFirstContent)" class="message-header icon-label"> <div
v-if="
index > 0 &&
filteredMessages[index - 1].role === 'user' &&
(hasRenderableAssistantActions(msg.actions || []) || msg.awaitingFirstContent)
"
class="message-header icon-label"
>
<span class="icon icon-sm" :style="iconStyleSafe('bot')" aria-hidden="true"></span> <span class="icon icon-sm" :style="iconStyleSafe('bot')" aria-hidden="true"></span>
<span>{{ aiAssistantName }}</span> <span>{{ aiAssistantName }}</span>
<span v-if="assistantWorkLabel(index)" class="assistant-work-status">{{ assistantWorkLabel(index) }}</span> <span v-if="assistantWorkLabel(index)" class="assistant-work-status">{{
assistantWorkLabel(index)
}}</span>
</div> </div>
<div <div
v-if="msg.awaitingFirstContent" v-if="msg.awaitingFirstContent"
@ -77,7 +94,7 @@
/> />
</template> </template>
<template v-else-if="stackedBlocksEnabled"> <template v-else-if="stackedBlocksEnabled">
<template v-for="(group, groupIndex) in splitActionGroups(msg.actions || [], index)" :key="group.key"> <template v-for="group in splitActionGroups(msg.actions || [], index)" :key="group.key">
<StackedBlocks <StackedBlocks
v-if="group.kind === 'stack'" v-if="group.kind === 'stack'"
class="stacked-blocks-wrapper" class="stacked-blocks-wrapper"
@ -103,53 +120,90 @@
'streaming-content': group.action?.streaming, 'streaming-content': group.action?.streaming,
'completed-tool': group.action?.type === 'tool' && !group.action?.streaming, 'completed-tool': group.action?.type === 'tool' && !group.action?.streaming,
'immediate-show': 'immediate-show':
group.action?.streaming || group.action?.type === 'text' || group.action?.type === 'thinking', group.action?.streaming ||
group.action?.type === 'text' ||
group.action?.type === 'thinking',
'thinking-finished': group.action?.type === 'thinking' && !group.action?.streaming 'thinking-finished': group.action?.type === 'thinking' && !group.action?.streaming
}" }"
> >
<div <div
v-if="group.action?.type === 'thinking'" v-if="group.action?.type === 'thinking'"
class="collapsible-block thinking-block" class="collapsible-block thinking-block"
:class="{ expanded: expandedBlocks?.has(group.action.blockId || `${index}-thinking-${group.actionIndex}`) }" :class="{
expanded: expandedBlocks?.has(
group.action.blockId || `${index}-thinking-${group.actionIndex}`
)
}"
> >
<div class="collapsible-header" @click="toggleBlock(group.action.blockId || `${index}-thinking-${group.actionIndex}`)"> <div
class="collapsible-header"
@click="
toggleBlock(group.action.blockId || `${index}-thinking-${group.actionIndex}`)
"
>
<div class="arrow"></div> <div class="arrow"></div>
<div class="status-icon"> <div class="status-icon">
<span class="thinking-icon" :class="{ 'thinking-animation': group.action.streaming }"> <span
<span class="icon icon-sm" :style="iconStyleSafe('brain')" aria-hidden="true"></span> class="thinking-icon"
:class="{ 'thinking-animation': group.action.streaming }"
>
<span
class="icon icon-sm"
:style="iconStyleSafe('brain')"
aria-hidden="true"
></span>
</span> </span>
</div> </div>
<span class="status-text">{{ group.action.streaming ? '正在思考...' : '思考过程' }}</span> <span class="status-text">{{
group.action.streaming ? '正在思考...' : '思考过程'
}}</span>
</div> </div>
<div <div
class="collapsible-content" class="collapsible-content"
:ref="el => registerCollapseContent(group.action.blockId || `${index}-thinking-${group.actionIndex}`, el)" :ref="
(el) =>
registerCollapseContent(
group.action.blockId || `${index}-thinking-${group.actionIndex}`,
el
)
"
> >
<div <div
class="content-inner thinking-content" class="content-inner thinking-content"
:ref="el => registerThinkingRef(group.action.blockId || `${index}-thinking-${group.actionIndex}`, el)" :ref="
(el) =>
registerThinkingRef(
group.action.blockId || `${index}-thinking-${group.actionIndex}`,
el
)
"
@scroll=" @scroll="
handleThinkingScroll( handleThinkingScroll(
group.action.blockId || `${index}-thinking-${group.actionIndex}`, group.action.blockId || `${index}-thinking-${group.actionIndex}`,
$event $event
) )
" "
style="max-height: 240px; overflow-y: auto;" style="max-height: 240px; overflow-y: auto"
> >
{{ group.action.content }} {{ group.action.content }}
</div>
</div>
</div> </div>
</div>
</div>
<div v-else-if="group.action?.type === 'text'" class="text-output"> <div v-else-if="group.action?.type === 'text'" class="text-output">
<div class="text-content" :class="{ 'streaming-text': group.action.streaming }"> <div class="text-content" :class="{ 'streaming-text': group.action.streaming }">
<div v-if="group.action.streaming" v-html="renderMarkdown(group.action.content, true)"></div> <div
v-if="group.action.streaming"
v-html="renderMarkdown(group.action.content, true)"
></div>
<div v-else v-html="renderMarkdown(group.action.content, false)"></div> <div v-else v-html="renderMarkdown(group.action.content, false)"></div>
</div> </div>
</div> </div>
<div <div
v-else-if="group.action?.type === 'system' && getSubAgentSystemNoticeLabel(group.action)" v-else-if="
group.action?.type === 'system' && getSubAgentSystemNoticeLabel(group.action)
"
class="sub-agent-system-summary-line" class="sub-agent-system-summary-line"
> >
{{ getSubAgentSystemNoticeLabel(group.action) }} {{ getSubAgentSystemNoticeLabel(group.action) }}
@ -163,26 +217,56 @@
<div class="append-placeholder-content"> <div class="append-placeholder-content">
<template v-if="group.action.append?.success !== false"> <template v-if="group.action.append?.success !== false">
<div class="icon-label append-status"> <div class="icon-label append-status">
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span> <span
<span>已写入 {{ group.action.append?.path || '目标文件' }} 的追加内容内容已保存至文件</span> class="icon icon-sm"
:style="iconStyleSafe('pencil')"
aria-hidden="true"
></span>
<span
>已写入
{{ group.action.append?.path || '目标文件' }}
的追加内容内容已保存至文件</span
>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div class="icon-label append-status append-error-text"> <div class="icon-label append-status append-error-text">
<span class="icon icon-sm" :style="iconStyleSafe('x')" aria-hidden="true"></span> <span
<span> {{ group.action.append?.path || '目标文件' }} 写入失败内容已截获供后续修复</span> class="icon icon-sm"
:style="iconStyleSafe('x')"
aria-hidden="true"
></span>
<span
>
{{ group.action.append?.path || '目标文件' }}
写入失败内容已截获供后续修复</span
>
</div> </div>
</template> </template>
<div class="append-meta" v-if="group.action.append"> <div class="append-meta" v-if="group.action.append">
<span v-if="group.action.append.lines !== null && group.action.append.lines !== undefined"> <span
v-if="
group.action.append.lines !== null &&
group.action.append.lines !== undefined
"
>
· 行数 {{ group.action.append.lines }} · 行数 {{ group.action.append.lines }}
</span> </span>
<span v-if="group.action.append.bytes !== null && group.action.append.bytes !== undefined"> <span
v-if="
group.action.append.bytes !== null &&
group.action.append.bytes !== undefined
"
>
· 字节 {{ group.action.append.bytes }} · 字节 {{ group.action.append.bytes }}
</span> </span>
</div> </div>
<div class="append-warning icon-label" v-if="group.action.append?.forced"> <div class="append-warning icon-label" v-if="group.action.append?.forced">
<span class="icon icon-sm" :style="iconStyleSafe('triangleAlert')" aria-hidden="true"></span> <span
class="icon icon-sm"
:style="iconStyleSafe('triangleAlert')"
aria-hidden="true"
></span>
<span>未检测到结束标记请根据提示继续补充</span> <span>未检测到结束标记请根据提示继续补充</span>
</div> </div>
</div> </div>
@ -195,16 +279,28 @@
> >
<div class="append-placeholder-content"> <div class="append-placeholder-content">
<div class="icon-label append-status"> <div class="icon-label append-status">
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span> <span
class="icon icon-sm"
:style="iconStyleSafe('pencil')"
aria-hidden="true"
></span>
<span>{{ group.action.append?.summary || '文件追加完成' }}</span> <span>{{ group.action.append?.summary || '文件追加完成' }}</span>
</div> </div>
<div class="append-meta" v-if="group.action.append"> <div class="append-meta" v-if="group.action.append">
<span>{{ group.action.append.path || '目标文件' }}</span> <span>{{ group.action.append.path || '目标文件' }}</span>
<span v-if="group.action.append.lines">· 行数 {{ group.action.append.lines }}</span> <span v-if="group.action.append.lines"
<span v-if="group.action.append.bytes">· 字节 {{ group.action.append.bytes }}</span> >· 行数 {{ group.action.append.lines }}</span
>
<span v-if="group.action.append.bytes"
>· 字节 {{ group.action.append.bytes }}</span
>
</div> </div>
<div class="append-warning icon-label" v-if="group.action.append?.forced"> <div class="append-warning icon-label" v-if="group.action.append?.forced">
<span class="icon icon-sm" :style="iconStyleSafe('triangleAlert')" aria-hidden="true"></span> <span
class="icon icon-sm"
:style="iconStyleSafe('triangleAlert')"
aria-hidden="true"
></span>
<span>未检测到结束标记请按提示继续补充</span> <span>未检测到结束标记请按提示继续补充</span>
</div> </div>
</div> </div>
@ -213,172 +309,263 @@
<ToolAction <ToolAction
v-else-if="group.action?.type === 'tool'" v-else-if="group.action?.type === 'tool'"
:action="group.action" :action="group.action"
:expanded="expandedBlocks?.has(group.action.blockId || `${index}-tool-${group.actionIndex}`)" :expanded="
expandedBlocks?.has(
group.action.blockId || `${index}-tool-${group.actionIndex}`
)
"
:icon-style="iconStyleSafe" :icon-style="iconStyleSafe"
:get-tool-animation-class="getToolAnimationClass" :get-tool-animation-class="getToolAnimationClass"
:get-tool-icon="getToolIcon" :get-tool-icon="getToolIcon"
:get-tool-status-text="getToolStatusText" :get-tool-status-text="getToolStatusText"
:get-tool-description="getToolDescription" :get-tool-description="getToolDescription"
:format-search-topic="formatSearchTopic" :format-search-topic="formatSearchTopic"
:format-search-time="formatSearchTime" :format-search-time="formatSearchTime"
:format-search-domains="formatSearchDomains" :format-search-domains="formatSearchDomains"
:streaming-message="streamingMessage" :streaming-message="streamingMessage"
:register-collapse-content="registerCollapseContent" :register-collapse-content="registerCollapseContent"
:collapse-key="group.action.blockId || `${index}-tool-${group.actionIndex}`" :collapse-key="group.action.blockId || `${index}-tool-${group.actionIndex}`"
@toggle="toggleBlock(group.action.blockId || `${index}-tool-${group.actionIndex}`)" @toggle="
/> toggleBlock(group.action.blockId || `${index}-tool-${group.actionIndex}`)
"
/>
</div> </div>
</template> </template>
</template> </template>
<template v-else> <template v-else>
<div <template
v-for="(action, actionIndex) in msg.actions || []" v-for="(action, actionIndex) in msg.actions || []"
v-if="isActionVisible(action)"
:key="action.id || `${index}-${actionIndex}`" :key="action.id || `${index}-${actionIndex}`"
class="action-item"
:class="{
'streaming-content': action.streaming,
'completed-tool': action.type === 'tool' && !action.streaming,
'immediate-show': action.streaming || action.type === 'text' || action.type === 'thinking',
'thinking-finished': action.type === 'thinking' && !action.streaming
}"
> >
<div <div
v-if="action.type === 'thinking'" v-if="isActionVisible(action)"
class="collapsible-block thinking-block" class="action-item"
:class="{ expanded: expandedBlocks?.has(action.blockId || `${index}-thinking-${actionIndex}`) }" :class="{
'streaming-content': action.streaming,
'completed-tool': action.type === 'tool' && !action.streaming,
'immediate-show':
action.streaming || action.type === 'text' || action.type === 'thinking',
'thinking-finished': action.type === 'thinking' && !action.streaming
}"
> >
<div class="collapsible-header" @click="toggleBlock(action.blockId || `${index}-thinking-${actionIndex}`)">
<div class="arrow"></div>
<div class="status-icon">
<span class="thinking-icon" :class="{ 'thinking-animation': action.streaming }">
<span class="icon icon-sm" :style="iconStyleSafe('brain')" aria-hidden="true"></span>
</span>
</div>
<span class="status-text">{{ action.streaming ? '正在思考...' : '思考过程' }}</span>
</div>
<div <div
class="collapsible-content" v-if="action.type === 'thinking'"
:ref="el => registerCollapseContent(action.blockId || `${index}-thinking-${actionIndex}`, el)" class="collapsible-block thinking-block"
:class="{
expanded: expandedBlocks?.has(
action.blockId || `${index}-thinking-${actionIndex}`
)
}"
> >
<div <div
class="content-inner thinking-content" class="collapsible-header"
:ref="el => registerThinkingRef(action.blockId || `${index}-thinking-${actionIndex}`, el)" @click="toggleBlock(action.blockId || `${index}-thinking-${actionIndex}`)"
@scroll="
handleThinkingScroll(
action.blockId || `${index}-thinking-${actionIndex}`,
$event
)
"
style="max-height: 240px; overflow-y: auto;"
> >
{{ action.content }} <div class="arrow"></div>
<div class="status-icon">
<span
class="thinking-icon"
:class="{ 'thinking-animation': action.streaming }"
>
<span
class="icon icon-sm"
:style="iconStyleSafe('brain')"
aria-hidden="true"
></span>
</span>
</div>
<span class="status-text">{{
action.streaming ? '正在思考...' : '思考过程'
}}</span>
</div>
<div
class="collapsible-content"
:ref="
(el) =>
registerCollapseContent(
action.blockId || `${index}-thinking-${actionIndex}`,
el
)
"
>
<div
class="content-inner thinking-content"
:ref="
(el) =>
registerThinkingRef(
action.blockId || `${index}-thinking-${actionIndex}`,
el
)
"
@scroll="
handleThinkingScroll(
action.blockId || `${index}-thinking-${actionIndex}`,
$event
)
"
style="max-height: 240px; overflow-y: auto"
>
{{ action.content }}
</div>
</div> </div>
</div> </div>
</div>
<div v-else-if="action.type === 'text'" class="text-output"> <div v-else-if="action.type === 'text'" class="text-output">
<div class="text-content" :class="{ 'streaming-text': action.streaming }"> <div class="text-content" :class="{ 'streaming-text': action.streaming }">
<div v-if="action.streaming" v-html="renderMarkdown(action.content, true)"></div> <div
<div v-else v-html="renderMarkdown(action.content, false)"></div> v-if="action.streaming"
v-html="renderMarkdown(action.content, true)"
></div>
<div v-else v-html="renderMarkdown(action.content, false)"></div>
</div>
</div> </div>
</div>
<div <div
v-else-if="action.type === 'system' && getSubAgentSystemNoticeLabel(action)" v-else-if="action.type === 'system' && getSubAgentSystemNoticeLabel(action)"
class="sub-agent-system-summary-line" class="sub-agent-system-summary-line"
> >
{{ getSubAgentSystemNoticeLabel(action) }} {{ getSubAgentSystemNoticeLabel(action) }}
</div> </div>
<div <div
v-else-if="action.type === 'append_payload'" v-else-if="action.type === 'append_payload'"
class="append-placeholder" class="append-placeholder"
:class="{ 'append-error': action.append?.success === false }" :class="{ 'append-error': action.append?.success === false }"
> >
<div class="append-placeholder-content"> <div class="append-placeholder-content">
<template v-if="action.append?.success !== false"> <template v-if="action.append?.success !== false">
<div class="icon-label append-status">
<span
class="icon icon-sm"
:style="iconStyleSafe('pencil')"
aria-hidden="true"
></span>
<span
>已写入
{{ action.append?.path || '目标文件' }}
的追加内容内容已保存至文件</span
>
</div>
</template>
<template v-else>
<div class="icon-label append-status append-error-text">
<span
class="icon icon-sm"
:style="iconStyleSafe('x')"
aria-hidden="true"
></span>
<span
>
{{ action.append?.path || '目标文件' }}
写入失败内容已截获供后续修复</span
>
</div>
</template>
<div class="append-meta" v-if="action.append">
<span
v-if="action.append.lines !== null && action.append.lines !== undefined"
>
· 行数 {{ action.append.lines }}
</span>
<span
v-if="action.append.bytes !== null && action.append.bytes !== undefined"
>
· 字节 {{ action.append.bytes }}
</span>
</div>
<div class="append-warning icon-label" v-if="action.append?.forced">
<span
class="icon icon-sm"
:style="iconStyleSafe('triangleAlert')"
aria-hidden="true"
></span>
<span>未检测到结束标记请根据提示继续补充</span>
</div>
</div>
</div>
<div
v-else-if="action.type === 'append'"
class="append-placeholder"
:class="{ 'append-error': action.append?.success === false }"
>
<div class="append-placeholder-content">
<div class="icon-label append-status"> <div class="icon-label append-status">
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span> <span
<span>已写入 {{ action.append?.path || '目标文件' }} 的追加内容内容已保存至文件</span> class="icon icon-sm"
:style="iconStyleSafe('pencil')"
aria-hidden="true"
></span>
<span>{{ action.append?.summary || '文件追加完成' }}</span>
</div> </div>
</template> <div class="append-meta" v-if="action.append">
<template v-else> <span>{{ action.append.path || '目标文件' }}</span>
<div class="icon-label append-status append-error-text"> <span v-if="action.append.lines">· 行数 {{ action.append.lines }}</span>
<span class="icon icon-sm" :style="iconStyleSafe('x')" aria-hidden="true"></span> <span v-if="action.append.bytes">· 字节 {{ action.append.bytes }}</span>
<span> {{ action.append?.path || '目标文件' }} 写入失败内容已截获供后续修复</span> </div>
<div class="append-warning icon-label" v-if="action.append?.forced">
<span
class="icon icon-sm"
:style="iconStyleSafe('triangleAlert')"
aria-hidden="true"
></span>
<span>未检测到结束标记请按提示继续补充</span>
</div> </div>
</template>
<div class="append-meta" v-if="action.append">
<span v-if="action.append.lines !== null && action.append.lines !== undefined">
· 行数 {{ action.append.lines }}
</span>
<span v-if="action.append.bytes !== null && action.append.bytes !== undefined">
· 字节 {{ action.append.bytes }}
</span>
</div>
<div class="append-warning icon-label" v-if="action.append?.forced">
<span class="icon icon-sm" :style="iconStyleSafe('triangleAlert')" aria-hidden="true"></span>
<span>未检测到结束标记请根据提示继续补充</span>
</div> </div>
</div> </div>
</div>
<div <ToolAction
v-else-if="action.type === 'append'" v-else-if="action.type === 'tool'"
class="append-placeholder" :action="action"
:class="{ 'append-error': action.append?.success === false }" :expanded="expandedBlocks?.has(action.blockId || `${index}-tool-${actionIndex}`)"
> :icon-style="iconStyleSafe"
<div class="append-placeholder-content"> :get-tool-animation-class="getToolAnimationClass"
<div class="icon-label append-status"> :get-tool-icon="getToolIcon"
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span> :get-tool-status-text="getToolStatusText"
<span>{{ action.append?.summary || '文件追加完成' }}</span> :get-tool-description="getToolDescription"
</div> :format-search-topic="formatSearchTopic"
<div class="append-meta" v-if="action.append"> :format-search-time="formatSearchTime"
<span>{{ action.append.path || '目标文件' }}</span> :format-search-domains="formatSearchDomains"
<span v-if="action.append.lines">· 行数 {{ action.append.lines }}</span> :streaming-message="streamingMessage"
<span v-if="action.append.bytes">· 字节 {{ action.append.bytes }}</span> :register-collapse-content="registerCollapseContent"
</div> :collapse-key="action.blockId || `${index}-tool-${actionIndex}`"
<div class="append-warning icon-label" v-if="action.append?.forced"> @toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)"
<span class="icon icon-sm" :style="iconStyleSafe('triangleAlert')" aria-hidden="true"></span> />
<span>未检测到结束标记请按提示继续补充</span>
</div>
</div>
</div> </div>
</template>
<ToolAction
v-else-if="action.type === 'tool'"
:action="action"
:expanded="expandedBlocks?.has(action.blockId || `${index}-tool-${actionIndex}`)"
:icon-style="iconStyleSafe"
:get-tool-animation-class="getToolAnimationClass"
:get-tool-icon="getToolIcon"
:get-tool-status-text="getToolStatusText"
:get-tool-description="getToolDescription"
:format-search-topic="formatSearchTopic"
:format-search-time="formatSearchTime"
:format-search-domains="formatSearchDomains"
:streaming-message="streamingMessage"
:register-collapse-content="registerCollapseContent"
:collapse-key="action.blockId || `${index}-tool-${actionIndex}`"
@toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)"
/>
</div>
</template> </template>
</div> </div>
<div v-else class="system-message" @vue:mounted="() => console.log('[ChatArea] 渲染系统消息:', { index, role: msg.role, content: msg.content })"> <div
<div class="collapsible-block system-block" :class="{ expanded: expandedBlocks?.has(`system-${index}`) }"> v-else
class="system-message"
@vue:mounted="
() =>
console.log('[ChatArea] 渲染系统消息:', {
index,
role: msg.role,
content: msg.content
})
"
>
<div
class="collapsible-block system-block"
:class="{ expanded: expandedBlocks?.has(`system-${index}`) }"
>
<div class="collapsible-header" @click="toggleBlock(`system-${index}`)"> <div class="collapsible-header" @click="toggleBlock(`system-${index}`)">
<div class="arrow"></div> <div class="arrow"></div>
<div class="status-icon"> <div class="status-icon">
<span class="tool-icon icon icon-md" :style="iconStyleSafe('info')" aria-hidden="true"></span> <span
class="tool-icon icon icon-md"
:style="iconStyleSafe('info')"
aria-hidden="true"
></span>
</div> </div>
<span class="status-text">系统消息 (role: {{ msg.role }})</span> <span class="status-text">系统消息 (role: {{ msg.role }})</span>
</div> </div>
<div <div
class="collapsible-content" class="collapsible-content"
:ref="el => registerCollapseContent(`system-${index}`, el)" :ref="(el) => registerCollapseContent(`system-${index}`, el)"
> >
<div class="content-inner"> <div class="content-inner">
{{ msg.content }} {{ msg.content }}
@ -437,20 +624,29 @@ const userName = computed(() => {
}); });
const filteredMessages = computed(() => { const filteredMessages = computed(() => {
const source = props.messages || []; const source = props.messages || [];
const droppedRoleSystem = source.filter(m => m && m.role === 'system'); const droppedRoleSystem = source.filter((m) => m && m.role === 'system');
if (droppedRoleSystem.length > 0) { if (droppedRoleSystem.length > 0) {
console.log('[DEBUG_SYSTEM][render] filteredMessages 过滤了 role=system 消息', { console.log('[DEBUG_SYSTEM][render] filteredMessages 过滤了 role=system 消息', {
count: droppedRoleSystem.length, count: droppedRoleSystem.length,
samples: droppedRoleSystem.slice(0, 3) samples: droppedRoleSystem.slice(0, 3)
}); });
} }
return source.filter(m => !(m && m.metadata && m.metadata.system_injected_image) && m.role !== 'system'); return source.filter(
(m) => !(m && m.metadata && m.metadata.system_injected_image) && m.role !== 'system'
);
}); });
const latestMessageIndex = computed(() => filteredMessages.value.length - 1); const latestMessageIndex = computed(() => filteredMessages.value.length - 1);
const SUB_AGENT_DONE_LABEL_RE = /^子智能体\d+\s*任务完成$/; const SUB_AGENT_DONE_LABEL_RE = /^子智能体\d+\s*任务完成$/;
const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[:]/; const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[:]/;
const BG_RUN_COMMAND_DONE_LABEL_RE = /^(?:\[)?后台\s*run_command\s*完成(?:\])?$/; const BG_RUN_COMMAND_DONE_LABEL_RE = /^(?:\[)?后台\s*run_command\s*完成(?:\])?$/;
const RENDERABLE_ACTION_TYPES = new Set(['thinking', 'text', 'tool', 'append', 'append_payload', 'system']); const RENDERABLE_ACTION_TYPES = new Set([
'thinking',
'text',
'tool',
'append',
'append_payload',
'system'
]);
const nowMs = ref(Date.now()); const nowMs = ref(Date.now());
let timerHandle: number | null = null; let timerHandle: number | null = null;
const debugLoggedSystemActionKeys = new Set<string>(); const debugLoggedSystemActionKeys = new Set<string>();
@ -525,7 +721,10 @@ function getSubAgentSystemNoticeLabel(action: any): string | null {
} }
return null; return null;
} }
if (action.variant === 'sub_agent_done' && (SUB_AGENT_DONE_LABEL_RE.test(content) || BG_RUN_COMMAND_DONE_LABEL_RE.test(content))) { if (
action.variant === 'sub_agent_done' &&
(SUB_AGENT_DONE_LABEL_RE.test(content) || BG_RUN_COMMAND_DONE_LABEL_RE.test(content))
) {
if (!debugLoggedSystemActionKeys.has(`${key}-variant-pass`)) { if (!debugLoggedSystemActionKeys.has(`${key}-variant-pass`)) {
console.log('[DEBUG_SYSTEM][render] 命中 variant 渲染', { action, label: content }); console.log('[DEBUG_SYSTEM][render] 命中 variant 渲染', { action, label: content });
debugLoggedSystemActionKeys.add(`${key}-variant-pass`); debugLoggedSystemActionKeys.add(`${key}-variant-pass`);
@ -535,7 +734,10 @@ function getSubAgentSystemNoticeLabel(action: any): string | null {
const m = content.match(SUB_AGENT_DONE_PREFIX_RE); const m = content.match(SUB_AGENT_DONE_PREFIX_RE);
if (m && /已完成/.test(content)) { if (m && /已完成/.test(content)) {
if (!debugLoggedSystemActionKeys.has(`${key}-regex-pass`)) { if (!debugLoggedSystemActionKeys.has(`${key}-regex-pass`)) {
console.log('[DEBUG_SYSTEM][render] 命中 regex 渲染', { action, normalized: `子智能体${m[1]} 任务完成` }); console.log('[DEBUG_SYSTEM][render] 命中 regex 渲染', {
action,
normalized: `子智能体${m[1]} 任务完成`
});
debugLoggedSystemActionKeys.add(`${key}-regex-pass`); debugLoggedSystemActionKeys.add(`${key}-regex-pass`);
} }
return `子智能体${m[1]} 任务完成`; return `子智能体${m[1]} 任务完成`;
@ -614,9 +816,15 @@ function assistantWorkLabel(index: number): string {
if (status === 'working') { if (status === 'working') {
return `工作中 ${formatDurationMs(nowMs.value - startMs)}`; return `工作中 ${formatDurationMs(nowMs.value - startMs)}`;
} }
const durationMs = typeof timer.duration_ms === 'number' const durationMs =
? timer.duration_ms typeof timer.duration_ms === 'number'
: Math.max(0, (Number.isFinite(Date.parse(timer.finished_at || '')) ? Date.parse(timer.finished_at) : nowMs.value) - startMs); ? timer.duration_ms
: Math.max(
0,
(Number.isFinite(Date.parse(timer.finished_at || ''))
? Date.parse(timer.finished_at)
: nowMs.value) - startMs
);
return `工作完成 ${formatDurationMs(durationMs)}`; return `工作完成 ${formatDurationMs(durationMs)}`;
} }
@ -633,7 +841,8 @@ onBeforeUnmount(() => {
} }
}); });
const isStackable = (action: any) => action && (action.type === 'thinking' || action.type === 'tool'); const isStackable = (action: any) =>
action && (action.type === 'thinking' || action.type === 'tool');
const isEmptyTextAction = (action: any) => { const isEmptyTextAction = (action: any) => {
if (!action || action.type !== 'text') { if (!action || action.type !== 'text') {
return false; return false;
@ -652,7 +861,7 @@ const splitActionGroups = (actions: any[] = [], messageIndex = 0) => {
if (actions.length > 0) { if (actions.length > 0) {
console.log(`[splitActionGroups] 消息 ${messageIndex}:`, { console.log(`[splitActionGroups] 消息 ${messageIndex}:`, {
totalActions: actions.length, totalActions: actions.length,
actionTypes: actions.map(a => a.type), actionTypes: actions.map((a) => a.type),
stackedBlocksEnabled: props.stackedBlocksEnabled stackedBlocksEnabled: props.stackedBlocksEnabled
}); });
} }
@ -698,7 +907,7 @@ const splitActionGroups = (actions: any[] = [], messageIndex = 0) => {
if (actions.length > 0) { if (actions.length > 0) {
console.log(`[splitActionGroups] 消息 ${messageIndex} 结果:`, { console.log(`[splitActionGroups] 消息 ${messageIndex} 结果:`, {
totalGroups: result.length, totalGroups: result.length,
groups: result.map(g => ({ groups: result.map((g) => ({
kind: g.kind, kind: g.kind,
actionsCount: g.kind === 'stack' ? g.actions.length : 1, actionsCount: g.kind === 'stack' ? g.actions.length : 1,
actionType: g.kind === 'stack' ? g.actions[0]?.type : g.action?.type actionType: g.kind === 'stack' ? g.actions[0]?.type : g.action?.type

View File

@ -29,8 +29,20 @@
</div> </div>
<!-- 加载动画或完成图标 --> <!-- 加载动画或完成图标 -->
<div class="summary-status-icon"> <div class="summary-status-icon">
<component v-if="isSummaryRunning(group.actions, group.id)" :is="getSummaryLoader(group.id)" /> <component
<svg v-else class="check-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> v-if="isSummaryRunning(group.actions, group.id)"
:is="getSummaryLoader(group.id)"
/>
<svg
v-else
class="check-icon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
<polyline points="7.5 12 10.5 15 16.5 9" /> <polyline points="7.5 12 10.5 15 16.5 9" />
</svg> </svg>
@ -40,11 +52,7 @@
<!-- 步骤容器展开时显示所有步骤 --> <!-- 步骤容器展开时显示所有步骤 -->
<div class="steps-container" :class="{ show: expandedGroups.has(group.id) }"> <div class="steps-container" :class="{ show: expandedGroups.has(group.id) }">
<div class="steps-wrapper"> <div class="steps-wrapper">
<div <div v-for="step in getSummarySteps(group.actions)" :key="step.id" class="step-item">
v-for="(step, idx) in getSummarySteps(group.actions)"
:key="step.id"
class="step-item"
>
<div class="step-timeline"> <div class="step-timeline">
<span <span
class="step-icon icon icon-sm" class="step-icon icon icon-sm"
@ -57,14 +65,16 @@
<div v-if="step.type === 'thinking'" class="step-body"> <div v-if="step.type === 'thinking'" class="step-body">
<div <div
class="thinking-content" class="thinking-content"
:ref="el => registerThinking(step.id, el)" :ref="(el) => registerThinking(step.id, el)"
@scroll="handleScroll(step.id, $event)" @scroll="handleScroll(step.id, $event)"
v-html="renderContent(step.content)" v-html="renderContent(step.content)"
></div> ></div>
</div> </div>
<div v-else-if="step.type === 'tool'" class="step-body"> <div v-else-if="step.type === 'tool'" class="step-body">
<div class="step-header">{{ getToolName(step.action) }}</div> <div class="step-header">{{ getToolName(step.action) }}</div>
<div class="tool-intent" v-if="getToolIntent(step.action)">{{ getToolIntent(step.action) }}</div> <div class="tool-intent" v-if="getToolIntent(step.action)">
{{ getToolIntent(step.action) }}
</div>
<div class="tool-result" v-if="step.action.tool?.result"> <div class="tool-result" v-if="step.action.tool?.result">
<div v-html="renderToolResult(step.action)"></div> <div v-html="renderToolResult(step.action)"></div>
</div> </div>
@ -79,7 +89,11 @@
</div> </div>
<!-- 文本组 --> <!-- 文本组 -->
<div v-else-if="group.type === 'text'" class="text-output" :class="{ 'streaming-text': group.streaming }"> <div
v-else-if="group.type === 'text'"
class="text-output"
:class="{ 'streaming-text': group.streaming }"
>
<div class="text-content" :class="{ 'streaming-text': group.streaming }"> <div class="text-content" :class="{ 'streaming-text': group.streaming }">
<div v-if="group.streaming" v-html="renderMarkdown(group.content || '', true)"></div> <div v-if="group.streaming" v-html="renderMarkdown(group.content || '', true)"></div>
<div v-else v-html="renderMarkdown(group.content || '', false)"></div> <div v-else v-html="renderMarkdown(group.content || '', false)"></div>
@ -91,7 +105,15 @@
<span class="summary-preview">{{ group.content || '' }}</span> <span class="summary-preview">{{ group.content || '' }}</span>
</div> </div>
<div class="summary-status-icon"> <div class="summary-status-icon">
<svg class="check-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
class="check-icon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
<polyline points="7.5 12 10.5 15 16.5 9" /> <polyline points="7.5 12 10.5 15 16.5 9" />
</svg> </svg>
@ -172,11 +194,11 @@ const getAnimatedSummaryChars = (text: string) => Array.from(text || '');
// action // action
const getSummaryLoader = (groupId: string) => { const getSummaryLoader = (groupId: string) => {
const group = blockGroups.value.find(g => g.id === groupId); const group = blockGroups.value.find((g) => g.id === groupId);
if (!group || !group.actions) return getRandomLoader(); if (!group || !group.actions) return getRandomLoader();
// streamingactionaction // streamingactionaction
const streamingAction = group.actions.find(a => a.streaming); const streamingAction = group.actions.find((a) => a.streaming);
const currentAction = streamingAction || group.actions[group.actions.length - 1]; const currentAction = streamingAction || group.actions[group.actions.length - 1];
if (!currentAction) return getRandomLoader(); if (!currentAction) return getRandomLoader();
@ -196,9 +218,10 @@ const getSummaryLoader = (groupId: string) => {
// 使""key // 使""key
// actionkey // actionkey
// action使key // action使key
const typeSegmentKey = lastDifferentTypeIndex >= 0 const typeSegmentKey =
? `${group.actions[lastDifferentTypeIndex].id}-${currentAction.type}` lastDifferentTypeIndex >= 0
: `start-${currentAction.type}`; ? `${group.actions[lastDifferentTypeIndex].id}-${currentAction.type}`
: `start-${currentAction.type}`;
const cacheKey = `${groupId}-${typeSegmentKey}`; const cacheKey = `${groupId}-${typeSegmentKey}`;
@ -221,7 +244,8 @@ const blockGroups = computed(() => {
// thinking/tool // thinking/tool
if (currentGroup.length > 0) { if (currentGroup.length > 0) {
// 使 action id group id // 使 action id group id
const firstActionId = currentGroup[0].id || currentGroup[0].blockId || `summary-${groupIndex}`; const firstActionId =
currentGroup[0].id || currentGroup[0].blockId || `summary-${groupIndex}`;
groups.push({ groups.push({
type: 'summary', type: 'summary',
id: `summary-${firstActionId}`, id: `summary-${firstActionId}`,
@ -270,7 +294,7 @@ const blockGroups = computed(() => {
// / // /
const getSummaryPreview = (actions: Action[]) => { const getSummaryPreview = (actions: Action[]) => {
// streaming // streaming
const streamingActions = actions.filter(a => a.streaming); const streamingActions = actions.filter((a) => a.streaming);
let currentStep; let currentStep;
if (streamingActions.length > 0) { if (streamingActions.length > 0) {
@ -278,7 +302,7 @@ const getSummaryPreview = (actions: Action[]) => {
currentStep = streamingActions[streamingActions.length - 1]; currentStep = streamingActions[streamingActions.length - 1];
} else { } else {
// streaming // streaming
const toolActions = actions.filter(a => a.type === 'tool'); const toolActions = actions.filter((a) => a.type === 'tool');
if (toolActions.length > 0) { if (toolActions.length > 0) {
currentStep = toolActions[toolActions.length - 1]; currentStep = toolActions[toolActions.length - 1];
} else { } else {
@ -332,11 +356,11 @@ const isSummaryRunning = (actions: Action[], groupId: string) => {
} }
// group blockGroups // group blockGroups
const currentIndex = blockGroups.value.findIndex(g => g.id === groupId); const currentIndex = blockGroups.value.findIndex((g) => g.id === groupId);
if (currentIndex === -1) return false; if (currentIndex === -1) return false;
// //
const hasTextAfter = blockGroups.value.slice(currentIndex + 1).some(g => g.type === 'text'); const hasTextAfter = blockGroups.value.slice(currentIndex + 1).some((g) => g.type === 'text');
// //
// 使 // 使
@ -360,9 +384,9 @@ const toggleExpand = (groupId: string) => {
// ref // ref
nextTick(() => { nextTick(() => {
const group = blockGroups.value.find(g => g.id === groupId); const group = blockGroups.value.find((g) => g.id === groupId);
if (group && group.actions) { if (group && group.actions) {
group.actions.forEach(action => { group.actions.forEach((action) => {
if (action.type === 'thinking') { if (action.type === 'thinking') {
const blockId = action.id || action.blockId; const blockId = action.id || action.blockId;
if (blockId && typeof props.registerThinkingRef === 'function') { if (blockId && typeof props.registerThinkingRef === 'function') {
@ -377,9 +401,9 @@ const toggleExpand = (groupId: string) => {
// ref // ref
nextTick(() => { nextTick(() => {
const group = blockGroups.value.find(g => g.id === groupId); const group = blockGroups.value.find((g) => g.id === groupId);
if (group && group.actions) { if (group && group.actions) {
group.actions.forEach(action => { group.actions.forEach((action) => {
if (action.type === 'thinking') { if (action.type === 'thinking') {
const blockId = action.id || action.blockId; const blockId = action.id || action.blockId;
if (blockId) { if (blockId) {
@ -487,8 +511,9 @@ const registerThinking = (key: string, el: Element | null) => {
} }
// //
const group = blockGroups.value.find(g => const group = blockGroups.value.find(
g.type === 'summary' && g.actions && g.actions.some(a => (a.id === key || a.blockId === key)) (g) =>
g.type === 'summary' && g.actions && g.actions.some((a) => a.id === key || a.blockId === key)
); );
// //
@ -505,9 +530,13 @@ const handleScroll = (blockId: string, event: Event) => {
} }
}; };
watch(() => props.actions, () => { watch(
// () => props.actions,
}, { deep: true }); () => {
//
},
{ deep: true }
);
</script> </script>
<style scoped> <style scoped>

View File

@ -30,94 +30,129 @@
:style="{ transform: `translateY(${innerOffset}px)` }" :style="{ transform: `translateY(${innerOffset}px)` }"
appear appear
> >
<div v-for="(action, idx) in stackableActions" :key="blockKey(action, idx)" class="stacked-item">
<div <div
v-if="action.type === 'thinking'" v-for="(action, idx) in stackableActions"
class="collapsible-block thinking-block stacked-block" :key="blockKey(action, idx)"
:class="{ expanded: isExpanded(action, idx), processing: action.streaming }" class="stacked-item"
> >
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))"> <div
<div class="arrow"></div> v-if="action.type === 'thinking'"
<div class="status-icon"> class="collapsible-block thinking-block stacked-block"
<span class="thinking-icon" :class="{ 'thinking-animation': action.streaming }"> :class="{ expanded: isExpanded(action, idx), processing: action.streaming }"
<span class="icon icon-sm" :style="iconStyle('brain')" aria-hidden="true"></span> >
</span> <div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
<div class="arrow"></div>
<div class="status-icon">
<span class="thinking-icon" :class="{ 'thinking-animation': action.streaming }">
<span class="icon icon-sm" :style="iconStyle('brain')" aria-hidden="true"></span>
</span>
</div>
<span class="status-text">{{ action.streaming ? '正在思考...' : '思考过程' }}</span>
</div> </div>
<span class="status-text">{{ action.streaming ? '正在思考...' : '思考过程' }}</span> <div class="collapsible-content" :style="contentStyle(blockKey(action, idx))">
</div> <div
<div class="collapsible-content" :style="contentStyle(blockKey(action, idx))"> class="content-inner thinking-content"
<div :ref="(el) => registerThinking(blockKey(action, idx), el)"
class="content-inner thinking-content" @scroll="handleThinkingScrollInternal(blockKey(action, idx), $event)"
:ref="el => registerThinking(blockKey(action, idx), el)" style="max-height: 240px; overflow-y: auto"
@scroll="handleThinkingScrollInternal(blockKey(action, idx), $event)" >
style="max-height: 240px; overflow-y: auto;" {{ action.content }}
> </div>
{{ action.content }}
</div> </div>
</div> </div>
</div>
<div <div
v-else-if="action.type === 'tool'" v-else-if="action.type === 'tool'"
class="collapsible-block tool-block stacked-block" class="collapsible-block tool-block stacked-block"
:class="{ :class="{
expanded: isExpanded(action, idx), expanded: isExpanded(action, idx),
processing: isToolProcessing(action), processing: isToolProcessing(action),
completed: isToolCompleted(action) completed: isToolCompleted(action)
}" }"
> >
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))"> <div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
<div class="arrow"></div> <div class="arrow"></div>
<div class="status-icon"> <div class="status-icon">
<span <span
class="tool-icon icon icon-md" class="tool-icon icon icon-md"
:class="getToolAnimationClass(action.tool)" :class="getToolAnimationClass(action.tool)"
:style="iconStyle(getToolIcon(action.tool))" :style="iconStyle(getToolIcon(action.tool))"
aria-hidden="true" aria-hidden="true"
></span> ></span>
</div>
<span class="status-text">{{ getToolStatusText(action.tool) }}</span>
<span class="tool-desc">{{ getToolDescription(action.tool) }}</span>
</div> </div>
<span class="status-text">{{ getToolStatusText(action.tool) }}</span> <div class="collapsible-content" :style="contentStyle(blockKey(action, idx))">
<span class="tool-desc">{{ getToolDescription(action.tool) }}</span> <div class="content-inner">
</div> <div
<div class="collapsible-content" :style="contentStyle(blockKey(action, idx))"> v-if="shouldUseEnhancedDisplay && renderToolResult(action)"
<div class="content-inner"> v-html="renderToolResult(action)"
<div v-if="shouldUseEnhancedDisplay && renderToolResult(action)" v-html="renderToolResult(action)"></div> ></div>
<div v-else-if="!shouldUseEnhancedDisplay && action.tool?.name === 'web_search' && action.tool?.result"> <div
<div class="search-meta"> v-else-if="
<div><strong>搜索内容</strong>{{ action.tool.result.query || action.tool.arguments?.query }}</div> !shouldUseEnhancedDisplay &&
<div><strong>主题</strong>{{ formatSearchTopic(action.tool.result.filters || {}) }}</div> action.tool?.name === 'web_search' &&
<div><strong>时间范围</strong>{{ formatSearchTime(action.tool.result.filters || {}) }}</div> action.tool?.result
<div><strong>限定网站</strong>{{ formatSearchDomains(action.tool.result.filters || {}) }}</div> "
<div><strong>结果数量</strong>{{ action.tool.result.total_results }}</div> >
</div> <div class="search-meta">
<div v-if="action.tool.result.results && action.tool.result.results.length" class="search-result-list"> <div>
<div v-for="item in action.tool.result.results" :key="item.url || item.index" class="search-result-item"> <strong>搜索内容</strong
<div class="search-result-title">{{ item.title || '无标题' }}</div> >{{ action.tool.result.query || action.tool.arguments?.query }}
<div class="search-result-url"> </div>
<a v-if="item.url" :href="item.url" target="_blank">{{ item.url }}</a><span v-else>无可用链接</span> <div>
<strong>主题</strong
>{{ formatSearchTopic(action.tool.result.filters || {}) }}
</div>
<div>
<strong>时间范围</strong
>{{ formatSearchTime(action.tool.result.filters || {}) }}
</div>
<div>
<strong>限定网站</strong
>{{ formatSearchDomains(action.tool.result.filters || {}) }}
</div>
<div><strong>结果数量</strong>{{ action.tool.result.total_results }}</div>
</div>
<div
v-if="action.tool.result.results && action.tool.result.results.length"
class="search-result-list"
>
<div
v-for="item in action.tool.result.results"
:key="item.url || item.index"
class="search-result-item"
>
<div class="search-result-title">{{ item.title || '无标题' }}</div>
<div class="search-result-url">
<a v-if="item.url" :href="item.url" target="_blank">{{ item.url }}</a
><span v-else>无可用链接</span>
</div>
</div> </div>
</div> </div>
<div v-else class="search-empty">未返回详细的搜索结果</div>
</div> </div>
<div v-else class="search-empty">未返回详细的搜索结果</div> <div v-else-if="action.tool?.name === 'run_python' && action.tool?.result">
</div> <div class="code-block">
<div v-else-if="action.tool?.name === 'run_python' && action.tool?.result"> <div class="code-label">代码</div>
<div class="code-block"> <pre><code class="language-python">{{ action.tool.result.code || action.tool.arguments?.code }}</code></pre>
<div class="code-label">代码</div> </div>
<pre><code class="language-python">{{ action.tool.result.code || action.tool.arguments?.code }}</code></pre> <div v-if="action.tool.result.output" class="output-block">
<div class="output-label">输出</div>
<pre>{{ action.tool.result.output }}</pre>
</div>
</div> </div>
<div v-if="action.tool.result.output" class="output-block"> <div v-else>
<div class="output-label">输出</div> <pre>{{
<pre>{{ action.tool.result.output }}</pre> JSON.stringify(action.tool?.result || action.tool?.arguments, null, 2)
}}</pre>
</div> </div>
</div> </div>
<div v-else>
<pre>{{ JSON.stringify(action.tool?.result || action.tool?.arguments, null, 2) }}</pre>
</div>
</div> </div>
<div v-if="isToolProcessing(action)" class="progress-indicator"></div>
</div> </div>
<div v-if="isToolProcessing(action)" class="progress-indicator"></div>
</div> </div>
</div>
</TransitionGroup> </TransitionGroup>
</div> </div>
</div> </div>
@ -154,7 +189,12 @@ const shouldUseEnhancedDisplay = computed(() => {
}); });
const renderToolResult = (action: any) => { const renderToolResult = (action: any) => {
return renderEnhancedToolResult(action, props.formatSearchTopic, props.formatSearchTime, props.formatSearchDomains); return renderEnhancedToolResult(
action,
props.formatSearchTopic,
props.formatSearchTime,
props.formatSearchDomains
);
}; };
const VISIBLE_LIMIT = 6; const VISIBLE_LIMIT = 6;
@ -175,7 +215,9 @@ let syncRaf: number | null = null;
let syncUntil = 0; let syncUntil = 0;
const isMoreAnimating = ref(false); const isMoreAnimating = ref(false);
const stackableActions = computed(() => (props.actions || []).filter((item) => item && (item.type === 'thinking' || item.type === 'tool'))); const stackableActions = computed(() =>
(props.actions || []).filter((item) => item && (item.type === 'thinking' || item.type === 'tool'))
);
const hiddenCount = computed(() => Math.max(0, stackableActions.value.length - VISIBLE_LIMIT)); const hiddenCount = computed(() => Math.max(0, stackableActions.value.length - VISIBLE_LIMIT));
const moreVisible = computed(() => hiddenCount.value > 0 || showAll.value); const moreVisible = computed(() => hiddenCount.value > 0 || showAll.value);
@ -268,7 +310,8 @@ const setShellMetrics = () => {
moreHeight.value = moreVisible.value ? moreBaseHeight() : 0; moreHeight.value = moreVisible.value ? moreBaseHeight() : 0;
const targetShell = moreHeight.value + (showAll.value || !moreVisible.value ? totalHeight : windowHeight); const targetShell =
moreHeight.value + (showAll.value || !moreVisible.value ? totalHeight : windowHeight);
const targetOffset = showAll.value || !moreVisible.value ? 0 : -hiddenHeight; const targetOffset = showAll.value || !moreVisible.value ? 0 : -hiddenHeight;
shellHeight.value = targetShell; shellHeight.value = targetShell;

View File

@ -3,16 +3,16 @@
<div class="monitor-stage"> <div class="monitor-stage">
<div class="monitor"> <div class="monitor">
<div class="monitor-shell"> <div class="monitor-shell">
<div class="monitor-top"> <div class="monitor-top">
<div class="monitor-brand">Agent Display Surface</div> <div class="monitor-brand">Agent Display Surface</div>
<div class="status-pill-group"> <div class="status-pill-group">
<div class="status-pill">{{ statusLabel }}</div> <div class="status-pill">{{ statusLabel }}</div>
<div class="progress-pill" v-if="currentProgressLabel"> <div class="progress-pill" v-if="currentProgressLabel">
<span class="pulse-dot"></span> <span class="pulse-dot"></span>
<span class="progress-text">{{ currentProgressLabel }}</span> <span class="progress-text">{{ currentProgressLabel }}</span>
</div>
</div> </div>
</div> </div>
</div>
<div class="monitor-screen" ref="screenEl"> <div class="monitor-screen" ref="screenEl">
<div class="desktop-layer"> <div class="desktop-layer">
<div class="desktop-section apps"> <div class="desktop-section apps">
@ -167,11 +167,19 @@
</div> </div>
<div class="wait-body"> <div class="wait-body">
<div class="flip-clock" ref="waitDisplay"> <div class="flip-clock" ref="waitDisplay">
<div class="flip-digit" data-value="0"><span class="top">0</span><span class="bottom">0</span></div> <div class="flip-digit" data-value="0">
<div class="flip-digit" data-value="0"><span class="top">0</span><span class="bottom">0</span></div> <span class="top">0</span><span class="bottom">0</span>
</div>
<div class="flip-digit" data-value="0">
<span class="top">0</span><span class="bottom">0</span>
</div>
<div class="flip-separator">:</div> <div class="flip-separator">:</div>
<div class="flip-digit" data-value="0"><span class="top">0</span><span class="bottom">0</span></div> <div class="flip-digit" data-value="0">
<div class="flip-digit" data-value="0"><span class="top">0</span><span class="bottom">0</span></div> <span class="top">0</span><span class="bottom">0</span>
</div>
<div class="flip-digit" data-value="0">
<span class="top">0</span><span class="bottom">0</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -297,21 +305,21 @@ const bubbleIconSlot = ref<HTMLElement | null>(null);
const bubbleTextSlot = ref<HTMLElement | null>(null); const bubbleTextSlot = ref<HTMLElement | null>(null);
const mousePointer = ref<HTMLElement | null>(null); const mousePointer = ref<HTMLElement | null>(null);
const mouseIcon = new URL('../../icons/mouse-pointer-2.svg', import.meta.url).href; const mouseIcon = new URL('../../../icons/mouse-pointer-2.svg', import.meta.url).href;
const assets = { const assets = {
brainIcon: new URL('../../icons/brain.svg', import.meta.url).href, brainIcon: new URL('../../../icons/brain.svg', import.meta.url).href,
folderIcon: new URL('../../icons/folder.svg', import.meta.url).href, folderIcon: new URL('../../../icons/folder.svg', import.meta.url).href,
folderOpenIcon: new URL('../../icons/folder-open.svg', import.meta.url).href, folderOpenIcon: new URL('../../../icons/folder-open.svg', import.meta.url).href,
fileIcon: new URL('../../icons/file.svg', import.meta.url).href, fileIcon: new URL('../../../icons/file.svg', import.meta.url).href,
apps: { apps: {
browser: new URL('../../icons/globe.svg', import.meta.url).href, browser: new URL('../../../icons/globe.svg', import.meta.url).href,
terminal: new URL('../../icons/laptop.svg', import.meta.url).href, terminal: new URL('../../../icons/laptop.svg', import.meta.url).href,
command: new URL('../../icons/terminal.svg', import.meta.url).href, command: new URL('../../../icons/terminal.svg', import.meta.url).href,
python: new URL('../../icons/python.svg', import.meta.url).href, python: new URL('../../../icons/python.svg', import.meta.url).href,
memory: new URL('../../icons/sticky-note.svg', import.meta.url).href, memory: new URL('../../../icons/sticky-note.svg', import.meta.url).href,
todo: new URL('../../icons/clipboard.svg', import.meta.url).href, todo: new URL('../../../icons/clipboard.svg', import.meta.url).href,
subagent: new URL('../../icons/bot.svg', import.meta.url).href subagent: new URL('../../../icons/bot.svg', import.meta.url).href
} }
}; };
@ -398,7 +406,7 @@ onMounted(() => {
// / // /
watch( watch(
lastTreeSnapshot, lastTreeSnapshot,
roots => { (roots) => {
if (director.value && Array.isArray(roots)) { if (director.value && Array.isArray(roots)) {
director.value.setDesktopRoots(roots, { immediate: true }); director.value.setDesktopRoots(roots, { immediate: true });
} }

View File

@ -1,7 +1,11 @@
<template> <template>
<div class="action-summary"> <div class="action-summary">
<div class="summary-header icon-label"> <div class="summary-header icon-label">
<span class="icon icon-sm" :style="iconStyle(action.type === 'apply' ? 'pencil' : 'clipboard')" aria-hidden="true"></span> <span
class="icon icon-sm"
:style="iconStyle(action.type === 'apply' ? 'pencil' : 'clipboard')"
aria-hidden="true"
></span>
<span>{{ action.title || (action.type === 'apply' ? '应用修改' : '追加内容') }}</span> <span>{{ action.title || (action.type === 'apply' ? '应用修改' : '追加内容') }}</span>
</div> </div>
<div class="summary-content" v-if="action.content"> <div class="summary-content" v-if="action.content">
@ -9,9 +13,30 @@
</div> </div>
<div class="summary-actions"> <div class="summary-actions">
<button type="button" class="ghost" @click="$emit('copy', action, blockId)">复制</button> <button type="button" class="ghost" @click="$emit('copy', action, blockId)">复制</button>
<button type="button" class="ghost" v-if="action.type === 'apply'" @click="$emit('apply', action, blockId)">应用</button> <button
<button type="button" class="ghost" v-if="action.type === 'append'" @click="$emit('run', action, blockId)">执行</button> type="button"
<button v-if="action.path" type="button" class="ghost" @click="$emit('download', action.path)">下载文件</button> class="ghost"
v-if="action.type === 'apply'"
@click="$emit('apply', action, blockId)"
>
应用
</button>
<button
type="button"
class="ghost"
v-if="action.type === 'append'"
@click="$emit('run', action, blockId)"
>
执行
</button>
<button
v-if="action.path"
type="button"
class="ghost"
@click="$emit('download', action.path)"
>
下载文件
</button>
</div> </div>
</div> </div>
</template> </template>

View File

@ -15,8 +15,12 @@
</template> </template>
<div class="append-meta" v-if="action.append"> <div class="append-meta" v-if="action.append">
<span v-if="action.append.path">{{ action.append.path }}</span> <span v-if="action.append.path">{{ action.append.path }}</span>
<span v-if="action.append.lines !== null && action.append.lines !== undefined">· 行数 {{ action.append.lines }}</span> <span v-if="action.append.lines !== null && action.append.lines !== undefined"
<span v-if="action.append.bytes !== null && action.append.bytes !== undefined">· 字节 {{ action.append.bytes }}</span> >· 行数 {{ action.append.lines }}</span
>
<span v-if="action.append.bytes !== null && action.append.bytes !== undefined"
>· 字节 {{ action.append.bytes }}</span
>
</div> </div>
<div class="append-warning icon-label" v-if="action.append?.forced"> <div class="append-warning icon-label" v-if="action.append?.forced">
<span class="icon icon-sm" :style="iconStyle('triangleAlert')" aria-hidden="true"></span> <span class="icon icon-sm" :style="iconStyle('triangleAlert')" aria-hidden="true"></span>

View File

@ -18,9 +18,15 @@
<pre class="entry-content">{{ entry.content }}</pre> <pre class="entry-content">{{ entry.content }}</pre>
</div> </div>
<div class="modify-meta" v-if="action.modify"> <div class="modify-meta" v-if="action.modify">
<span v-if="action.modify.total !== null && action.modify.total !== undefined">· {{ action.modify.total }} </span> <span v-if="action.modify.total !== null && action.modify.total !== undefined"
<span v-if="action.modify.completed && action.modify.completed.length">· 已完成 {{ action.modify.completed.length }} </span> >· {{ action.modify.total }} </span
<span v-if="action.modify.failed && action.modify.failed.length">· 未完成 {{ action.modify.failed.length }} </span> >
<span v-if="action.modify.completed && action.modify.completed.length"
>· 已完成 {{ action.modify.completed.length }} </span
>
<span v-if="action.modify.failed && action.modify.failed.length"
>· 未完成 {{ action.modify.failed.length }} </span
>
</div> </div>
<div class="modify-warning icon-label" v-if="action.modify?.forced"> <div class="modify-warning icon-label" v-if="action.modify?.forced">
<span class="icon icon-sm" :style="iconStyle('triangleAlert')" aria-hidden="true"></span> <span class="icon icon-sm" :style="iconStyle('triangleAlert')" aria-hidden="true"></span>

View File

@ -7,8 +7,12 @@
</div> </div>
<div class="modify-meta" v-if="action.modify"> <div class="modify-meta" v-if="action.modify">
<span v-if="action.modify.total">· {{ action.modify.total }} </span> <span v-if="action.modify.total">· {{ action.modify.total }} </span>
<span v-if="action.modify.completed">· 完成 {{ action.modify.completed.length || action.modify.completed }} </span> <span v-if="action.modify.completed"
<span v-if="action.modify.failed">· 未完成 {{ action.modify.failed.length || action.modify.failed }} </span> >· 完成 {{ action.modify.completed.length || action.modify.completed }} </span
>
<span v-if="action.modify.failed"
>· 未完成 {{ action.modify.failed.length || action.modify.failed }} </span
>
</div> </div>
<div class="modify-warning icon-label" v-if="action.modify?.forced"> <div class="modify-warning icon-label" v-if="action.modify?.forced">
<span class="icon icon-sm" :style="iconStyle('triangleAlert')" aria-hidden="true"></span> <span class="icon icon-sm" :style="iconStyle('triangleAlert')" aria-hidden="true"></span>

View File

@ -12,9 +12,9 @@
<div class="collapsible-content"> <div class="collapsible-content">
<div <div
class="content-inner thinking-content" class="content-inner thinking-content"
:ref="el => setRef(blockId, el)" :ref="(el) => setRef(blockId, el)"
@scroll="event => handleScroll(blockId, event)" @scroll="(event) => handleScroll(blockId, event)"
style="max-height: 240px; overflow-y: auto;" style="max-height: 240px; overflow-y: auto"
> >
{{ action.content }} {{ action.content }}
</div> </div>

View File

@ -22,7 +22,11 @@
</div> </div>
<div <div
class="collapsible-content" class="collapsible-content"
:ref="el => registerCollapseContent && registerCollapseContent(collapseKey || action.tool.id || action.id || 'tool', el)" :ref="
(el) =>
registerCollapseContent &&
registerCollapseContent(collapseKey || action.tool.id || action.id || 'tool', el)
"
> >
<div class="content-inner"> <div class="content-inner">
<div v-if="shouldUseEnhancedDisplay" v-html="renderEnhancedToolResult()"></div> <div v-if="shouldUseEnhancedDisplay" v-html="renderEnhancedToolResult()"></div>
@ -80,7 +84,7 @@ function formatBytes(bytes: number): string {
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB']; const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
} }
function renderEnhancedToolResult(): string { function renderEnhancedToolResult(): string {
@ -255,10 +259,13 @@ function renderWriteFile(result: any, args: any): string {
if (isAppend) { if (isAppend) {
html += `<div><strong>模式:</strong>追加</div>`; html += `<div><strong>模式:</strong>追加</div>`;
} }
if (!result.success && result.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
}
html += '</div>'; html += '</div>';
// //
if (content) { if (result.success && content) {
html += '<div class="tool-result-diff scrollable">'; html += '<div class="tool-result-diff scrollable">';
const lines = content.split('\n'); const lines = content.split('\n');
lines.forEach((line: string) => { lines.forEach((line: string) => {
@ -284,8 +291,15 @@ function renderEditFile(result: any, args: any): string {
let html = '<div class="tool-result-meta">'; let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`; html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`; html += `<div><strong>状态:</strong>${status}</div>`;
if (!result.success && result.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
}
html += '</div>'; html += '</div>';
if (!result.success) {
return html;
}
// replacements 使 // replacements 使
if (replacements.length > 0) { if (replacements.length > 0) {
html += '<div class="tool-result-diff scrollable">'; html += '<div class="tool-result-diff scrollable">';
@ -689,7 +703,7 @@ function renderTodoCreate(result: any, args: any): string {
html += '<div class="tool-result-content">'; html += '<div class="tool-result-content">';
html += '<div class="todo-list">'; html += '<div class="todo-list">';
tasks.forEach((task: any) => { tasks.forEach((task: any) => {
const taskText = typeof task === 'string' ? task : (task.text || task.title || ''); const taskText = typeof task === 'string' ? task : task.text || task.title || '';
html += `<div class="todo-item"><input type="checkbox" disabled /> ${escapeHtml(taskText)}</div>`; html += `<div class="todo-item"><input type="checkbox" disabled /> ${escapeHtml(taskText)}</div>`;
}); });
html += '</div>'; html += '</div>';
@ -713,7 +727,7 @@ function renderTodoUpdate(result: any, args: any): string {
html += '<div class="tool-result-content">'; html += '<div class="tool-result-content">';
html += '<div class="todo-list">'; html += '<div class="todo-list">';
tasks.forEach((task: any) => { tasks.forEach((task: any) => {
const taskText = typeof task === 'string' ? task : (task.text || task.title || ''); const taskText = typeof task === 'string' ? task : task.text || task.title || '';
const completed = typeof task === 'object' && (task.completed || task.done || task.checked); const completed = typeof task === 'object' && (task.completed || task.done || task.checked);
html += `<div class="todo-item"><input type="checkbox" ${completed ? 'checked' : ''} disabled /> ${escapeHtml(taskText)}</div>`; html += `<div class="todo-item"><input type="checkbox" ${completed ? 'checked' : ''} disabled /> ${escapeHtml(taskText)}</div>`;
}); });
@ -989,7 +1003,7 @@ function renderEasterEgg(result: any, args: any): string {
gap: 8px; gap: 8px;
} }
.todo-item input[type="checkbox"] { .todo-item input[type='checkbox'] {
margin: 0; margin: 0;
} }

View File

@ -11,7 +11,7 @@ export function formatBytes(bytes: number): string {
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB']; const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
} }
export function renderEnhancedToolResult( export function renderEnhancedToolResult(
@ -89,7 +89,7 @@ export function renderEnhancedToolResult(
else if (name === 'todo_create') { else if (name === 'todo_create') {
return renderTodoCreate(result, args); return renderTodoCreate(result, args);
} else if (name === 'todo_update_task') { } else if (name === 'todo_update_task') {
return renderTodoUpdate(result, args); return renderTodoUpdate(result);
} }
// 彩蛋类 // 彩蛋类
@ -200,10 +200,13 @@ function renderWriteFile(result: any, args: any): string {
if (isAppend) { if (isAppend) {
html += `<div><strong>模式:</strong>追加</div>`; html += `<div><strong>模式:</strong>追加</div>`;
} }
if (!result.success && result.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
}
html += '</div>'; html += '</div>';
// 显示写入的内容 // 显示写入的内容
if (content) { if (result.success && content) {
html += '<div class="tool-result-diff scrollable">'; html += '<div class="tool-result-diff scrollable">';
const lines = content.split('\n'); const lines = content.split('\n');
lines.forEach((line: string) => { lines.forEach((line: string) => {
@ -229,8 +232,15 @@ function renderEditFile(result: any, args: any): string {
let html = '<div class="tool-result-meta">'; let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`; html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`; html += `<div><strong>状态:</strong>${status}</div>`;
if (!result.success && result.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
}
html += '</div>'; html += '</div>';
if (!result.success) {
return html;
}
// 如果有 replacements 数组,使用新格式 // 如果有 replacements 数组,使用新格式
if (replacements.length > 0) { if (replacements.length > 0) {
html += '<div class="tool-result-diff scrollable">'; html += '<div class="tool-result-diff scrollable">';
@ -618,7 +628,8 @@ function renderTodoCreate(result: any, args: any): string {
html += '<div class="tool-result-content">'; html += '<div class="tool-result-content">';
html += '<div class="todo-list">'; html += '<div class="todo-list">';
tasks.forEach((task: any) => { tasks.forEach((task: any) => {
const taskText = typeof task === 'string' ? task : (task.text || task.title || task.content || ''); const taskText =
typeof task === 'string' ? task : task.text || task.title || task.content || '';
html += `<div class="todo-item"><input type="checkbox" disabled /> ${escapeHtml(taskText)}</div>`; html += `<div class="todo-item"><input type="checkbox" disabled /> ${escapeHtml(taskText)}</div>`;
}); });
html += '</div>'; html += '</div>';
@ -628,7 +639,7 @@ function renderTodoCreate(result: any, args: any): string {
return html; return html;
} }
function renderTodoUpdate(result: any, args: any): string { function renderTodoUpdate(result: any): string {
const status = result.success ? '✓ 已更新' : '✗ 失败'; const status = result.success ? '✓ 已更新' : '✗ 失败';
const todoList = result.todo_list || {}; const todoList = result.todo_list || {};
const title = todoList.title || ''; const title = todoList.title || '';
@ -643,8 +654,11 @@ function renderTodoUpdate(result: any, args: any): string {
html += '<div class="tool-result-content">'; html += '<div class="tool-result-content">';
html += '<div class="todo-list">'; html += '<div class="todo-list">';
tasks.forEach((task: any) => { tasks.forEach((task: any) => {
const taskText = typeof task === 'string' ? task : (task.text || task.title || task.content || ''); const taskText =
const completed = typeof task === 'object' && (task.status === 'done' || task.completed || task.done || task.checked); typeof task === 'string' ? task : task.text || task.title || task.content || '';
const completed =
typeof task === 'object' &&
(task.status === 'done' || task.completed || task.done || task.checked);
html += `<div class="todo-item"><input type="checkbox" ${completed ? 'checked' : ''} disabled /> ${escapeHtml(taskText)}</div>`; html += `<div class="todo-item"><input type="checkbox" ${completed ? 'checked' : ''} disabled /> ${escapeHtml(taskText)}</div>`;
}); });
html += '</div>'; html += '</div>';

View File

@ -14,7 +14,7 @@
.bouncing-squares-loader { .bouncing-squares-loader {
--loader-scale: 0.45; --loader-scale: 0.45;
--square-size: 7.6px; /* 7.6 * 0.45 ≈ 3.42px,略大一点 */ --square-size: 7.6px; /* 7.6 * 0.45 ≈ 3.42px,略大一点 */
--square-gap: 8.9px; /* 固定间距,缩放后约 4px */ --square-gap: 8.9px; /* 固定间距,缩放后约 4px */
--start-x: 3px; --start-x: 3px;
position: relative; position: relative;
width: 40px; width: 40px;

View File

@ -21,7 +21,7 @@
.chaotic-orbit-loader::before, .chaotic-orbit-loader::before,
.chaotic-orbit-loader::after { .chaotic-orbit-loader::after {
content: ""; content: '';
position: absolute; position: absolute;
width: 3px; width: 3px;
height: 3px; height: 3px;
@ -29,16 +29,42 @@
will-change: transform; will-change: transform;
} }
.chaotic-orbit-loader::before { animation: orbit var(--speed) linear infinite; } .chaotic-orbit-loader::before {
.chaotic-orbit-loader::after { animation: orbit var(--speed) linear calc(var(--speed) / -2) infinite; } animation: orbit var(--speed) linear infinite;
}
.chaotic-orbit-loader::after {
animation: orbit var(--speed) linear calc(var(--speed) / -2) infinite;
}
@keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes orbit { @keyframes orbit {
0% { transform: translate(12px) scale(.74); opacity:.65; } 0% {
25% { transform: translate(0) scale(.47); opacity:.3; } transform: translate(12px) scale(0.74);
50% { transform: translate(-12px) scale(.74); opacity:.65; } opacity: 0.65;
75% { transform: translate(0) scale(1); opacity:1; } }
100% { transform: translate(12px) scale(.74); opacity:.65; } 25% {
transform: translate(0) scale(0.47);
opacity: 0.3;
}
50% {
transform: translate(-12px) scale(0.74);
opacity: 0.65;
}
75% {
transform: translate(0) scale(1);
opacity: 1;
}
100% {
transform: translate(12px) scale(0.74);
opacity: 0.65;
}
} }
</style> </style>

View File

@ -18,7 +18,7 @@
<style scoped> <style scoped>
.dot-spinner-loader { .dot-spinner-loader {
--size: 22px; --size: 22px;
--speed: .9s; --speed: 0.9s;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
@ -39,32 +39,67 @@
} }
.dot::before { .dot::before {
content: ""; content: '';
width: 3px; width: 3px;
height: 3px; height: 3px;
background: var(--claude-text-tertiary); background: var(--claude-text-tertiary);
transform: scale(0); transform: scale(0);
opacity: .5; opacity: 0.5;
animation: pulse calc(var(--speed) * 1.111) ease-in-out infinite; animation: pulse calc(var(--speed) * 1.111) ease-in-out infinite;
} }
.dot:nth-child(2) { transform: rotate(45deg); } .dot:nth-child(2) {
.dot:nth-child(2)::before { animation-delay: calc(var(--speed) * -0.875); } transform: rotate(45deg);
.dot:nth-child(3) { transform: rotate(90deg); } }
.dot:nth-child(3)::before { animation-delay: calc(var(--speed) * -0.75); } .dot:nth-child(2)::before {
.dot:nth-child(4) { transform: rotate(135deg); } animation-delay: calc(var(--speed) * -0.875);
.dot:nth-child(4)::before { animation-delay: calc(var(--speed) * -0.625); } }
.dot:nth-child(5) { transform: rotate(180deg); } .dot:nth-child(3) {
.dot:nth-child(5)::before { animation-delay: calc(var(--speed) * -0.5); } transform: rotate(90deg);
.dot:nth-child(6) { transform: rotate(225deg); } }
.dot:nth-child(6)::before { animation-delay: calc(var(--speed) * -0.375); } .dot:nth-child(3)::before {
.dot:nth-child(7) { transform: rotate(270deg); } animation-delay: calc(var(--speed) * -0.75);
.dot:nth-child(7)::before { animation-delay: calc(var(--speed) * -0.25); } }
.dot:nth-child(8) { transform: rotate(315deg); } .dot:nth-child(4) {
.dot:nth-child(8)::before { animation-delay: calc(var(--speed) * -0.125); } transform: rotate(135deg);
}
.dot:nth-child(4)::before {
animation-delay: calc(var(--speed) * -0.625);
}
.dot:nth-child(5) {
transform: rotate(180deg);
}
.dot:nth-child(5)::before {
animation-delay: calc(var(--speed) * -0.5);
}
.dot:nth-child(6) {
transform: rotate(225deg);
}
.dot:nth-child(6)::before {
animation-delay: calc(var(--speed) * -0.375);
}
.dot:nth-child(7) {
transform: rotate(270deg);
}
.dot:nth-child(7)::before {
animation-delay: calc(var(--speed) * -0.25);
}
.dot:nth-child(8) {
transform: rotate(315deg);
}
.dot:nth-child(8)::before {
animation-delay: calc(var(--speed) * -0.125);
}
@keyframes pulse { @keyframes pulse {
0%, 100% { transform: scale(0); opacity: .5; } 0%,
50% { transform: scale(1); opacity: 1; } 100% {
transform: scale(0);
opacity: 0.5;
}
50% {
transform: scale(1);
opacity: 1;
}
} }
</style> </style>

View File

@ -18,7 +18,7 @@
.dual-chase-loader::before { .dual-chase-loader::before {
box-sizing: border-box; box-sizing: border-box;
position: absolute; position: absolute;
content: ""; content: '';
width: var(--size); width: var(--size);
height: var(--size); height: var(--size);
top: 50%; top: 50%;
@ -34,7 +34,8 @@
} }
@keyframes chase-down { @keyframes chase-down {
0%, 100% { 0%,
100% {
transform: none; transform: none;
} }
@ -52,7 +53,8 @@
} }
@keyframes chase-up { @keyframes chase-up {
0%, 100% { 0%,
100% {
transform: none; transform: none;
} }

View File

@ -60,48 +60,142 @@
} }
@keyframes square1 { @keyframes square1 {
0% { left: 0; top: 0; } 0% {
8.33% { left: 0; top: var(--offset); } left: 0;
100% { left: 0; top: var(--offset); } top: 0;
}
8.33% {
left: 0;
top: var(--offset);
}
100% {
left: 0;
top: var(--offset);
}
} }
@keyframes square2 { @keyframes square2 {
0% { left: 0; top: var(--offset); } 0% {
8.33% { left: 0; top: calc(2 * var(--offset)); } left: 0;
16.67% { left: var(--offset); top: calc(2 * var(--offset)); } top: var(--offset);
25% { left: var(--offset); top: var(--offset); } }
83.33% { left: var(--offset); top: var(--offset); } 8.33% {
91.67% { left: var(--offset); top: 0; } left: 0;
100% { left: 0; top: 0; } top: calc(2 * var(--offset));
}
16.67% {
left: var(--offset);
top: calc(2 * var(--offset));
}
25% {
left: var(--offset);
top: var(--offset);
}
83.33% {
left: var(--offset);
top: var(--offset);
}
91.67% {
left: var(--offset);
top: 0;
}
100% {
left: 0;
top: 0;
}
} }
@keyframes square3 { @keyframes square3 {
0%, 100% { left: var(--offset); top: var(--offset); } 0%,
16.67% { left: var(--offset); top: var(--offset); } 100% {
25% { left: var(--offset); top: 0; } left: var(--offset);
33.33% { left: calc(2 * var(--offset)); top: 0; } top: var(--offset);
41.67% { left: calc(2 * var(--offset)); top: var(--offset); } }
66.67% { left: calc(2 * var(--offset)); top: var(--offset); } 16.67% {
75% { left: calc(2 * var(--offset)); top: calc(2 * var(--offset)); } left: var(--offset);
83.33% { left: var(--offset); top: calc(2 * var(--offset)); } top: var(--offset);
91.67% { left: var(--offset); top: var(--offset); } }
25% {
left: var(--offset);
top: 0;
}
33.33% {
left: calc(2 * var(--offset));
top: 0;
}
41.67% {
left: calc(2 * var(--offset));
top: var(--offset);
}
66.67% {
left: calc(2 * var(--offset));
top: var(--offset);
}
75% {
left: calc(2 * var(--offset));
top: calc(2 * var(--offset));
}
83.33% {
left: var(--offset);
top: calc(2 * var(--offset));
}
91.67% {
left: var(--offset);
top: var(--offset);
}
} }
@keyframes square4 { @keyframes square4 {
0% { left: calc(2 * var(--offset)); top: var(--offset); } 0% {
33.33% { left: calc(2 * var(--offset)); top: var(--offset); } left: calc(2 * var(--offset));
41.67% { left: calc(2 * var(--offset)); top: calc(2 * var(--offset)); } top: var(--offset);
50% { left: calc(3 * var(--offset)); top: calc(2 * var(--offset)); } }
58.33% { left: calc(3 * var(--offset)); top: var(--offset); } 33.33% {
100% { left: calc(3 * var(--offset)); top: var(--offset); } left: calc(2 * var(--offset));
top: var(--offset);
}
41.67% {
left: calc(2 * var(--offset));
top: calc(2 * var(--offset));
}
50% {
left: calc(3 * var(--offset));
top: calc(2 * var(--offset));
}
58.33% {
left: calc(3 * var(--offset));
top: var(--offset);
}
100% {
left: calc(3 * var(--offset));
top: var(--offset);
}
} }
@keyframes square5 { @keyframes square5 {
0% { left: calc(3 * var(--offset)); top: var(--offset); } 0% {
50% { left: calc(3 * var(--offset)); top: var(--offset); } left: calc(3 * var(--offset));
58.33% { left: calc(3 * var(--offset)); top: 0; } top: var(--offset);
66.67% { left: calc(2 * var(--offset)); top: 0; } }
75% { left: calc(2 * var(--offset)); top: var(--offset); } 50% {
100% { left: calc(2 * var(--offset)); top: var(--offset); } left: calc(3 * var(--offset));
top: var(--offset);
}
58.33% {
left: calc(3 * var(--offset));
top: 0;
}
66.67% {
left: calc(2 * var(--offset));
top: 0;
}
75% {
left: calc(2 * var(--offset));
top: var(--offset);
}
100% {
left: calc(2 * var(--offset));
top: var(--offset);
}
} }
</style> </style>

View File

@ -29,14 +29,34 @@
animation: follow-track 1.25s infinite; animation: follow-track 1.25s infinite;
} }
.sq:nth-child(1) { top: 0; animation-delay: 0.15s; } .sq:nth-child(1) {
.sq:nth-child(2) { top: 5px; animation-delay: 0.3s; } top: 0;
.sq:nth-child(3) { top: 10px; animation-delay: 0.45s; } animation-delay: 0.15s;
.sq:nth-child(4) { top: 15px; animation-delay: 0.6s; } }
.sq:nth-child(5) { top: 20px; animation-delay: 0.75s; } .sq:nth-child(2) {
top: 5px;
animation-delay: 0.3s;
}
.sq:nth-child(3) {
top: 10px;
animation-delay: 0.45s;
}
.sq:nth-child(4) {
top: 15px;
animation-delay: 0.6s;
}
.sq:nth-child(5) {
top: 20px;
animation-delay: 0.75s;
}
@keyframes follow-track { @keyframes follow-track {
0% { transform: rotate(0deg) translateY(-4px); } 0% {
60%, 100% { transform: rotate(360deg) translateY(-4px); } transform: rotate(0deg) translateY(-4px);
}
60%,
100% {
transform: rotate(360deg) translateY(-4px);
}
} }
</style> </style>

View File

@ -30,15 +30,32 @@
animation: corner-cycle 1.8s ease-in-out infinite; animation: corner-cycle 1.8s ease-in-out infinite;
} }
.sq:nth-child(1) { animation-delay: -0.45s; } .sq:nth-child(1) {
.sq:nth-child(2) { animation-delay: -0.9s; } animation-delay: -0.45s;
.sq:nth-child(3) { animation-delay: -1.35s; } }
.sq:nth-child(4) { animation-delay: -1.8s; } .sq:nth-child(2) {
animation-delay: -0.9s;
}
.sq:nth-child(3) {
animation-delay: -1.35s;
}
.sq:nth-child(4) {
animation-delay: -1.8s;
}
@keyframes corner-cycle { @keyframes corner-cycle {
0%, 100% { transform: translate(-6px, -6px); } 0%,
25% { transform: translate(6px, -6px); } 100% {
50% { transform: translate(6px, 6px); } transform: translate(-6px, -6px);
75% { transform: translate(-6px, 6px); } }
25% {
transform: translate(6px, -6px);
}
50% {
transform: translate(6px, 6px);
}
75% {
transform: translate(-6px, 6px);
}
} }
</style> </style>

View File

@ -26,13 +26,26 @@
animation: jump 2s ease infinite; animation: jump 2s ease infinite;
} }
.sq:nth-child(2n) { animation-delay: 300ms; } .sq:nth-child(2n) {
.sq:nth-child(3n) { animation-delay: 600ms; } animation-delay: 300ms;
}
.sq:nth-child(3n) {
animation-delay: 600ms;
}
@keyframes jump { @keyframes jump {
0%, 100% { transform: translateY(0); } 0%,
25% { transform: translateY(-10px) scale(0.66); } 100% {
50% { transform: translateY(0); } transform: translateY(0);
75% { transform: translateY(3px) scale(0.9); } }
25% {
transform: translateY(-10px) scale(0.66);
}
50% {
transform: translateY(0);
}
75% {
transform: translateY(3px) scale(0.9);
}
} }
</style> </style>

View File

@ -35,7 +35,7 @@
} }
.dot::before { .dot::before {
content: ""; content: '';
width: 3px; width: 3px;
height: 3px; height: 3px;
background: var(--claude-text-tertiary); background: var(--claude-text-tertiary);
@ -52,10 +52,20 @@
} }
@keyframes leap { @keyframes leap {
0% { transform: translateX(0) rotate(0deg); } 0% {
33.333% { transform: translateX(0) rotate(180deg); } transform: translateX(0) rotate(0deg);
66.666% { transform: translateX(calc(var(--size) * -0.4)) rotate(180deg); } }
99.999% { transform: translateX(calc(var(--size) * -0.8)) rotate(180deg); } 33.333% {
100% { transform: translateX(0) rotate(0deg); } transform: translateX(0) rotate(180deg);
}
66.666% {
transform: translateX(calc(var(--size) * -0.4)) rotate(180deg);
}
99.999% {
transform: translateX(calc(var(--size) * -0.8)) rotate(180deg);
}
100% {
transform: translateX(0) rotate(0deg);
}
} }
</style> </style>

View File

@ -31,7 +31,7 @@
} }
.dot::after { .dot::after {
content: ""; content: '';
position: absolute; position: absolute;
top: 75%; top: 75%;
width: 3px; width: 3px;
@ -48,14 +48,32 @@
} }
@keyframes swing { @keyframes swing {
0% { transform: rotate(0deg); animation-timing-function: ease-out; } 0% {
25% { transform: rotate(70deg); animation-timing-function: ease-in; } transform: rotate(0deg);
50% { transform: rotate(0deg); animation-timing-function: linear; } animation-timing-function: ease-out;
}
25% {
transform: rotate(70deg);
animation-timing-function: ease-in;
}
50% {
transform: rotate(0deg);
animation-timing-function: linear;
}
} }
@keyframes swing2 { @keyframes swing2 {
0% { transform: rotate(0deg); animation-timing-function: linear; } 0% {
50% { transform: rotate(0deg); animation-timing-function: ease-out; } transform: rotate(0deg);
75% { transform: rotate(-70deg); animation-timing-function: ease-in; } animation-timing-function: linear;
}
50% {
transform: rotate(0deg);
animation-timing-function: ease-out;
}
75% {
transform: rotate(-70deg);
animation-timing-function: ease-in;
}
} }
</style> </style>

View File

@ -32,7 +32,7 @@
} }
.box:before { .box:before {
content: ""; content: '';
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
@ -59,139 +59,355 @@
margin-bottom: 0; margin-bottom: 0;
} }
.box:nth-child(1) { animation: moveBox-1 4s infinite; } .box:nth-child(1) {
.box:nth-child(2) { animation: moveBox-2 4s infinite; } animation: moveBox-1 4s infinite;
.box:nth-child(3) { animation: moveBox-3 4s infinite; } }
.box:nth-child(4) { animation: moveBox-4 4s infinite; } .box:nth-child(2) {
.box:nth-child(5) { animation: moveBox-5 4s infinite; } animation: moveBox-2 4s infinite;
.box:nth-child(6) { animation: moveBox-6 4s infinite; } }
.box:nth-child(7) { animation: moveBox-7 4s infinite; } .box:nth-child(3) {
.box:nth-child(8) { animation: moveBox-8 4s infinite; } animation: moveBox-3 4s infinite;
.box:nth-child(9) { animation: moveBox-9 4s infinite; } }
.box:nth-child(4) {
animation: moveBox-4 4s infinite;
}
.box:nth-child(5) {
animation: moveBox-5 4s infinite;
}
.box:nth-child(6) {
animation: moveBox-6 4s infinite;
}
.box:nth-child(7) {
animation: moveBox-7 4s infinite;
}
.box:nth-child(8) {
animation: moveBox-8 4s infinite;
}
.box:nth-child(9) {
animation: moveBox-9 4s infinite;
}
@keyframes moveBox-1 { @keyframes moveBox-1 {
9% { transform: translate(-7px, 0); } 9% {
18% { transform: translate(0, 0); } transform: translate(-7px, 0);
27% { transform: translate(0, 0); } }
36% { transform: translate(7px, 0); } 18% {
45% { transform: translate(7px, 7px); } transform: translate(0, 0);
54% { transform: translate(7px, 7px); } }
63% { transform: translate(7px, 7px); } 27% {
72% { transform: translate(7px, 0); } transform: translate(0, 0);
81% { transform: translate(0, 0); } }
90% { transform: translate(-7px, 0); } 36% {
100% { transform: translate(0, 0); } transform: translate(7px, 0);
}
45% {
transform: translate(7px, 7px);
}
54% {
transform: translate(7px, 7px);
}
63% {
transform: translate(7px, 7px);
}
72% {
transform: translate(7px, 0);
}
81% {
transform: translate(0, 0);
}
90% {
transform: translate(-7px, 0);
}
100% {
transform: translate(0, 0);
}
} }
@keyframes moveBox-2 { @keyframes moveBox-2 {
9% { transform: translate(0, 0); } 9% {
18% { transform: translate(7px, 0); } transform: translate(0, 0);
27% { transform: translate(0, 0); } }
36% { transform: translate(7px, 0); } 18% {
45% { transform: translate(7px, 7px); } transform: translate(7px, 0);
54% { transform: translate(7px, 7px); } }
63% { transform: translate(7px, 7px); } 27% {
72% { transform: translate(7px, 7px); } transform: translate(0, 0);
81% { transform: translate(0, 7px); } }
90% { transform: translate(0, 7px); } 36% {
100% { transform: translate(0, 0); } transform: translate(7px, 0);
}
45% {
transform: translate(7px, 7px);
}
54% {
transform: translate(7px, 7px);
}
63% {
transform: translate(7px, 7px);
}
72% {
transform: translate(7px, 7px);
}
81% {
transform: translate(0, 7px);
}
90% {
transform: translate(0, 7px);
}
100% {
transform: translate(0, 0);
}
} }
@keyframes moveBox-3 { @keyframes moveBox-3 {
9% { transform: translate(-7px, 0); } 9% {
18% { transform: translate(-7px, 0); } transform: translate(-7px, 0);
27% { transform: translate(0, 0); } }
36% { transform: translate(-7px, 0); } 18% {
45% { transform: translate(-7px, 0); } transform: translate(-7px, 0);
54% { transform: translate(-7px, 0); } }
63% { transform: translate(-7px, 0); } 27% {
72% { transform: translate(-7px, 0); } transform: translate(0, 0);
81% { transform: translate(-7px, -7px); } }
90% { transform: translate(0, -7px); } 36% {
100% { transform: translate(0, 0); } transform: translate(-7px, 0);
}
45% {
transform: translate(-7px, 0);
}
54% {
transform: translate(-7px, 0);
}
63% {
transform: translate(-7px, 0);
}
72% {
transform: translate(-7px, 0);
}
81% {
transform: translate(-7px, -7px);
}
90% {
transform: translate(0, -7px);
}
100% {
transform: translate(0, 0);
}
} }
@keyframes moveBox-4 { @keyframes moveBox-4 {
9% { transform: translate(-7px, 0); } 9% {
18% { transform: translate(-7px, 0); } transform: translate(-7px, 0);
27% { transform: translate(-7px, -7px); } }
36% { transform: translate(0, -7px); } 18% {
45% { transform: translate(0, 0); } transform: translate(-7px, 0);
54% { transform: translate(0, -7px); } }
63% { transform: translate(0, -7px); } 27% {
72% { transform: translate(0, -7px); } transform: translate(-7px, -7px);
81% { transform: translate(-7px, -7px); } }
90% { transform: translate(-7px, 0); } 36% {
100% { transform: translate(0, 0); } transform: translate(0, -7px);
}
45% {
transform: translate(0, 0);
}
54% {
transform: translate(0, -7px);
}
63% {
transform: translate(0, -7px);
}
72% {
transform: translate(0, -7px);
}
81% {
transform: translate(-7px, -7px);
}
90% {
transform: translate(-7px, 0);
}
100% {
transform: translate(0, 0);
}
} }
@keyframes moveBox-5 { @keyframes moveBox-5 {
9% { transform: translate(0, 0); } 9% {
18% { transform: translate(0, 0); } transform: translate(0, 0);
27% { transform: translate(0, 0); } }
36% { transform: translate(7px, 0); } 18% {
45% { transform: translate(7px, 0); } transform: translate(0, 0);
54% { transform: translate(7px, 0); } }
63% { transform: translate(7px, 0); } 27% {
72% { transform: translate(7px, 0); } transform: translate(0, 0);
81% { transform: translate(7px, -7px); } }
90% { transform: translate(0, -7px); } 36% {
100% { transform: translate(0, 0); } transform: translate(7px, 0);
}
45% {
transform: translate(7px, 0);
}
54% {
transform: translate(7px, 0);
}
63% {
transform: translate(7px, 0);
}
72% {
transform: translate(7px, 0);
}
81% {
transform: translate(7px, -7px);
}
90% {
transform: translate(0, -7px);
}
100% {
transform: translate(0, 0);
}
} }
@keyframes moveBox-6 { @keyframes moveBox-6 {
9% { transform: translate(0, 0); } 9% {
18% { transform: translate(-7px, 0); } transform: translate(0, 0);
27% { transform: translate(-7px, 0); } }
36% { transform: translate(0, 0); } 18% {
45% { transform: translate(0, 0); } transform: translate(-7px, 0);
54% { transform: translate(0, 0); } }
63% { transform: translate(0, 0); } 27% {
72% { transform: translate(0, 7px); } transform: translate(-7px, 0);
81% { transform: translate(-7px, 7px); } }
90% { transform: translate(-7px, 0); } 36% {
100% { transform: translate(0, 0); } transform: translate(0, 0);
}
45% {
transform: translate(0, 0);
}
54% {
transform: translate(0, 0);
}
63% {
transform: translate(0, 0);
}
72% {
transform: translate(0, 7px);
}
81% {
transform: translate(-7px, 7px);
}
90% {
transform: translate(-7px, 0);
}
100% {
transform: translate(0, 0);
}
} }
@keyframes moveBox-7 { @keyframes moveBox-7 {
9% { transform: translate(7px, 0); } 9% {
18% { transform: translate(7px, 0); } transform: translate(7px, 0);
27% { transform: translate(7px, 0); } }
36% { transform: translate(0, 0); } 18% {
45% { transform: translate(0, -7px); } transform: translate(7px, 0);
54% { transform: translate(7px, -7px); } }
63% { transform: translate(0, -7px); } 27% {
72% { transform: translate(0, -7px); } transform: translate(7px, 0);
81% { transform: translate(0, 0); } }
90% { transform: translate(7px, 0); } 36% {
100% { transform: translate(0, 0); } transform: translate(0, 0);
}
45% {
transform: translate(0, -7px);
}
54% {
transform: translate(7px, -7px);
}
63% {
transform: translate(0, -7px);
}
72% {
transform: translate(0, -7px);
}
81% {
transform: translate(0, 0);
}
90% {
transform: translate(7px, 0);
}
100% {
transform: translate(0, 0);
}
} }
@keyframes moveBox-8 { @keyframes moveBox-8 {
9% { transform: translate(0, 0); } 9% {
18% { transform: translate(-7px, 0); } transform: translate(0, 0);
27% { transform: translate(-7px, -7px); } }
36% { transform: translate(0, -7px); } 18% {
45% { transform: translate(0, -7px); } transform: translate(-7px, 0);
54% { transform: translate(0, -7px); } }
63% { transform: translate(0, -7px); } 27% {
72% { transform: translate(0, -7px); } transform: translate(-7px, -7px);
81% { transform: translate(7px, -7px); } }
90% { transform: translate(7px, 0); } 36% {
100% { transform: translate(0, 0); } transform: translate(0, -7px);
}
45% {
transform: translate(0, -7px);
}
54% {
transform: translate(0, -7px);
}
63% {
transform: translate(0, -7px);
}
72% {
transform: translate(0, -7px);
}
81% {
transform: translate(7px, -7px);
}
90% {
transform: translate(7px, 0);
}
100% {
transform: translate(0, 0);
}
} }
@keyframes moveBox-9 { @keyframes moveBox-9 {
9% { transform: translate(-7px, 0); } 9% {
18% { transform: translate(-7px, 0); } transform: translate(-7px, 0);
27% { transform: translate(0, 0); } }
36% { transform: translate(-7px, 0); } 18% {
45% { transform: translate(0, 0); } transform: translate(-7px, 0);
54% { transform: translate(0, 0); } }
63% { transform: translate(-7px, 0); } 27% {
72% { transform: translate(-7px, 0); } transform: translate(0, 0);
81% { transform: translate(-14px, 0); } }
90% { transform: translate(-7px, 0); } 36% {
100% { transform: translate(0, 0); } transform: translate(-7px, 0);
}
45% {
transform: translate(0, 0);
}
54% {
transform: translate(0, 0);
}
63% {
transform: translate(-7px, 0);
}
72% {
transform: translate(-7px, 0);
}
81% {
transform: translate(-14px, 0);
}
90% {
transform: translate(-7px, 0);
}
100% {
transform: translate(0, 0);
}
} }
</style> </style>

View File

@ -32,7 +32,7 @@
} }
.orbe::after { .orbe::after {
content: ""; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@ -43,8 +43,13 @@
} }
@keyframes orbit { @keyframes orbit {
0% {} 0% {
80% { transform: rotate(360deg); } }
100% { transform: rotate(360deg); } 80% {
transform: rotate(360deg);
}
100% {
transform: rotate(360deg);
}
} }
</style> </style>

View File

@ -16,23 +16,74 @@
<style scoped> <style scoped>
@keyframes square-animation { @keyframes square-animation {
0% { left: 0; top: 0; } 0% {
10.5% { left: 0; top: 0; } left: 0;
12.5% { left: 8px; top: 0; } top: 0;
23% { left: 8px; top: 0; } }
25% { left: 16px; top: 0; } 10.5% {
35.5% { left: 16px; top: 0; } left: 0;
37.5% { left: 16px; top: 8px; } top: 0;
48% { left: 16px; top: 8px; } }
50% { left: 8px; top: 8px; } 12.5% {
60.5% { left: 8px; top: 8px; } left: 8px;
62.5% { left: 8px; top: 16px; } top: 0;
73% { left: 8px; top: 16px; } }
75% { left: 0; top: 16px; } 23% {
85.5% { left: 0; top: 16px; } left: 8px;
87.5% { left: 0; top: 8px; } top: 0;
98% { left: 0; top: 8px; } }
100% { left: 0; top: 0; } 25% {
left: 16px;
top: 0;
}
35.5% {
left: 16px;
top: 0;
}
37.5% {
left: 16px;
top: 8px;
}
48% {
left: 16px;
top: 8px;
}
50% {
left: 8px;
top: 8px;
}
60.5% {
left: 8px;
top: 8px;
}
62.5% {
left: 8px;
top: 16px;
}
73% {
left: 8px;
top: 16px;
}
75% {
left: 0;
top: 16px;
}
85.5% {
left: 0;
top: 16px;
}
87.5% {
left: 0;
top: 8px;
}
98% {
left: 0;
top: 8px;
}
100% {
left: 0;
top: 0;
}
} }
.rotating-squares-loader { .rotating-squares-loader {

View File

@ -18,14 +18,68 @@
} }
@keyframes roll { @keyframes roll {
0% { box-shadow: 0 0 transparent, 0 0 transparent, 0 0 transparent, 0 0 transparent; } 0% {
12% { box-shadow: 6px 0 currentColor, 0 0 transparent, 0 0 transparent, 0 0 transparent; } box-shadow:
25% { box-shadow: 12px 0 currentColor, 6px 0 currentColor, 0 0 transparent, 0 0 transparent; } 0 0 transparent,
36% { box-shadow: 18px 0 currentColor, 12px 0 currentColor, 6px 0 currentColor, 0 0 transparent; } 0 0 transparent,
50% { box-shadow: 24px 0 currentColor, 18px 0 currentColor, 12px 0 currentColor, 6px 0 currentColor; } 0 0 transparent,
62% { box-shadow: 36px 0 transparent, 24px 0 currentColor, 18px 0 currentColor, 12px 0 currentColor; } 0 0 transparent;
75% { box-shadow: 36px 0 transparent, 36px 0 transparent, 24px 0 currentColor, 18px 0 currentColor; } }
87% { box-shadow: 36px 0 transparent, 36px 0 transparent, 36px 0 transparent, 24px 0 currentColor; } 12% {
100% { box-shadow: 36px 0 transparent, 36px 0 transparent, 36px 0 transparent, 36px 0 transparent; } box-shadow:
6px 0 currentColor,
0 0 transparent,
0 0 transparent,
0 0 transparent;
}
25% {
box-shadow:
12px 0 currentColor,
6px 0 currentColor,
0 0 transparent,
0 0 transparent;
}
36% {
box-shadow:
18px 0 currentColor,
12px 0 currentColor,
6px 0 currentColor,
0 0 transparent;
}
50% {
box-shadow:
24px 0 currentColor,
18px 0 currentColor,
12px 0 currentColor,
6px 0 currentColor;
}
62% {
box-shadow:
36px 0 transparent,
24px 0 currentColor,
18px 0 currentColor,
12px 0 currentColor;
}
75% {
box-shadow:
36px 0 transparent,
36px 0 transparent,
24px 0 currentColor,
18px 0 currentColor;
}
87% {
box-shadow:
36px 0 transparent,
36px 0 transparent,
36px 0 transparent,
24px 0 currentColor;
}
100% {
box-shadow:
36px 0 transparent,
36px 0 transparent,
36px 0 transparent,
36px 0 transparent;
}
} }
</style> </style>

View File

@ -29,13 +29,26 @@
animation: wave var(--speed) ease-in-out infinite; animation: wave var(--speed) ease-in-out infinite;
} }
.sq:nth-child(1) { animation-delay: calc(var(--speed) * -0.45); } .sq:nth-child(1) {
.sq:nth-child(2) { animation-delay: calc(var(--speed) * -0.3); } animation-delay: calc(var(--speed) * -0.45);
.sq:nth-child(3) { animation-delay: calc(var(--speed) * -0.15); } }
.sq:nth-child(4) { animation-delay: 0s; } .sq:nth-child(2) {
animation-delay: calc(var(--speed) * -0.3);
}
.sq:nth-child(3) {
animation-delay: calc(var(--speed) * -0.15);
}
.sq:nth-child(4) {
animation-delay: 0s;
}
@keyframes wave { @keyframes wave {
0%, 100% { transform: translateY(0); } 0%,
50% { transform: translateY(-8px); } 100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@ -29,7 +29,11 @@ export interface MonitorDriver {
showThinkingBubble(): void; showThinkingBubble(): void;
hideBubble(): void; hideBubble(): void;
previewSceneProgress(name: string): void; previewSceneProgress(name: string): void;
playScene(name: string, payload: Record<string, any>, runtime: MonitorSceneRuntime): Promise<void>; playScene(
name: string,
payload: Record<string, any>,
runtime: MonitorSceneRuntime
): Promise<void>;
destroy(): void; destroy(): void;
preparePendingCreation?(path?: string | null): void; preparePendingCreation?(path?: string | null): void;
} }

View File

@ -3,7 +3,11 @@
<div v-if="node.type === 'folder'" class="file-node folder-node"> <div v-if="node.type === 'folder'" class="file-node folder-node">
<button class="folder-header" type="button" :style="folderPadding" @click="toggle"> <button class="folder-header" type="button" :style="folderPadding" @click="toggle">
<span class="folder-arrow">{{ isExpanded ? '▾' : '▸' }}</span> <span class="folder-arrow">{{ isExpanded ? '▾' : '▸' }}</span>
<span class="icon icon-sm folder-icon" :style="iconStyle(isExpanded ? 'folderOpen' : 'folder')" aria-hidden="true"></span> <span
class="icon icon-sm folder-icon"
:style="iconStyle(isExpanded ? 'folderOpen' : 'folder')"
aria-hidden="true"
></span>
<span class="folder-name">{{ node.name }}</span> <span class="folder-name">{{ node.name }}</span>
</button> </button>
<div v-show="isExpanded" class="folder-children"> <div v-show="isExpanded" class="folder-children">

View File

@ -10,18 +10,43 @@
'has-text': (inputMessage || '').trim().length > 0 'has-text': (inputMessage || '').trim().length > 0
}" }"
> >
<input type="file" ref="fileUploadInput" class="file-input-hidden" multiple @change="onFileChange" /> <input
type="file"
ref="fileUploadInput"
class="file-input-hidden"
multiple
@change="onFileChange"
/>
<div class="input-stack"> <div class="input-stack">
<div v-if="selectedImages && selectedImages.length" class="image-inline-row"> <div v-if="selectedImages && selectedImages.length" class="image-inline-row">
<div class="image-thumbnail-wrapper" v-for="img in selectedImages" :key="img"> <div class="image-thumbnail-wrapper" v-for="img in selectedImages" :key="img">
<img :src="getPreviewUrl(img)" :alt="formatImageName(img)" class="image-thumbnail" /> <img :src="getPreviewUrl(img)" :alt="formatImageName(img)" class="image-thumbnail" />
<button type="button" class="image-remove-btn-hover" @click.stop="$emit('remove-image', img)">×</button> <button
type="button"
class="image-remove-btn-hover"
@click.stop="$emit('remove-image', img)"
>
×
</button>
</div> </div>
</div> </div>
<div v-if="selectedVideos && selectedVideos.length" class="image-inline-row video-inline-row"> <div
v-if="selectedVideos && selectedVideos.length"
class="image-inline-row video-inline-row"
>
<div class="image-thumbnail-wrapper" v-for="video in selectedVideos" :key="video"> <div class="image-thumbnail-wrapper" v-for="video in selectedVideos" :key="video">
<img :src="getPreviewUrl(video)" :alt="formatImageName(video)" class="image-thumbnail" /> <img
<button type="button" class="image-remove-btn-hover" @click.stop="$emit('remove-video', video)">×</button> :src="getPreviewUrl(video)"
:alt="formatImageName(video)"
class="image-thumbnail"
/>
<button
type="button"
class="image-remove-btn-hover"
@click.stop="$emit('remove-video', video)"
>
×
</button>
</div> </div>
</div> </div>
<div class="input-row"> <div class="input-row">
@ -54,7 +79,10 @@
!isConnected || !isConnected ||
(inputLocked && !streamingMessage) || (inputLocked && !streamingMessage) ||
(mediaUploading && !streamingMessage) || (mediaUploading && !streamingMessage) ||
((!(inputMessage || '').trim() && (!selectedImages?.length && !selectedVideos?.length)) && !streamingMessage) (!(inputMessage || '').trim() &&
!selectedImages?.length &&
!selectedVideos?.length &&
!streamingMessage)
" "
> >
<span v-if="streamingMessage" class="stop-icon"></span> <span v-if="streamingMessage" class="stop-icon"></span>
@ -165,7 +193,14 @@ const props = defineProps<{
currentConversationId: string | null; currentConversationId: string | null;
iconStyle: (key: string) => Record<string, string>; iconStyle: (key: string) => Record<string, string>;
toolCategoryIcon: (categoryId: string) => string; toolCategoryIcon: (categoryId: string) => string;
modelOptions: Array<{ key: string; label: string; description: string; disabled?: boolean; supportsImage?: boolean; supportsVideo?: boolean }>; modelOptions: Array<{
key: string;
label: string;
description: string;
disabled?: boolean;
supportsImage?: boolean;
supportsVideo?: boolean;
}>;
currentModelKey: string; currentModelKey: string;
selectedImages?: string[]; selectedImages?: string[];
selectedVideos?: string[]; selectedVideos?: string[];

View File

@ -60,7 +60,6 @@
<span class="entry-arrow"></span> <span class="entry-arrow"></span>
</button> </button>
<transition name="submenu-slide"> <transition name="submenu-slide">
<div class="quick-submenu tool-submenu" v-if="toolMenuOpen"> <div class="quick-submenu tool-submenu" v-if="toolMenuOpen">
<div class="submenu-status" v-if="toolSettingsLoading">正在同步工具状态...</div> <div class="submenu-status" v-if="toolSettingsLoading">正在同步工具状态...</div>
@ -76,13 +75,15 @@
:disabled="streamingMessage || !isConnected || toolSettingsLoading" :disabled="streamingMessage || !isConnected || toolSettingsLoading"
> >
<span class="submenu-label icon-label"> <span class="submenu-label icon-label">
<span class="icon icon-sm" :style="getIconStyle(toolCategoryIcon(category.id))" aria-hidden="true"></span> <span
class="icon icon-sm"
:style="getIconStyle(toolCategoryIcon(category.id))"
aria-hidden="true"
></span>
<span>{{ category.label }}</span> <span>{{ category.label }}</span>
</span> </span>
<span class="entry-arrow"> <span class="entry-arrow">
<template v-if="category.locked"> <template v-if="category.locked"> 被管理员锁定 </template>
被管理员锁定
</template>
<template v-else> <template v-else>
{{ category.enabled ? '禁用' : '启用' }} {{ category.enabled ? '禁用' : '启用' }}
</template> </template>
@ -149,7 +150,14 @@ const props = defineProps<{
modeMenuOpen: boolean; modeMenuOpen: boolean;
runMode?: 'fast' | 'thinking' | 'deep'; runMode?: 'fast' | 'thinking' | 'deep';
modelMenuOpen: boolean; modelMenuOpen: boolean;
modelOptions: Array<{ key: string; label: string; description: string; disabled?: boolean; supportsImage?: boolean; supportsVideo?: boolean }>; modelOptions: Array<{
key: string;
label: string;
description: string;
disabled?: boolean;
supportsImage?: boolean;
supportsVideo?: boolean;
}>;
currentModelKey: string; currentModelKey: string;
blockUpload?: boolean; blockUpload?: boolean;
blockToolToggle?: boolean; blockToolToggle?: boolean;
@ -176,41 +184,15 @@ defineEmits<{
(event: 'pick-video'): void; (event: 'pick-video'): void;
}>(); }>();
const runModeOptions = [
{ value: 'fast', label: '快速模式' },
{ value: 'thinking', label: '思考模式' },
{ value: 'deep', label: '深度思考模式' }
] as const;
const runModeLabelMap: Record<'fast' | 'thinking' | 'deep', string> = {
fast: '快速模式',
thinking: '思考模式',
deep: '深度思考'
};
const resolvedRunMode = computed<'fast' | 'thinking' | 'deep'>(() => {
if (props.runMode === 'deep' || props.runMode === 'thinking' || props.runMode === 'fast') {
return props.runMode;
}
return props.thinkingMode ? 'thinking' : 'fast';
});
const runModeLabel = computed(() => runModeLabelMap[resolvedRunMode.value]);
const getIconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {}); const getIconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {});
const currentModelLabel = computed(() => {
const found = props.modelOptions?.find(m => m.key === props.currentModelKey);
return found ? found.label : '未选择';
});
const currentModelSupportsImage = computed(() => { const currentModelSupportsImage = computed(() => {
const found = props.modelOptions?.find(m => m.key === props.currentModelKey) as any; const found = props.modelOptions?.find((m) => m.key === props.currentModelKey) as any;
return !!found?.supportsImage; return !!found?.supportsImage;
}); });
const currentModelSupportsVideo = computed(() => { const currentModelSupportsVideo = computed(() => {
const found = props.modelOptions?.find(m => m.key === props.currentModelKey) as any; const found = props.modelOptions?.find((m) => m.key === props.currentModelKey) as any;
return !!found?.supportsVideo; return !!found?.supportsVideo;
}); });
</script> </script>

View File

@ -3,16 +3,20 @@
<div v-if="activeCommand" class="subagent-activity-overlay" @click.self="close"> <div v-if="activeCommand" class="subagent-activity-overlay" @click.self="close">
<div class="subagent-activity-modal bg-command-modal"> <div class="subagent-activity-modal bg-command-modal">
<div class="subagent-activity-header"> <div class="subagent-activity-header">
<div class="subagent-activity-title"> <div class="subagent-activity-title">后台指令 {{ activeCommand.command_id }}</div>
后台指令 {{ activeCommand.command_id }}
</div>
<button type="button" class="subagent-activity-close" @click="close">×</button> <button type="button" class="subagent-activity-close" @click="close">×</button>
</div> </div>
<div class="subagent-activity-meta"> <div class="subagent-activity-meta">
<span class="subagent-activity-status" :class="activeDetail?.status || activeCommand.status || ''"> <span
class="subagent-activity-status"
:class="activeDetail?.status || activeCommand.status || ''"
>
{{ activeDetail?.status || activeCommand.status || 'running' }} {{ activeDetail?.status || activeCommand.status || 'running' }}
</span> </span>
<span class="subagent-activity-summary" v-if="activeDetail?.command || activeCommand.command"> <span
class="subagent-activity-summary"
v-if="activeDetail?.command || activeCommand.command"
>
{{ activeDetail?.command || activeCommand.command }} {{ activeDetail?.command || activeCommand.command }}
</span> </span>
</div> </div>

View File

@ -8,7 +8,9 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
<div v-if="generatedPath" class="hint">已生成 {{ generatedPath }}</div> <div v-if="generatedPath" class="hint">已生成 {{ generatedPath }}</div>
<button type="button" class="ghost-btn" @click="$emit('close')" :disabled="submitting">关闭</button> <button type="button" class="ghost-btn" @click="$emit('close')" :disabled="submitting">
关闭
</button>
</div> </div>
</div> </div>
@ -63,7 +65,9 @@
<div class="review-right"> <div class="review-right">
<div class="preview-header"> <div class="preview-header">
<span>预览 {{ previewLimit }} </span> <span>预览 {{ previewLimit }} </span>
<span v-if="preview && preview.length" class="preview-count">{{ preview.length }} </span> <span v-if="preview && preview.length" class="preview-count"
>{{ preview.length }} </span
>
</div> </div>
<div class="preview-box" :class="{ loading: previewLoading }"> <div class="preview-box" :class="{ loading: previewLoading }">
<div v-if="previewLoading" class="placeholder"> <div v-if="previewLoading" class="placeholder">
@ -90,7 +94,11 @@
<div class="review-footer"> <div class="review-footer">
<label class="toggle-send"> <label class="toggle-send">
<input type="checkbox" :checked="sendToModel" @change="$emit('toggle-send', ($event.target as HTMLInputElement).checked)" /> <input
type="checkbox"
:checked="sendToModel"
@change="$emit('toggle-send', ($event.target as HTMLInputElement).checked)"
/>
<span class="switch"></span> <span class="switch"></span>
<span class="label">是否发送给模型</span> <span class="label">是否发送给模型</span>
</label> </label>
@ -110,7 +118,7 @@
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: 'ConversationReviewDialog' }); defineOptions({ name: 'ConversationReviewDialog' });
const props = defineProps<{ defineProps<{
open: boolean; open: boolean;
conversations: Array<{ conversations: Array<{
id: string; id: string;
@ -134,9 +142,9 @@ const props = defineProps<{
}>(); }>();
const icons = { const icons = {
loading: new URL('../../icons/clock.svg', import.meta.url).href, loading: new URL('../../../icons/clock.svg', import.meta.url).href,
error: new URL('../../icons/triangle-alert.svg', import.meta.url).href, error: new URL('../../../icons/triangle-alert.svg', import.meta.url).href,
empty: new URL('../../icons/file.svg', import.meta.url).href empty: new URL('../../../icons/file.svg', import.meta.url).href
}; };
defineEmits<{ defineEmits<{
@ -277,7 +285,10 @@ const formatUpdatedAt = (value: string | number) => {
border-radius: 10px; border-radius: 10px;
padding: 14px 14px; padding: 14px 14px;
cursor: pointer; cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
transform 0.15s ease;
overflow: hidden; overflow: hidden;
min-height: 64px; min-height: 64px;
line-height: 1.4; line-height: 1.4;
@ -443,7 +454,10 @@ const formatUpdatedAt = (value: string | number) => {
padding: 10px 18px; padding: 10px 18px;
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease; transition:
transform 0.12s ease,
box-shadow 0.12s ease,
background 0.12s ease;
} }
.toggle-send { .toggle-send {

View File

@ -1,16 +1,11 @@
<template> <template>
<transition name="overlay-fade"> <transition name="overlay-fade">
<div v-if="open" class="image-picker-backdrop" @click.self="close"> <div v-if="open" class="image-picker-backdrop" @click.self="close">
<div class="image-picker-panel"> <div class="image-picker-panel">
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<div class="title">选择图片最多9张</div> <div class="title">选择图片最多9张</div>
<button <button type="button" class="local-btn" :disabled="uploading" @click="triggerLocal">
type="button"
class="local-btn"
:disabled="uploading"
@click="triggerLocal"
>
{{ uploading ? '上传中...' : '从本地发送' }} {{ uploading ? '上传中...' : '从本地发送' }}
</button> </button>
<input <input
@ -25,30 +20,37 @@
<button class="close-btn" @click="close">×</button> <button class="close-btn" @click="close">×</button>
</div> </div>
<div class="body"> <div class="body">
<div v-if="loading" class="loading">加载中...</div> <div v-if="loading" class="loading">加载中...</div>
<div v-else-if="!images.length" class="empty">未找到图片文件</div> <div v-else-if="!images.length" class="empty">未找到图片文件</div>
<div v-else class="grid"> <div v-else class="grid">
<div <div
v-for="item in images" v-for="item in images"
:key="item.path" :key="item.path"
class="card" class="card"
:class="{ selected: selectedSet.has(item.path) }" :class="{ selected: selectedSet.has(item.path) }"
@click="toggle(item.path)" @click="toggle(item.path)"
:title="item.path" :title="item.path"
> >
<img :src="previewUrl(item.path)" :alt="item.name" /> <img :src="previewUrl(item.path)" :alt="item.name" />
<div class="name">{{ item.name }}</div> <div class="name">{{ item.name }}</div>
</div>
</div>
</div>
<div class="footer">
<div class="count">已选 {{ selectedSet.size }} / 9</div>
<div class="actions">
<button type="button" class="btn secondary" @click="close">取消</button>
<button
type="button"
class="btn primary"
:disabled="!selectedSet.size"
@click="confirm"
>
确认
</button>
</div> </div>
</div> </div>
</div> </div>
<div class="footer">
<div class="count">已选 {{ selectedSet.size }} / 9</div>
<div class="actions">
<button type="button" class="btn secondary" @click="close">取消</button>
<button type="button" class="btn primary" :disabled="!selectedSet.size" @click="confirm">确认</button>
</div>
</div>
</div>
</div> </div>
</transition> </transition>
</template> </template>

View File

@ -1,7 +1,12 @@
<template> <template>
<transition name="overlay-fade"> <transition name="overlay-fade">
<div v-if="visible" class="new-user-tutorial-mask" @click.self="emitSkip"> <div v-if="visible" class="new-user-tutorial-mask" @click.self="emitSkip">
<div class="new-user-tutorial-dialog" role="dialog" aria-modal="true" aria-label="新手教程提示"> <div
class="new-user-tutorial-dialog"
role="dialog"
aria-modal="true"
aria-label="新手教程提示"
>
<p class="eyebrow">欢迎</p> <p class="eyebrow">欢迎</p>
<h3>新用户 {{ username || '用户' }}</h3> <h3>新用户 {{ username || '用户' }}</h3>
<p class="desc">是否需要通过新手教程来快速认识这个系统</p> <p class="desc">是否需要通过新手教程来快速认识这个系统</p>
@ -87,7 +92,10 @@ button {
font-weight: 700; font-weight: 700;
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
transition: transform 0.16s ease, box-shadow 0.2s ease, opacity 0.2s ease; transition:
transform 0.16s ease,
box-shadow 0.2s ease,
opacity 0.2s ease;
} }
.primary { .primary {

View File

@ -9,8 +9,12 @@
<button type="button" class="subagent-activity-close" @click="close">×</button> <button type="button" class="subagent-activity-close" @click="close">×</button>
</div> </div>
<div class="subagent-activity-meta"> <div class="subagent-activity-meta">
<span class="subagent-activity-status" :class="activeAgent.status || ''">{{ activeAgent.status || 'running' }}</span> <span class="subagent-activity-status" :class="activeAgent.status || ''">{{
<span class="subagent-activity-summary" v-if="activeAgent.summary">{{ activeAgent.summary }}</span> activeAgent.status || 'running'
}}</span>
<span class="subagent-activity-summary" v-if="activeAgent.summary">{{
activeAgent.summary
}}</span>
</div> </div>
<div class="subagent-activity-body"> <div class="subagent-activity-body">
<div v-if="activityError" class="subagent-activity-error">{{ activityError }}</div> <div v-if="activityError" class="subagent-activity-error">{{ activityError }}</div>

View File

@ -10,12 +10,37 @@
@touchcancel="handleTouchEnd" @touchcancel="handleTouchEnd"
> >
<div class="tutorial-click-blocker" @click.stop></div> <div class="tutorial-click-blocker" @click.stop></div>
<div v-if="stepReady && maskVisible" class="tutorial-mask" :style="[maskTopStyle, maskInteractivityStyle]" @click.stop></div> <div
<div v-if="stepReady && maskVisible" class="tutorial-mask" :style="[maskLeftStyle, maskInteractivityStyle]" @click.stop></div> v-if="stepReady && maskVisible"
<div v-if="stepReady && maskVisible" class="tutorial-mask" :style="[maskRightStyle, maskInteractivityStyle]" @click.stop></div> class="tutorial-mask"
<div v-if="stepReady && maskVisible" class="tutorial-mask" :style="[maskBottomStyle, maskInteractivityStyle]" @click.stop></div> :style="[maskTopStyle, maskInteractivityStyle]"
@click.stop
></div>
<div
v-if="stepReady && maskVisible"
class="tutorial-mask"
:style="[maskLeftStyle, maskInteractivityStyle]"
@click.stop
></div>
<div
v-if="stepReady && maskVisible"
class="tutorial-mask"
:style="[maskRightStyle, maskInteractivityStyle]"
@click.stop
></div>
<div
v-if="stepReady && maskVisible"
class="tutorial-mask"
:style="[maskBottomStyle, maskInteractivityStyle]"
@click.stop
></div>
<div v-if="stepReady && maskVisible" class="tutorial-highlight" :style="highlightStyle" aria-hidden="true"></div> <div
v-if="stepReady && maskVisible"
class="tutorial-highlight"
:style="highlightStyle"
aria-hidden="true"
></div>
<span <span
v-if="clickEffect" v-if="clickEffect"
class="tutorial-click-effect" class="tutorial-click-effect"
@ -36,9 +61,13 @@
</header> </header>
<h3 class="tutorial-popover__title">{{ step?.title || '新手教程' }}</h3> <h3 class="tutorial-popover__title">{{ step?.title || '新手教程' }}</h3>
<p class="tutorial-popover__desc">{{ step?.description || '' }}</p> <p class="tutorial-popover__desc">{{ step?.description || '' }}</p>
<p v-if="isWaitingTarget" class="tutorial-popover__warn">未找到目标元素可稍后重试或跳过当前步骤</p> <p v-if="isWaitingTarget" class="tutorial-popover__warn">
未找到目标元素可稍后重试或跳过当前步骤
</p>
<p v-else-if="isMustClick" class="tutorial-popover__hint">请点击高亮目标继续</p> <p v-else-if="isMustClick" class="tutorial-popover__hint">请点击高亮目标继续</p>
<p v-if="showPersonalScrollHint" class="tutorial-popover__hint">提示个人空间内容可上下滚动查看</p> <p v-if="showPersonalScrollHint" class="tutorial-popover__hint">
提示个人空间内容可上下滚动查看
</p>
<footer class="tutorial-popover__actions"> <footer class="tutorial-popover__actions">
<button type="button" class="tutorial-btn" :disabled="nextDisabled" @click="goNext"> <button type="button" class="tutorial-btn" :disabled="nextDisabled" @click="goNext">
@ -69,7 +98,12 @@ const GAP = 14;
const HIGHLIGHT_PADDING = 2; const HIGHLIGHT_PADDING = 2;
const tutorialStore = useTutorialStore(); const tutorialStore = useTutorialStore();
const { running, activeStep: step, visibleStepIndex, totalVisibleSteps } = storeToRefs(tutorialStore); const {
running,
activeStep: step,
visibleStepIndex,
totalVisibleSteps
} = storeToRefs(tutorialStore);
const panelRef = ref<HTMLElement | null>(null); const panelRef = ref<HTMLElement | null>(null);
const targetRect = ref<RectLike | null>(null); const targetRect = ref<RectLike | null>(null);
@ -89,7 +123,13 @@ const maskVisible = computed(() => Boolean(targetRect.value));
const nextDisabled = computed(() => isMustClick.value && !!targetRect.value); const nextDisabled = computed(() => isMustClick.value && !!targetRect.value);
const isPersonalTutorialPhase = computed(() => { const isPersonalTutorialPhase = computed(() => {
const id = step.value?.id || ''; const id = step.value?.id || '';
return id === 'personal-overview' || id === 'open-personal-space' || id === 'close-personal-space' || id.startsWith('tab-') || id.startsWith('page-'); return (
id === 'personal-overview' ||
id === 'open-personal-space' ||
id === 'close-personal-space' ||
id.startsWith('tab-') ||
id.startsWith('page-')
);
}); });
const showPersonalScrollHint = computed(() => isPersonalTutorialPhase.value); const showPersonalScrollHint = computed(() => isPersonalTutorialPhase.value);
const nextLabel = computed(() => { const nextLabel = computed(() => {
@ -117,7 +157,12 @@ const maskTopStyle = computed(() => {
const maskLeftStyle = computed(() => { const maskLeftStyle = computed(() => {
const rect = clampedRect.value; const rect = clampedRect.value;
if (!rect) return {}; if (!rect) return {};
return { top: `${rect.top}px`, left: '0px', width: `${Math.max(0, rect.left)}px`, height: `${rect.height}px` }; return {
top: `${rect.top}px`,
left: '0px',
width: `${Math.max(0, rect.left)}px`,
height: `${rect.height}px`
};
}); });
const maskRightStyle = computed(() => { const maskRightStyle = computed(() => {
@ -228,10 +273,17 @@ const placePopover = (rect: RectLike | null) => {
const placements: Array<'top' | 'right' | 'bottom' | 'left'> = const placements: Array<'top' | 'right' | 'bottom' | 'left'> =
preferred === 'auto' preferred === 'auto'
? (['right', 'bottom', 'left', 'top'] as Array<'top' | 'right' | 'bottom' | 'left'>).sort((a, b) => spaces[b] - spaces[a]) ? (['right', 'bottom', 'left', 'top'] as Array<'top' | 'right' | 'bottom' | 'left'>).sort(
(a, b) => spaces[b] - spaces[a]
)
: [preferred as 'top' | 'right' | 'bottom' | 'left', 'right', 'bottom', 'left', 'top']; : [preferred as 'top' | 'right' | 'bottom' | 'left', 'right', 'bottom', 'left', 'top'];
let best: { p: 'top' | 'right' | 'bottom' | 'left'; left: number; top: number; overlap: number } | null = null; let best: {
p: 'top' | 'right' | 'bottom' | 'left';
left: number;
top: number;
overlap: number;
} | null = null;
for (const p of Array.from(new Set(placements))) { for (const p of Array.from(new Set(placements))) {
let top = 0; let top = 0;
let left = 0; let left = 0;
@ -251,9 +303,19 @@ const placePopover = (rect: RectLike | null) => {
const clampedLeft = clamp(left, edge, Math.max(edge, vw - panelWidth - edge)); const clampedLeft = clamp(left, edge, Math.max(edge, vw - panelWidth - edge));
const clampedTop = clamp(top, edge, Math.max(edge, vh - panelHeight - edge)); const clampedTop = clamp(top, edge, Math.max(edge, vh - panelHeight - edge));
const fits = left >= edge && top >= edge && left + panelWidth <= vw - edge && top + panelHeight <= vh - edge; const fits =
const overlapX = Math.max(0, Math.min(clampedLeft + panelWidth, rect.right) - Math.max(clampedLeft, rect.left)); left >= edge &&
const overlapY = Math.max(0, Math.min(clampedTop + panelHeight, rect.bottom) - Math.max(clampedTop, rect.top)); top >= edge &&
left + panelWidth <= vw - edge &&
top + panelHeight <= vh - edge;
const overlapX = Math.max(
0,
Math.min(clampedLeft + panelWidth, rect.right) - Math.max(clampedLeft, rect.left)
);
const overlapY = Math.max(
0,
Math.min(clampedTop + panelHeight, rect.bottom) - Math.max(clampedTop, rect.top)
);
const overlap = overlapX * overlapY; const overlap = overlapX * overlapY;
if (fits) { if (fits) {
resolvedPlacement.value = p; resolvedPlacement.value = p;
@ -292,7 +354,14 @@ const updateRect = () => {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
targetRect.value = targetRect.value =
rect.width > 0 && rect.height > 0 rect.width > 0 && rect.height > 0
? { top: rect.top, left: rect.left, width: rect.width, height: rect.height, right: rect.right, bottom: rect.bottom } ? {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
right: rect.right,
bottom: rect.bottom
}
: null; : null;
placePopover(targetRect.value); placePopover(targetRect.value);
}; };
@ -321,12 +390,17 @@ const handleCaptureClick = (event: MouseEvent) => {
}; };
const getPersonalScrollTarget = () => { const getPersonalScrollTarget = () => {
const pageCandidates = Array.from(document.querySelectorAll('.personalization-content .personal-page')) as HTMLElement[]; const pageCandidates = Array.from(
document.querySelectorAll('.personalization-content .personal-page')
) as HTMLElement[];
const pageScrollable = pageCandidates.find((el) => el.scrollHeight - el.clientHeight > 2); const pageScrollable = pageCandidates.find((el) => el.scrollHeight - el.clientHeight > 2);
if (pageScrollable) return pageScrollable; if (pageScrollable) return pageScrollable;
const content = document.querySelector('.personalization-content') as HTMLElement | null; const content = document.querySelector('.personalization-content') as HTMLElement | null;
if (content && content.scrollHeight - content.clientHeight > 2) return content; if (content && content.scrollHeight - content.clientHeight > 2) return content;
return (document.querySelector('[data-tutorial="personal-content-shell"]') as HTMLElement | null) || content; return (
(document.querySelector('[data-tutorial="personal-content-shell"]') as HTMLElement | null) ||
content
);
}; };
const handleWheel = (event: WheelEvent) => { const handleWheel = (event: WheelEvent) => {
@ -397,7 +471,12 @@ const triggerAutoClick = () => {
const targetEl = menu || (document.body as HTMLElement); const targetEl = menu || (document.body as HTMLElement);
playClickEffect(targetEl); playClickEffect(targetEl);
window.setTimeout(() => { window.setTimeout(() => {
const event = new MouseEvent('click', { bubbles: true, cancelable: true, clientX: 12, clientY: 12 }); const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
clientX: 12,
clientY: 12
});
document.body.dispatchEvent(event); document.body.dispatchEvent(event);
}, 420); }, 420);
return true; return true;
@ -420,9 +499,12 @@ const triggerAutoClick = () => {
}, },
shouldScroll ? 220 : 0 shouldScroll ? 220 : 0
); );
window.setTimeout(() => { window.setTimeout(
(el as HTMLElement).click(); () => {
}, shouldScroll ? 640 : 420); (el as HTMLElement).click();
},
shouldScroll ? 640 : 420
);
return true; return true;
}; };
@ -447,7 +529,7 @@ const handleExit = () => tutorialStore.exitTutorial();
watch( watch(
() => [running.value, step.value?.id], () => [running.value, step.value?.id],
async ([isRunning, stepId], [prevRunning, prevStepId]) => { async ([, stepId], [prevRunning, prevStepId]) => {
if (!running.value) { if (!running.value) {
targetRect.value = null; targetRect.value = null;
stepReady.value = true; stepReady.value = true;
@ -461,7 +543,9 @@ watch(
} }
const nextStep = step.value; const nextStep = step.value;
const settleDelay = const settleDelay =
nextStep?.mode === 'must_click' || nextStep?.autoClick || nextStep?.autoOutsideClick ? 500 : 0; nextStep?.mode === 'must_click' || nextStep?.autoClick || nextStep?.autoOutsideClick
? 500
: 0;
stepSettleTimer = window.setTimeout(async () => { stepSettleTimer = window.setTimeout(async () => {
await nextTick(); await nextTick();
updateRect(); updateRect();

View File

@ -1,16 +1,11 @@
<template> <template>
<transition name="overlay-fade"> <transition name="overlay-fade">
<div v-if="open" class="image-picker-backdrop" @click.self="close"> <div v-if="open" class="image-picker-backdrop" @click.self="close">
<div class="image-picker-panel"> <div class="image-picker-panel">
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<div class="title">选择视频一次最多 1 </div> <div class="title">选择视频一次最多 1 </div>
<button <button type="button" class="local-btn" :disabled="uploading" @click="triggerLocal">
type="button"
class="local-btn"
:disabled="uploading"
@click="triggerLocal"
>
{{ uploading ? '上传中...' : '从本地发送' }} {{ uploading ? '上传中...' : '从本地发送' }}
</button> </button>
<input <input
@ -47,7 +42,14 @@
<div class="count">已选 {{ selectedSet.size }} / 1</div> <div class="count">已选 {{ selectedSet.size }} / 1</div>
<div class="actions"> <div class="actions">
<button type="button" class="btn secondary" @click="close">取消</button> <button type="button" class="btn secondary" @click="close">取消</button>
<button type="button" class="btn primary" :disabled="!selectedSet.size" @click="confirm">确认</button> <button
type="button"
class="btn primary"
:disabled="!selectedSet.size"
@click="confirm"
>
确认
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,9 @@
<template> <template>
<aside class="sidebar right-sidebar" :class="{ collapsed }" :style="{ width: collapsed ? '0px' : width + 'px' }"> <aside
class="sidebar right-sidebar"
:class="{ collapsed }"
:style="{ width: collapsed ? '0px' : width + 'px' }"
>
<div class="sidebar-header"> <div class="sidebar-header">
<h3 class="icon-label"> <h3 class="icon-label">
<span class="icon icon-sm" :style="iconStyle('eye')" aria-hidden="true"></span> <span class="icon icon-sm" :style="iconStyle('eye')" aria-hidden="true"></span>

View File

@ -4,7 +4,11 @@
<div class="compact-status-card"> <div class="compact-status-card">
<div class="status-line"> <div class="status-line">
<div class="status-brand"> <div class="status-brand">
<span class="status-logo-image status-logo-inline" v-html="statusLogoSvg" aria-hidden="true"></span> <span
class="status-logo-image status-logo-inline"
v-html="statusLogoSvg"
aria-hidden="true"
></span>
<div class="brand-text"> <div class="brand-text">
<span class="brand-name">AI Agent</span> <span class="brand-name">AI Agent</span>
<span class="agent-version" v-if="agentVersion">{{ agentVersion }}</span> <span class="agent-version" v-if="agentVersion">{{ agentVersion }}</span>
@ -42,7 +46,12 @@
<div class="sidebar-panel-card"> <div class="sidebar-panel-card">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="panel-menu-wrapper" ref="panelMenuWrapper"> <div class="panel-menu-wrapper" ref="panelMenuWrapper">
<button class="sidebar-view-toggle" data-tutorial="panel-menu-toggle" @click.stop="$emit('toggle-panel-menu')" title="切换侧边栏"> <button
class="sidebar-view-toggle"
data-tutorial="panel-menu-toggle"
@click.stop="$emit('toggle-panel-menu')"
title="切换侧边栏"
>
<span class="icon icon-md" :style="iconStyle('menu')" aria-hidden="true"></span> <span class="icon icon-md" :style="iconStyle('menu')" aria-hidden="true"></span>
</button> </button>
<transition name="fade"> <transition name="fade">
@ -61,7 +70,11 @@
@click.stop="$emit('select-panel', 'todo')" @click.stop="$emit('select-panel', 'todo')"
title="待办列表" title="待办列表"
> >
<span class="icon icon-md" :style="iconStyle('stickyNote')" aria-hidden="true"></span> <span
class="icon icon-md"
:style="iconStyle('stickyNote')"
aria-hidden="true"
></span>
</button> </button>
<button <button
type="button" type="button"
@ -77,7 +90,11 @@
@click.stop="$emit('select-panel', 'backgroundCommands')" @click.stop="$emit('select-panel', 'backgroundCommands')"
title="后台指令" title="后台指令"
> >
<span class="icon icon-md" :style="iconStyle('terminal')" aria-hidden="true"></span> <span
class="icon icon-md"
:style="iconStyle('terminal')"
aria-hidden="true"
></span>
</button> </button>
</div> </div>
</transition> </transition>
@ -114,10 +131,19 @@
<div v-if="panelMode === 'todo'" class="todo-panel"> <div v-if="panelMode === 'todo'" class="todo-panel">
<div v-if="!todoList" class="todo-empty">暂无待办列表</div> <div v-if="!todoList" class="todo-empty">暂无待办列表</div>
<div v-else> <div v-else>
<div class="todo-task" v-for="task in todoList.tasks || []" :key="task.index" :class="{ done: task.status === 'done' }"> <div
class="todo-task"
v-for="task in todoList.tasks || []"
:key="task.index"
:class="{ done: task.status === 'done' }"
>
<span class="todo-task-title">task{{ task.index }}{{ task.title }}</span> <span class="todo-task-title">task{{ task.index }}{{ task.title }}</span>
<span class="todo-task-status icon-label"> <span class="todo-task-status icon-label">
<span class="icon icon-sm" :style="iconStyle(task.status === 'done' ? 'check' : 'checkbox')" aria-hidden="true"></span> <span
class="icon icon-sm"
:style="iconStyle(task.status === 'done' ? 'check' : 'checkbox')"
aria-hidden="true"
></span>
<span>{{ task.status === 'done' ? '完成' : '未完成' }}</span> <span>{{ task.status === 'done' ? '完成' : '未完成' }}</span>
</span> </span>
</div> </div>
@ -127,7 +153,12 @@
<div v-else-if="panelMode === 'subAgents'" class="sub-agent-panel"> <div v-else-if="panelMode === 'subAgents'" class="sub-agent-panel">
<div v-if="!subAgents.length" class="sub-agent-empty">暂无运行中的子智能体</div> <div v-if="!subAgents.length" class="sub-agent-empty">暂无运行中的子智能体</div>
<div v-else class="sub-agent-cards"> <div v-else class="sub-agent-cards">
<div class="sub-agent-card" v-for="agent in subAgents" :key="agent.task_id" @click="openSubAgent(agent)"> <div
class="sub-agent-card"
v-for="agent in subAgents"
:key="agent.task_id"
@click="openSubAgent(agent)"
>
<div class="sub-agent-header"> <div class="sub-agent-header">
<span class="sub-agent-id">#{{ agent.agent_id }}</span> <span class="sub-agent-id">#{{ agent.agent_id }}</span>
<span class="sub-agent-status" :class="agent.status">{{ agent.status }}</span> <span class="sub-agent-status" :class="agent.status">{{ agent.status }}</span>
@ -147,10 +178,14 @@
@click="openBackgroundCommand(command)" @click="openBackgroundCommand(command)"
> >
<div class="sub-agent-header"> <div class="sub-agent-header">
<span class="sub-agent-id">{{ command.command_id }}</span> <span class="sub-agent-id">状态</span>
<span class="sub-agent-status" :class="command.status">{{ command.status || 'running' }}</span> <span class="sub-agent-status" :class="command.status">{{
formatBackgroundCommandStatus(command.status)
}}</span>
</div>
<div class="sub-agent-summary background-command-summary">
{{ formatBackgroundCommandPreview(command.command) }}
</div> </div>
<div class="sub-agent-summary">{{ command.command || '(空命令)' }}</div>
<div class="sub-agent-tool"> <div class="sub-agent-tool">
{{ formatBackgroundCommandMeta(command) }} {{ formatBackgroundCommandMeta(command) }}
</div> </div>
@ -209,7 +244,7 @@ defineEmits<{
}>(); }>();
const panelMenuWrapper = ref<HTMLElement | null>(null); const panelMenuWrapper = ref<HTMLElement | null>(null);
const statusLogoSvg = statusLogoSvgRaw.replace(/fill=\"#000000\"/g, 'fill=\"currentColor\"'); const statusLogoSvg = statusLogoSvgRaw.replace(/fill="#000000"/g, 'fill="currentColor"');
const panelStyle = computed(() => { const panelStyle = computed(() => {
if (props.collapsed) { if (props.collapsed) {
return { return {
@ -266,7 +301,8 @@ const modeIndicatorTitle = computed(() => {
const fileStore = useFileStore(); const fileStore = useFileStore();
const subAgentStore = useSubAgentStore(); const subAgentStore = useSubAgentStore();
const backgroundCommandStore = useBackgroundCommandStore(); const backgroundCommandStore = useBackgroundCommandStore();
const { fileTree, expandedFolders, todoList, fileTreeUnavailable, fileTreeMessage } = storeToRefs(fileStore); const { fileTree, expandedFolders, todoList, fileTreeUnavailable, fileTreeMessage } =
storeToRefs(fileStore);
const { subAgents } = storeToRefs(subAgentStore); const { subAgents } = storeToRefs(subAgentStore);
const { commands: backgroundCommands } = storeToRefs(backgroundCommandStore); const { commands: backgroundCommands } = storeToRefs(backgroundCommandStore);
@ -281,9 +317,22 @@ const openBackgroundCommand = (command: any) => {
const formatBackgroundCommandMeta = (command: any) => { const formatBackgroundCommandMeta = (command: any) => {
const code = command?.return_code; const code = command?.return_code;
if (code === null || code === undefined) { if (code === null || code === undefined) {
return '点击查看实时输出'; return '状态码:--';
} }
return `返回码:${code}`; return `状态码:${code}`;
};
const formatBackgroundCommandStatus = (status: any) => {
const normalized = (status || 'running').toString().toLowerCase();
return normalized;
};
const formatBackgroundCommandPreview = (rawCommand: any) => {
const content = (rawCommand || '(空命令)').toString().replace(/\s+/g, ' ').trim();
if (!content) {
return '(空命令)';
}
return content;
}; };
defineExpose({ defineExpose({

View File

@ -143,7 +143,7 @@ function confirmSelection() {
border-radius: 14px; border-radius: 14px;
padding: 16px 14px; padding: 16px 14px;
cursor: pointer; cursor: pointer;
transition: .2s ease; transition: 0.2s ease;
box-shadow: 0 8px 18px rgba(61, 57, 41, 0.06); box-shadow: 0 8px 18px rgba(61, 57, 41, 0.06);
background: #fff; background: #fff;
} }

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,11 @@
<button type="button" class="confirm-button" @click="resolve(false)"> <button type="button" class="confirm-button" @click="resolve(false)">
{{ dialog.cancelText || '取消' }} {{ dialog.cancelText || '取消' }}
</button> </button>
<button type="button" class="confirm-button confirm-button--primary" @click="resolve(true)"> <button
type="button"
class="confirm-button confirm-button--primary"
@click="resolve(true)"
>
{{ dialog.confirmText || '确认' }} {{ dialog.confirmText || '确认' }}
</button> </button>
</div> </div>

View File

@ -1,5 +1,10 @@
<template> <template>
<div class="easter-egg-overlay" :class="{ active: state.active }" aria-hidden="true" ref="overlayRoot"></div> <div
class="easter-egg-overlay"
:class="{ active: state.active }"
aria-hidden="true"
ref="overlayRoot"
></div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@ -2,9 +2,7 @@
<transition name="quota-toast-fade"> <transition name="quota-toast-fade">
<div class="quota-toast" v-if="quotaToast"> <div class="quota-toast" v-if="quotaToast">
<span class="quota-toast-label">{{ quotaToast.message }}</span> <span class="quota-toast-label">{{ quotaToast.message }}</span>
<button type="button" class="toast-close" aria-label="关闭通知" @click="dismiss"> <button type="button" class="toast-close" aria-label="关闭通知" @click="dismiss">×</button>
×
</button>
</div> </div>
</transition> </transition>
</template> </template>

View File

@ -19,8 +19,18 @@
stroke-width="1.7" stroke-width="1.7"
stroke-linejoin="round" stroke-linejoin="round"
/> />
<path d="M9 9.5h10" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" /> <path
<path d="M9 13h6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" /> d="M9 9.5h10"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
/>
<path
d="M9 13h6"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
/>
</svg> </svg>
</slot> </slot>
</span> </span>
@ -62,7 +72,12 @@
@click="$emit('toggle-workspace')" @click="$emit('toggle-workspace')"
> >
<span class="sr-only">{{ workspaceCollapsed ? '显示工作区' : '隐藏工作区' }}</span> <span class="sr-only">{{ workspaceCollapsed ? '显示工作区' : '隐藏工作区' }}</span>
<span class="icon icon-md" data-tutorial="workspace-toggle" aria-hidden="true" :style="iconStyle('layers')"></span> <span
class="icon icon-md"
data-tutorial="workspace-toggle"
aria-hidden="true"
:style="iconStyle('layers')"
></span>
</button> </button>
<button <button
type="button" type="button"
@ -72,8 +87,15 @@
:aria-disabled="displayModeDisabled" :aria-disabled="displayModeDisabled"
:title="displayMode === 'monitor' ? '退出显示器' : '虚拟显示器'" :title="displayMode === 'monitor' ? '退出显示器' : '虚拟显示器'"
> >
<span class="sr-only">{{ displayMode === 'monitor' ? '退出显示器' : '虚拟显示器' }}</span> <span class="sr-only">{{
<span class="icon icon-md" data-tutorial="monitor-toggle" aria-hidden="true" :style="iconStyle('monitor')"></span> displayMode === 'monitor' ? '退出显示器' : '虚拟显示器'
}}</span>
<span
class="icon icon-md"
data-tutorial="monitor-toggle"
aria-hidden="true"
:style="iconStyle('monitor')"
></span>
</button> </button>
</div> </div>
</template> </template>
@ -85,7 +107,11 @@
@click="$emit('create')" @click="$emit('create')"
> >
<template v-if="monitorModeActive"> <template v-if="monitorModeActive">
<span class="monitor-icon-slot icon icon-md" :style="iconStyle('pencil')" aria-hidden="true"></span> <span
class="monitor-icon-slot icon icon-md"
:style="iconStyle('pencil')"
aria-hidden="true"
></span>
<span class="sr-only">新建对话</span> <span class="sr-only">新建对话</span>
</template> </template>
<template v-else> <template v-else>
@ -111,13 +137,22 @@
<span class="sr-only">{{ collapsed ? '展开对话记录' : '折叠对话记录' }}</span> <span class="sr-only">{{ collapsed ? '展开对话记录' : '折叠对话记录' }}</span>
</template> </template>
<template v-else> <template v-else>
<span v-if="collapsed" class="icon icon-md" :style="iconStyle('menu')" aria-hidden="true"></span> <span
v-if="collapsed"
class="icon icon-md"
:style="iconStyle('menu')"
aria-hidden="true"
></span>
<span v-else class="toggle-arrow" aria-hidden="true"></span> <span v-else class="toggle-arrow" aria-hidden="true"></span>
</template> </template>
</template> </template>
<template v-else> <template v-else>
<template v-if="monitorModeActive"> <template v-if="monitorModeActive">
<span class="monitor-icon-slot icon icon-md" :style="iconStyle('x')" aria-hidden="true"></span> <span
class="monitor-icon-slot icon icon-md"
:style="iconStyle('x')"
aria-hidden="true"
></span>
<span class="sr-only">关闭侧边栏</span> <span class="sr-only">关闭侧边栏</span>
</template> </template>
<template v-else> <template v-else>
@ -136,13 +171,20 @@
:value="searchQuery" :value="searchQuery"
placeholder="搜索对话..." placeholder="搜索对话..."
@input="$emit('search', ($event.target as HTMLInputElement).value)" @input="$emit('search', ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="$emit('search-submit', ($event.target as HTMLInputElement).value)" @keydown.enter.prevent="
$emit('search-submit', ($event.target as HTMLInputElement).value)
"
/> />
</div> </div>
</div> </div>
<div class="conversation-list"> <div class="conversation-list">
<div v-if="loading && !displayConversations.length && !searchActive" class="loading-conversations">正在加载...</div> <div
v-if="loading && !displayConversations.length && !searchActive"
class="loading-conversations"
>
正在加载...
</div>
<div v-else-if="!displayConversations.length && !loading" class="no-conversations"> <div v-else-if="!displayConversations.length && !loading" class="no-conversations">
{{ searchActive ? '未找到匹配对话' : '暂无对话记录' }} {{ searchActive ? '未找到匹配对话' : '暂无对话记录' }}
</div> </div>
@ -158,7 +200,7 @@
<div class="conversation-meta"> <div class="conversation-meta">
<span class="conversation-time">{{ formatTime(conv.updated_at) }}</span> <span class="conversation-time">{{ formatTime(conv.updated_at) }}</span>
<span class="conversation-counts"> <span class="conversation-counts">
{{ (conv.total_messages || 0) }}条消息 {{ conv.total_messages || 0 }}条消息
<span v-if="(conv.total_tools || 0) > 0"> · {{ conv.total_tools }}工具</span> <span v-if="(conv.total_tools || 0) > 0"> · {{ conv.total_tools }}工具</span>
</span> </span>
</div> </div>
@ -183,7 +225,12 @@
</div> </div>
</div> </div>
<div v-if="!searchActive && hasMore" class="load-more"> <div v-if="!searchActive && hasMore" class="load-more">
<button class="load-more-btn" type="button" :disabled="loadingMore" @click="$emit('load-more')"> <button
class="load-more-btn"
type="button"
:disabled="loadingMore"
@click="$emit('load-more')"
>
{{ loadingMore ? '载入中...' : '加载更多' }} {{ loadingMore ? '载入中...' : '加载更多' }}
</button> </button>
</div> </div>
@ -222,7 +269,12 @@
@click="$emit('personal')" @click="$emit('personal')"
> >
<span class="sr-only">个人页面</span> <span class="sr-only">个人页面</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path <path
fill-rule="evenodd" fill-rule="evenodd"
clip-rule="evenodd" clip-rule="evenodd"
@ -279,7 +331,11 @@ const uiStore = useUiStore();
const conversationStore = useConversationStore(); const conversationStore = useConversationStore();
const personalizationStore = usePersonalizationStore(); const personalizationStore = usePersonalizationStore();
const { sidebarCollapsed: collapsed, workspaceCollapsed, chatDisplayMode: uiChatDisplayMode } = storeToRefs(uiStore); const {
sidebarCollapsed: collapsed,
workspaceCollapsed,
chatDisplayMode: uiChatDisplayMode
} = storeToRefs(uiStore);
const { const {
searchQuery, searchQuery,
searchResults, searchResults,
@ -299,9 +355,14 @@ const iconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {})
const resolvedShowCollapseButton = computed(() => props.showCollapseButton); const resolvedShowCollapseButton = computed(() => props.showCollapseButton);
const resolvedCollapseButtonVariant = computed(() => props.collapseButtonVariant); const resolvedCollapseButtonVariant = computed(() => props.collapseButtonVariant);
const displayMode = computed(() => props.displayMode); const displayMode = computed(() => props.displayMode);
const monitorModeActive = computed(() => displayMode.value === 'monitor' || uiChatDisplayMode.value === 'monitor'); const monitorModeActive = computed(
() => displayMode.value === 'monitor' || uiChatDisplayMode.value === 'monitor'
);
const displayModeDisabled = computed(() => props.displayModeDisabled); const displayModeDisabled = computed(() => props.displayModeDisabled);
const displayConversations = computed(() => (searchActive.value ? searchResults.value : conversations.value)); const displayConversations = computed(() =>
const loading = computed(() => (searchActive.value ? searchInProgress.value : conversationsLoading.value)); searchActive.value ? searchResults.value : conversations.value
);
const loading = computed(() =>
searchActive.value ? searchInProgress.value : conversationsLoading.value
);
</script> </script>

View File

@ -1,7 +1,13 @@
<template> <template>
<div class="token-drawer" v-if="visible" :class="{ collapsed }" data-tutorial="token-drawer"> <div class="token-drawer" v-if="visible" :class="{ collapsed }" data-tutorial="token-drawer">
<div class="token-display-panel"> <div class="token-display-panel">
<button class="token-close-btn" type="button" data-tutorial="token-close" @click="emit('toggle')" aria-label="收起用量统计"> <button
class="token-close-btn"
type="button"
data-tutorial="token-close"
@click="emit('toggle')"
aria-label="收起用量统计"
>
<span class="sr-only">关闭</span> <span class="sr-only">关闭</span>
</button> </button>
<div class="token-panel-content"> <div class="token-panel-content">
@ -11,7 +17,9 @@
<div class="stat-grid stat-grid--triple"> <div class="stat-grid stat-grid--triple">
<div class="stat-block"> <div class="stat-block">
<div class="stat-label">当前上下文</div> <div class="stat-label">当前上下文</div>
<div class="stat-value stat-value--accent">{{ formatTokenCount(currentContextTokens || 0) }}</div> <div class="stat-value stat-value--accent">
{{ formatTokenCount(currentContextTokens || 0) }}
</div>
</div> </div>
<div class="stat-block"> <div class="stat-block">
<div class="stat-label">累计输入</div> <div class="stat-label">累计输入</div>
@ -39,18 +47,20 @@
<div class="stat-grid stat-grid--double"> <div class="stat-grid stat-grid--double">
<div class="stat-block"> <div class="stat-block">
<div class="stat-label">CPU</div> <div class="stat-label">CPU</div>
<div class="stat-value">{{ formatPercentage(containerStatus.stats.cpu_percent) }}</div> <div class="stat-value">
{{ formatPercentage(containerStatus.stats.cpu_percent) }}
</div>
</div> </div>
<div class="stat-block"> <div class="stat-block">
<div class="stat-label">内存</div> <div class="stat-label">内存</div>
<div class="stat-value stat-value--mono"> <div class="stat-value stat-value--mono">
{{ formatBytes(containerStatus.stats.memory.used_bytes) }} {{ formatBytes(containerStatus.stats.memory.used_bytes) }}
<template v-if="containerStatus.stats.memory.limit_bytes"> <template v-if="containerStatus.stats.memory.limit_bytes">
/ {{ formatBytes(containerStatus.stats.memory.limit_bytes) }} / {{ formatBytes(containerStatus.stats.memory.limit_bytes) }}
</template> </template>
</div> </div>
<div class="stat-foot" v-if="containerStatus.stats.memory.percent"> <div class="stat-foot" v-if="containerStatus.stats.memory.percent">
{{ formatPercentage(containerStatus.stats.memory.percent) }} {{ formatPercentage(containerStatus.stats.memory.percent) }}
</div> </div>
</div> </div>
</div> </div>
@ -65,7 +75,9 @@
<div class="stat-block" v-for="tier in quotaTiers" :key="tier.key"> <div class="stat-block" v-for="tier in quotaTiers" :key="tier.key">
<div class="stat-label">{{ tier.label }}</div> <div class="stat-label">{{ tier.label }}</div>
<div class="stat-value">{{ formatQuotaValue(tier.value) }}</div> <div class="stat-value">{{ formatQuotaValue(tier.value) }}</div>
<div class="stat-foot" v-if="(tier.value.count || 0) > 0">重置 {{ formatResetTime(tier.value.reset_at) }}</div> <div class="stat-foot" v-if="(tier.value.count || 0) > 0">
重置 {{ formatResetTime(tier.value.reset_at) }}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -75,14 +87,18 @@
<div class="stat-block"> <div class="stat-block">
<div class="stat-label">网络</div> <div class="stat-label">网络</div>
<div class="stat-value stat-value--mono"> <div class="stat-value stat-value--mono">
{{ formatRate(containerNetRate.down_bps) }} {{ formatRate(containerNetRate.up_bps) }} {{ formatRate(containerNetRate.down_bps) }} {{
formatRate(containerNetRate.up_bps)
}}
</div> </div>
</div> </div>
<div class="stat-block"> <div class="stat-block">
<div class="stat-label">存储</div> <div class="stat-label">存储</div>
<div class="stat-value stat-value--mono"> <div class="stat-value stat-value--mono">
{{ formatBytes(projectStorage.used_bytes) }} {{ formatBytes(projectStorage.used_bytes) }}
<template v-if="projectStorage.limit_bytes">/ {{ formatBytes(projectStorage.limit_bytes) }}</template> <template v-if="projectStorage.limit_bytes"
>/ {{ formatBytes(projectStorage.limit_bytes) }}</template
>
</div> </div>
<div class="stat-foot" v-if="typeof projectStorage.usage_percent === 'number'"> <div class="stat-foot" v-if="typeof projectStorage.usage_percent === 'number'">
{{ projectStorage.usage_percent.toFixed(1) }}% {{ projectStorage.usage_percent.toFixed(1) }}%
@ -108,7 +124,10 @@ const emit = defineEmits<{
const props = defineProps<{ const props = defineProps<{
visible: boolean; visible: boolean;
collapsed: boolean; collapsed: boolean;
currentConversationTokens: { cumulative_input_tokens?: number; cumulative_output_tokens?: number }; currentConversationTokens: {
cumulative_input_tokens?: number;
cumulative_output_tokens?: number;
};
currentContextTokens: number; currentContextTokens: number;
containerStatus: any; containerStatus: any;
containerNetRate: { down_bps: number | null; up_bps: number | null }; containerNetRate: { down_bps: number | null; up_bps: number | null };

View File

@ -464,6 +464,12 @@
margin-bottom: 4px; margin-bottom: 4px;
} }
.background-command-summary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub-agent-tool { .sub-agent-tool {
font-size: 12px; font-size: 12px;
color: var(--claude-text-secondary); color: var(--claude-text-secondary);