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"
/>
<div
v-if="titleRibbonVisible"
class="conversation-ribbon"
ref="titleRibbon"
>
<div v-if="titleRibbonVisible" class="conversation-ribbon" ref="titleRibbon">
<button
type="button"
class="conversation-ribbon__selector"
@ -135,18 +131,23 @@
<span class="selector-mode">{{ headerRunModeLabel }}</span>
</span>
<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>
</button>
<span class="conversation-ribbon__text">{{ titleTypingText || currentConversationTitle }}</span>
<span class="conversation-ribbon__text">{{
titleTypingText || currentConversationTitle
}}</span>
<transition name="header-menu">
<div
v-if="headerMenuOpen"
class="model-mode-dropdown"
ref="headerMenu"
>
<div v-if="headerMenuOpen" class="model-mode-dropdown" ref="headerMenu">
<div class="dropdown-column" data-tutorial="header-model-options">
<div class="dropdown-title">模型</div>
<div class="dropdown-list dropdown-list--models">
@ -204,7 +205,7 @@
:format-search-time="formatSearchTime"
:format-search-domains="formatSearchDomains"
/>
<VirtualMonitorSurface v-show="chatDisplayMode === 'monitor'" />
<VirtualMonitorSurface v-if="chatDisplayMode === 'monitor'" />
<div v-if="blankHeroActive" class="blank-hero-overlay">
<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="M9 11V8a3 3 0 0 1 6 0v3" />
</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="M7 13l5 5 5-5" />
</svg>
@ -298,10 +305,14 @@
</div>
</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>
<PersonalizationDrawer />
<PersonalizationDrawer v-if="personalizationStore.visible" />
<transition name="overlay-fade">
<ImagePicker
v-if="imagePickerOpen"
@ -354,7 +365,7 @@
</transition>
<SubAgentActivityDialog />
<BackgroundCommandDialog />
<TutorialOverlay />
<TutorialOverlay v-if="tutorialStore.running" />
<NewUserTutorialPrompt
:visible="tutorialPromptVisible"
:username="tutorialPromptUsername"
@ -370,7 +381,13 @@
ref="mobilePanelTrigger"
>
<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" />
</button>
<button
@ -409,8 +426,18 @@
stroke-linecap="round"
stroke-linejoin="round"
/>
<path 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" />
<path
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>
<div class="item-label">对话记录</div>
</button>
@ -421,7 +448,12 @@
aria-label="工作文件"
@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>
</button>
<button
@ -431,7 +463,12 @@
aria-label="个人空间"
@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>
</button>
<button
@ -441,7 +478,12 @@
aria-label="新建对话"
@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>
</button>
<button
@ -451,7 +493,12 @@
aria-label="刷新页面"
@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>
</button>
</div>
@ -537,7 +584,13 @@
@click.self="closeMobileOverlay"
>
<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>
<LeftPanel
@ -561,14 +614,23 @@
</template>
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
import appOptions from './app';
import VirtualMonitorSurface from './components/chat/VirtualMonitorSurface.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 isAppShell =
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 = {
workspace: new URL('../icons/folder.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">
{{ secondaryLoading ? '校验中...' : '确认进入' }}
</button>
<button type="button" class="ghost-btn" :disabled="secondaryLoading" @click="checkSecondary">重新检测</button>
<button
type="button"
class="ghost-btn"
:disabled="secondaryLoading"
@click="checkSecondary"
>
重新检测
</button>
</div>
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
</div>
@ -31,9 +38,7 @@
<p>最近更新{{ timeAgo(snapshot?.generated_at || overview.generated_at) }}</p>
</div>
<div class="header-actions">
<label>
<input type="checkbox" v-model="autoRefresh" /> 自动刷新
</label>
<label> <input type="checkbox" v-model="autoRefresh" /> 自动刷新 </label>
<button type="button" :disabled="refreshing" @click="handleManualRefresh">
{{ refreshing ? '刷新中...' : '立即刷新' }}
</button>
@ -215,7 +220,10 @@
<td>{{ formatPercentNumber(item.stats?.memory?.percent) }}</td>
<td>{{ formatBytes(item.stats?.memory?.used_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>
<span :class="['status-badge', containerStatusClass(item)]">
{{ item.state?.status || (item.state?.running ? '运行中' : '未知') }}
@ -254,12 +262,14 @@
</div>
<div class="upload-meta">
<span>{{ timeAgo(upload.timestamp) }}</span>
<span :class="['status-badge', upload.accepted ? 'online' : 'danger']">
{{ upload.accepted ? '通过' : '阻断' }}
</span>
<span v-if="upload.error?.message" class="status-badge danger">{{ upload.error.message }}</span>
</div>
</li>
<span :class="['status-badge', upload.accepted ? 'online' : 'danger']">
{{ upload.accepted ? '通过' : '阻断' }}
</span>
<span v-if="upload.error?.message" class="status-badge danger">{{
upload.error.message
}}</span>
</div>
</li>
</ul>
</section>
<section v-else-if="activeSection === 'balance'" key="balance" class="panel">
@ -269,7 +279,9 @@
<p class="muted">手动刷新查看 Kimi / DeepSeek / Qwen 余额</p>
</div>
<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">
{{ balanceLoading ? '查询中...' : '手动刷新' }}
</button>
@ -280,7 +292,12 @@
<span>{{ balanceError }}</span>
</section>
<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">
<h3>{{ card.label }}</h3>
<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.cash !== undefined">现金{{ card.extras.cash }}</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>
</ul>
<p v-else class="muted small">{{ card.error || '未知错误' }}</p>
@ -341,10 +360,20 @@
<h4>{{ code.code }}</h4>
<span>{{ inviteStatus(code.remaining) }}</span>
<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 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>
</div>
@ -371,7 +400,13 @@ type Snapshot = Record<string, any> | null;
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 loading = ref(true);
@ -474,8 +509,17 @@ const fetchCsrfToken = async (): Promise<string> => {
};
const parseInviteRemaining = (raw: unknown): number | null => {
const text = String(raw ?? '').trim().toLowerCase();
if (!text || text === 'unlimited' || text === 'null' || text === 'none' || text === '不限' || text === '无限制') {
const text = String(raw ?? '')
.trim()
.toLowerCase();
if (
!text ||
text === 'unlimited' ||
text === 'null' ||
text === 'none' ||
text === '不限' ||
text === '无限制'
) {
return null;
}
const parsed = Number(text);
@ -494,16 +538,18 @@ const createInvite = async () => {
inviteSubmitting.value = true;
inviteError.value = null;
try {
const remaining = newInviteUnlimited.value ? null : parseInviteRemaining(newInviteRemaining.value);
const remaining = newInviteUnlimited.value
? null
: parseInviteRemaining(newInviteRemaining.value);
const csrfToken = await fetchCsrfToken();
const resp = await fetch('/api/admin/invites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
'X-CSRF-Token': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ code, remaining }),
body: JSON.stringify({ code, remaining })
});
const payload = await resp.json();
if (!resp.ok || !payload.success) {
@ -534,10 +580,10 @@ const editInvite = async (item: any) => {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
'X-CSRF-Token': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ code: item?.code, remaining }),
body: JSON.stringify({ code: item?.code, remaining })
});
const payload = await resp.json();
if (!resp.ok || !payload.success) {
@ -562,10 +608,10 @@ const removeInvite = async (code: string) => {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
'X-CSRF-Token': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ code }),
body: JSON.stringify({ code })
});
const payload = await resp.json();
if (!resp.ok || !payload.success) {
@ -693,9 +739,11 @@ const balanceCards = computed(() => {
success,
currency,
amount,
amountCny: data.available_cny ?? (currency === 'USD' && usdToCnyRate.value && amount != null
? Math.round(amount * usdToCnyRate.value * 100) / 100
: null),
amountCny:
data.available_cny ??
(currency === 'USD' && usdToCnyRate.value && amount != null
? Math.round(amount * usdToCnyRate.value * 100) / 100
: null),
extras: data,
error: success ? null : data.error
};
@ -717,7 +765,8 @@ const metricCards = computed(() => [
title: '容器使用',
value: `${containerSummary.value.active || 0}/${containerSummary.value.max_containers ?? '∞'}`,
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} 个空闲`
},
@ -819,7 +868,7 @@ const inviteStatus = (remaining: number | null | undefined) => {
:global(body) {
margin: 0;
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;
overflow: hidden;
}
@ -896,7 +945,10 @@ button {
font-weight: 600;
cursor: pointer;
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 {

View File

@ -16,7 +16,14 @@
<button type="button" :disabled="secondaryLoading" @click="handleVerifySecondary">
{{ secondaryLoading ? '校验中...' : '确认' }}
</button>
<button type="button" class="ghost-btn" :disabled="secondaryLoading" @click="checkSecondary">重新检测</button>
<button
type="button"
class="ghost-btn"
:disabled="secondaryLoading"
@click="checkSecondary"
>
重新检测
</button>
</div>
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
</div>
@ -49,12 +56,17 @@
<div class="metric-card">
<p>累计 Token</p>
<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 class="metric-card">
<p>活动容器</p>
<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>
</section>
@ -91,7 +103,9 @@
<td colspan="10">暂无 API 账号</td>
</tr>
<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>{{ formatTime(u.created_at) }}</td>
<td>{{ formatNumber(u.usage?.total_requests || 0) }}</td>
@ -104,9 +118,17 @@
</td>
<td class="token-cell">
<span class="token-text">
{{ revealTokens[u.username]?.visible ? (revealTokens[u.username]?.token || '—') : '••••••••••' }}
{{
revealTokens[u.username]?.visible
? revealTokens[u.username]?.token || '—'
: '••••••••••'
}}
</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">
<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"
@ -116,12 +138,22 @@
/>
</svg>
</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>
</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>
</tr>
</tbody>
@ -157,7 +189,10 @@
<td>{{ formatPercentNumber(c.stats?.cpu_percent) }}</td>
<td>{{ formatPercentNumber(c.stats?.memory?.percent) }}</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>
<span :class="['status-badge', c.running ? 'online' : 'offline']">
{{ c.running ? '运行中' : '未运行' }}
@ -179,7 +214,8 @@
<div>
<strong>{{ item.original_name || '未命名文件' }}</strong>
<div class="muted small">
用户 {{ item.username }} · {{ formatBytes(item.size) }} · {{ item.source || 'unknown' }}
用户 {{ item.username }} · {{ formatBytes(item.size) }} ·
{{ item.source || 'unknown' }}
</div>
</div>
<div class="muted small">
@ -204,7 +240,12 @@
</button>
</div>
<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">
<h4>{{ card.label }}</h4>
<span :class="['status-badge', card.success ? 'online' : 'danger']">
@ -224,7 +265,13 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
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 loading = ref(false);
@ -250,7 +297,10 @@ const containerItems = computed(() => {
return Object.keys(items).map((key) => ({ key, ...(items[key] || {}) }));
});
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(() => {
@ -264,7 +314,7 @@ const balanceCards = computed(() => {
amount: data.available ?? data.total ?? null,
amountCny: data.amount_cny ?? data.amountCny ?? null,
currency: data.currency || '',
error: data.error,
error: data.error
};
});
});
@ -339,7 +389,7 @@ const fetchUsers = async () => {
...u,
usage: u.usage || { total_requests: 0, endpoints: {}, last_request_at: null },
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) => {
map[u.username] = revealTokens.value[u.username] || { token: '', visible: false };
@ -383,7 +433,7 @@ const createUser = async () => {
method: 'POST',
credentials: 'same-origin',
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();
if (!resp.ok || !data.success) throw new Error(data.error || '创建失败');
@ -404,7 +454,7 @@ const deleteUser = async (username: string) => {
try {
const resp = await fetch(`/api/admin/api-users/${encodeURIComponent(username)}`, {
method: 'DELETE',
credentials: 'same-origin',
credentials: 'same-origin'
});
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || '删除失败');
@ -425,7 +475,7 @@ const toggleReveal = async (username: string) => {
revealLoading.value = username;
try {
const resp = await fetch(`/api/admin/api-users/${encodeURIComponent(username)}/token`, {
credentials: 'same-origin',
credentials: 'same-origin'
});
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || '获取失败');
@ -503,7 +553,7 @@ onBeforeUnmount(() => {
:global(body) {
margin: 0;
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;
}
@ -549,11 +599,11 @@ onBeforeUnmount(() => {
}
.metric-card {
background: rgba(255,255,255,0.94);
background: rgba(255, 255, 255, 0.94);
border-radius: 16px;
padding: 14px;
border: 1px solid rgba(44,32,19,0.12);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.6);
border: 1px solid rgba(44, 32, 19, 0.12);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.metric-card p {
@ -594,7 +644,7 @@ onBeforeUnmount(() => {
.create-form input {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.12);
border: 1px solid rgba(0, 0, 0, 0.12);
}
button {
@ -626,9 +676,10 @@ table {
border-collapse: collapse;
}
th, td {
th,
td {
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;
font-size: 14px;
}
@ -640,8 +691,8 @@ th, td {
}
.icon-btn {
background: rgba(255,255,255,0.9);
border: 1px solid rgba(0,0,0,0.12);
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
padding: 6px;
cursor: pointer;
@ -667,7 +718,7 @@ th, td {
}
.status-badge.offline {
background: rgba(0,0,0,0.06);
background: rgba(0, 0, 0, 0.06);
color: #5f5140;
}
@ -693,8 +744,8 @@ th, td {
.upload-item {
padding: 10px;
border-radius: 12px;
background: rgba(255,255,255,0.92);
border: 1px solid rgba(0,0,0,0.06);
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.grid {
@ -718,8 +769,8 @@ th, td {
}
.balance-card {
background: rgba(255,255,255,0.92);
border: 1px solid rgba(44,32,19,0.12);
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(44, 32, 19, 0.12);
border-radius: 14px;
padding: 12px;
}

View File

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

View File

@ -42,9 +42,9 @@ onMounted(() => {
<style scoped>
:global(html, body) {
min-height: 100%;
background: radial-gradient(140% 120% at 20% 20%, rgba(239, 229, 214, 0.9), transparent),
radial-gradient(120% 120% at 80% 0%, rgba(255, 255, 255, 0.9), transparent),
#f8f3e8;
background:
radial-gradient(140% 120% at 20% 20%, rgba(239, 229, 214, 0.9), transparent),
radial-gradient(120% 120% at 80% 0%, rgba(255, 255, 255, 0.9), transparent), #f8f3e8;
}
:global(#custom-tools-guide) {
min-height: 100vh;
@ -61,7 +61,15 @@ onMounted(() => {
max-width: 1080px;
margin: 0 auto;
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);
}
.guide-header {

View File

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

View File

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

View File

@ -19,12 +19,20 @@
<div v-if="msg.content" class="bubble-text">{{ msg.content }}</div>
<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">
<img :src="getPreviewUrl(img)" :alt="formatImageName(img)" class="image-thumbnail" />
<img
:src="getPreviewUrl(img)"
:alt="formatImageName(img)"
class="image-thumbnail"
/>
</div>
</div>
<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">
<img :src="getPreviewUrl(video)" :alt="formatImageName(video)" class="image-thumbnail" />
<img
:src="getPreviewUrl(video)"
:alt="formatImageName(video)"
class="image-thumbnail"
/>
</div>
</div>
</div>
@ -32,10 +40,19 @@
<div v-else-if="msg.role === 'assistant'" class="assistant-message">
<!-- assistant 消息前显示 AI Assistant 头部 -->
<!-- 只有当前一条消息是 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>{{ 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
v-if="msg.awaitingFirstContent"
@ -77,7 +94,7 @@
/>
</template>
<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
v-if="group.kind === 'stack'"
class="stacked-blocks-wrapper"
@ -103,53 +120,90 @@
'streaming-content': group.action?.streaming,
'completed-tool': group.action?.type === 'tool' && !group.action?.streaming,
'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
}"
>
<div
v-if="group.action?.type === 'thinking'"
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="status-icon">
<span class="thinking-icon" :class="{ 'thinking-animation': group.action.streaming }">
<span class="icon icon-sm" :style="iconStyleSafe('brain')" aria-hidden="true"></span>
<span
class="thinking-icon"
:class="{ 'thinking-animation': group.action.streaming }"
>
<span
class="icon icon-sm"
:style="iconStyleSafe('brain')"
aria-hidden="true"
></span>
</span>
</div>
<span class="status-text">{{ group.action.streaming ? '正在思考...' : '思考过程' }}</span>
<span class="status-text">{{
group.action.streaming ? '正在思考...' : '思考过程'
}}</span>
</div>
<div
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
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="
handleThinkingScroll(
group.action.blockId || `${index}-thinking-${group.actionIndex}`,
$event
)
"
style="max-height: 240px; overflow-y: auto;"
>
{{ group.action.content }}
style="max-height: 240px; overflow-y: auto"
>
{{ group.action.content }}
</div>
</div>
</div>
</div>
</div>
<div v-else-if="group.action?.type === 'text'" class="text-output">
<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>
</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"
>
{{ getSubAgentSystemNoticeLabel(group.action) }}
@ -163,26 +217,56 @@
<div class="append-placeholder-content">
<template v-if="group.action.append?.success !== false">
<div class="icon-label append-status">
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span>
<span>已写入 {{ group.action.append?.path || '目标文件' }} 的追加内容内容已保存至文件</span>
<span
class="icon icon-sm"
:style="iconStyleSafe('pencil')"
aria-hidden="true"
></span>
<span
>已写入
{{ group.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> {{ group.action.append?.path || '目标文件' }} 写入失败内容已截获供后续修复</span>
<span
class="icon icon-sm"
:style="iconStyleSafe('x')"
aria-hidden="true"
></span>
<span
>
{{ group.action.append?.path || '目标文件' }}
写入失败内容已截获供后续修复</span
>
</div>
</template>
<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 }}
</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 }}
</span>
</div>
<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>
</div>
</div>
@ -195,16 +279,28 @@
>
<div class="append-placeholder-content">
<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>
</div>
<div class="append-meta" v-if="group.action.append">
<span>{{ group.action.append.path || '目标文件' }}</span>
<span v-if="group.action.append.lines">· 行数 {{ group.action.append.lines }}</span>
<span v-if="group.action.append.bytes">· 字节 {{ group.action.append.bytes }}</span>
<span v-if="group.action.append.lines"
>· 行数 {{ group.action.append.lines }}</span
>
<span v-if="group.action.append.bytes"
>· 字节 {{ group.action.append.bytes }}</span
>
</div>
<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>
</div>
</div>
@ -213,172 +309,263 @@
<ToolAction
v-else-if="group.action?.type === 'tool'"
: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"
: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="group.action.blockId || `${index}-tool-${group.actionIndex}`"
@toggle="toggleBlock(group.action.blockId || `${index}-tool-${group.actionIndex}`)"
/>
: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="group.action.blockId || `${index}-tool-${group.actionIndex}`"
@toggle="
toggleBlock(group.action.blockId || `${index}-tool-${group.actionIndex}`)
"
/>
</div>
</template>
</template>
<template v-else>
<div
<template
v-for="(action, actionIndex) in msg.actions || []"
v-if="isActionVisible(action)"
: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
v-if="action.type === 'thinking'"
class="collapsible-block thinking-block"
:class="{ expanded: expandedBlocks?.has(action.blockId || `${index}-thinking-${actionIndex}`) }"
v-if="isActionVisible(action)"
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 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
class="collapsible-content"
:ref="el => registerCollapseContent(action.blockId || `${index}-thinking-${actionIndex}`, el)"
v-if="action.type === 'thinking'"
class="collapsible-block thinking-block"
:class="{
expanded: expandedBlocks?.has(
action.blockId || `${index}-thinking-${actionIndex}`
)
}"
>
<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;"
class="collapsible-header"
@click="toggleBlock(action.blockId || `${index}-thinking-${actionIndex}`)"
>
{{ 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 v-else-if="action.type === 'text'" class="text-output">
<div class="text-content" :class="{ 'streaming-text': action.streaming }">
<div v-if="action.streaming" v-html="renderMarkdown(action.content, true)"></div>
<div v-else v-html="renderMarkdown(action.content, false)"></div>
<div v-else-if="action.type === 'text'" class="text-output">
<div class="text-content" :class="{ 'streaming-text': action.streaming }">
<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
v-else-if="action.type === 'system' && getSubAgentSystemNoticeLabel(action)"
class="sub-agent-system-summary-line"
>
{{ getSubAgentSystemNoticeLabel(action) }}
</div>
<div
v-else-if="action.type === 'system' && getSubAgentSystemNoticeLabel(action)"
class="sub-agent-system-summary-line"
>
{{ getSubAgentSystemNoticeLabel(action) }}
</div>
<div
v-else-if="action.type === 'append_payload'"
class="append-placeholder"
:class="{ 'append-error': action.append?.success === false }"
>
<div class="append-placeholder-content">
<template v-if="action.append?.success !== false">
<div
v-else-if="action.type === 'append_payload'"
class="append-placeholder"
:class="{ 'append-error': action.append?.success === false }"
>
<div class="append-placeholder-content">
<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">
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span>
<span>已写入 {{ action.append?.path || '目标文件' }} 的追加内容内容已保存至文件</span>
<span
class="icon icon-sm"
:style="iconStyleSafe('pencil')"
aria-hidden="true"
></span>
<span>{{ action.append?.summary || '文件追加完成' }}</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 class="append-meta" v-if="action.append">
<span>{{ action.append.path || '目标文件' }}</span>
<span v-if="action.append.lines">· 行数 {{ action.append.lines }}</span>
<span v-if="action.append.bytes">· 字节 {{ 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>
</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">
<span class="icon icon-sm" :style="iconStyleSafe('pencil')" aria-hidden="true"></span>
<span>{{ action.append?.summary || '文件追加完成' }}</span>
</div>
<div class="append-meta" v-if="action.append">
<span>{{ action.append.path || '目标文件' }}</span>
<span v-if="action.append.lines">· 行数 {{ action.append.lines }}</span>
<span v-if="action.append.bytes">· 字节 {{ 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>
<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>
<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 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
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="arrow"></div>
<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>
<span class="status-text">系统消息 (role: {{ msg.role }})</span>
</div>
<div
class="collapsible-content"
:ref="el => registerCollapseContent(`system-${index}`, el)"
:ref="(el) => registerCollapseContent(`system-${index}`, el)"
>
<div class="content-inner">
{{ msg.content }}
@ -437,20 +624,29 @@ const userName = computed(() => {
});
const filteredMessages = computed(() => {
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) {
console.log('[DEBUG_SYSTEM][render] filteredMessages 过滤了 role=system 消息', {
count: droppedRoleSystem.length,
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 SUB_AGENT_DONE_LABEL_RE = /^子智能体\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 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());
let timerHandle: number | null = null;
const debugLoggedSystemActionKeys = new Set<string>();
@ -525,7 +721,10 @@ function getSubAgentSystemNoticeLabel(action: any): string | 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`)) {
console.log('[DEBUG_SYSTEM][render] 命中 variant 渲染', { action, label: content });
debugLoggedSystemActionKeys.add(`${key}-variant-pass`);
@ -535,7 +734,10 @@ function getSubAgentSystemNoticeLabel(action: any): string | null {
const m = content.match(SUB_AGENT_DONE_PREFIX_RE);
if (m && /已完成/.test(content)) {
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`);
}
return `子智能体${m[1]} 任务完成`;
@ -614,9 +816,15 @@ function assistantWorkLabel(index: number): string {
if (status === 'working') {
return `工作中 ${formatDurationMs(nowMs.value - startMs)}`;
}
const durationMs = typeof timer.duration_ms === 'number'
? timer.duration_ms
: Math.max(0, (Number.isFinite(Date.parse(timer.finished_at || '')) ? Date.parse(timer.finished_at) : nowMs.value) - startMs);
const durationMs =
typeof timer.duration_ms === 'number'
? timer.duration_ms
: Math.max(
0,
(Number.isFinite(Date.parse(timer.finished_at || ''))
? Date.parse(timer.finished_at)
: nowMs.value) - startMs
);
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) => {
if (!action || action.type !== 'text') {
return false;
@ -652,7 +861,7 @@ const splitActionGroups = (actions: any[] = [], messageIndex = 0) => {
if (actions.length > 0) {
console.log(`[splitActionGroups] 消息 ${messageIndex}:`, {
totalActions: actions.length,
actionTypes: actions.map(a => a.type),
actionTypes: actions.map((a) => a.type),
stackedBlocksEnabled: props.stackedBlocksEnabled
});
}
@ -698,7 +907,7 @@ const splitActionGroups = (actions: any[] = [], messageIndex = 0) => {
if (actions.length > 0) {
console.log(`[splitActionGroups] 消息 ${messageIndex} 结果:`, {
totalGroups: result.length,
groups: result.map(g => ({
groups: result.map((g) => ({
kind: g.kind,
actionsCount: g.kind === 'stack' ? g.actions.length : 1,
actionType: g.kind === 'stack' ? g.actions[0]?.type : g.action?.type

View File

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

View File

@ -30,94 +30,129 @@
:style="{ transform: `translateY(${innerOffset}px)` }"
appear
>
<div v-for="(action, idx) in stackableActions" :key="blockKey(action, idx)" class="stacked-item">
<div
v-if="action.type === 'thinking'"
class="collapsible-block thinking-block stacked-block"
:class="{ expanded: isExpanded(action, idx), processing: action.streaming }"
v-for="(action, idx) in stackableActions"
:key="blockKey(action, idx)"
class="stacked-item"
>
<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
v-if="action.type === 'thinking'"
class="collapsible-block thinking-block stacked-block"
:class="{ expanded: isExpanded(action, idx), processing: action.streaming }"
>
<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>
<span class="status-text">{{ action.streaming ? '正在思考...' : '思考过程' }}</span>
</div>
<div class="collapsible-content" :style="contentStyle(blockKey(action, idx))">
<div
class="content-inner thinking-content"
:ref="el => registerThinking(blockKey(action, idx), el)"
@scroll="handleThinkingScrollInternal(blockKey(action, idx), $event)"
style="max-height: 240px; overflow-y: auto;"
>
{{ action.content }}
<div class="collapsible-content" :style="contentStyle(blockKey(action, idx))">
<div
class="content-inner thinking-content"
:ref="(el) => registerThinking(blockKey(action, idx), el)"
@scroll="handleThinkingScrollInternal(blockKey(action, idx), $event)"
style="max-height: 240px; overflow-y: auto"
>
{{ action.content }}
</div>
</div>
</div>
</div>
<div
v-else-if="action.type === 'tool'"
class="collapsible-block tool-block stacked-block"
:class="{
expanded: isExpanded(action, idx),
processing: isToolProcessing(action),
completed: isToolCompleted(action)
}"
>
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
<div class="arrow"></div>
<div class="status-icon">
<span
class="tool-icon icon icon-md"
:class="getToolAnimationClass(action.tool)"
:style="iconStyle(getToolIcon(action.tool))"
aria-hidden="true"
></span>
<div
v-else-if="action.type === 'tool'"
class="collapsible-block tool-block stacked-block"
:class="{
expanded: isExpanded(action, idx),
processing: isToolProcessing(action),
completed: isToolCompleted(action)
}"
>
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
<div class="arrow"></div>
<div class="status-icon">
<span
class="tool-icon icon icon-md"
:class="getToolAnimationClass(action.tool)"
:style="iconStyle(getToolIcon(action.tool))"
aria-hidden="true"
></span>
</div>
<span class="status-text">{{ getToolStatusText(action.tool) }}</span>
<span class="tool-desc">{{ getToolDescription(action.tool) }}</span>
</div>
<span class="status-text">{{ getToolStatusText(action.tool) }}</span>
<span class="tool-desc">{{ getToolDescription(action.tool) }}</span>
</div>
<div class="collapsible-content" :style="contentStyle(blockKey(action, idx))">
<div class="content-inner">
<div v-if="shouldUseEnhancedDisplay && renderToolResult(action)" v-html="renderToolResult(action)"></div>
<div v-else-if="!shouldUseEnhancedDisplay && action.tool?.name === 'web_search' && action.tool?.result">
<div class="search-meta">
<div><strong>搜索内容</strong>{{ action.tool.result.query || action.tool.arguments?.query }}</div>
<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 class="collapsible-content" :style="contentStyle(blockKey(action, idx))">
<div class="content-inner">
<div
v-if="shouldUseEnhancedDisplay && renderToolResult(action)"
v-html="renderToolResult(action)"
></div>
<div
v-else-if="
!shouldUseEnhancedDisplay &&
action.tool?.name === 'web_search' &&
action.tool?.result
"
>
<div class="search-meta">
<div>
<strong>搜索内容</strong
>{{ action.tool.result.query || action.tool.arguments?.query }}
</div>
<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 v-else class="search-empty">未返回详细的搜索结果</div>
</div>
<div v-else class="search-empty">未返回详细的搜索结果</div>
</div>
<div v-else-if="action.tool?.name === 'run_python' && action.tool?.result">
<div class="code-block">
<div class="code-label">代码</div>
<pre><code class="language-python">{{ action.tool.result.code || action.tool.arguments?.code }}</code></pre>
<div v-else-if="action.tool?.name === 'run_python' && action.tool?.result">
<div class="code-block">
<div class="code-label">代码</div>
<pre><code class="language-python">{{ action.tool.result.code || action.tool.arguments?.code }}</code></pre>
</div>
<div v-if="action.tool.result.output" class="output-block">
<div class="output-label">输出</div>
<pre>{{ action.tool.result.output }}</pre>
</div>
</div>
<div v-if="action.tool.result.output" class="output-block">
<div class="output-label">输出</div>
<pre>{{ action.tool.result.output }}</pre>
<div v-else>
<pre>{{
JSON.stringify(action.tool?.result || action.tool?.arguments, null, 2)
}}</pre>
</div>
</div>
<div v-else>
<pre>{{ JSON.stringify(action.tool?.result || action.tool?.arguments, null, 2) }}</pre>
</div>
</div>
<div v-if="isToolProcessing(action)" class="progress-indicator"></div>
</div>
<div v-if="isToolProcessing(action)" class="progress-indicator"></div>
</div>
</div>
</TransitionGroup>
</div>
</div>
@ -154,7 +189,12 @@ const shouldUseEnhancedDisplay = computed(() => {
});
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;
@ -175,7 +215,9 @@ let syncRaf: number | null = null;
let syncUntil = 0;
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 moreVisible = computed(() => hiddenCount.value > 0 || showAll.value);
@ -268,7 +310,8 @@ const setShellMetrics = () => {
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;
shellHeight.value = targetShell;

View File

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

View File

@ -1,7 +1,11 @@
<template>
<div class="action-summary">
<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>
</div>
<div class="summary-content" v-if="action.content">
@ -9,9 +13,30 @@
</div>
<div class="summary-actions">
<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 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>
<button
type="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>
</template>

View File

@ -15,8 +15,12 @@
</template>
<div class="append-meta" v-if="action.append">
<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.bytes !== null && action.append.bytes !== undefined">· 字节 {{ action.append.bytes }}</span>
<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="iconStyle('triangleAlert')" aria-hidden="true"></span>

View File

@ -18,9 +18,15 @@
<pre class="entry-content">{{ entry.content }}</pre>
</div>
<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.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>
<span v-if="action.modify.total !== null && action.modify.total !== undefined"
>· {{ action.modify.total }} </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 class="modify-warning icon-label" v-if="action.modify?.forced">
<span class="icon icon-sm" :style="iconStyle('triangleAlert')" aria-hidden="true"></span>

View File

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

View File

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

View File

@ -22,7 +22,11 @@
</div>
<div
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 v-if="shouldUseEnhancedDisplay" v-html="renderEnhancedToolResult()"></div>
@ -80,7 +84,7 @@ function formatBytes(bytes: number): string {
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
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 {
@ -255,10 +259,13 @@ function renderWriteFile(result: any, args: any): string {
if (isAppend) {
html += `<div><strong>模式:</strong>追加</div>`;
}
if (!result.success && result.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
}
html += '</div>';
//
if (content) {
if (result.success && content) {
html += '<div class="tool-result-diff scrollable">';
const lines = content.split('\n');
lines.forEach((line: string) => {
@ -284,8 +291,15 @@ function renderEditFile(result: any, args: any): string {
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`;
if (!result.success && result.error) {
html += `<div><strong>错误:</strong>${escapeHtml(String(result.error))}</div>`;
}
html += '</div>';
if (!result.success) {
return html;
}
// replacements 使
if (replacements.length > 0) {
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="todo-list">';
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>';
@ -713,7 +727,7 @@ function renderTodoUpdate(result: any, args: any): string {
html += '<div class="tool-result-content">';
html += '<div class="todo-list">';
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);
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;
}
.todo-item input[type="checkbox"] {
.todo-item input[type='checkbox'] {
margin: 0;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -60,48 +60,142 @@
}
@keyframes square1 {
0% { left: 0; top: 0; }
8.33% { left: 0; top: var(--offset); }
100% { left: 0; top: var(--offset); }
0% {
left: 0;
top: 0;
}
8.33% {
left: 0;
top: var(--offset);
}
100% {
left: 0;
top: var(--offset);
}
}
@keyframes square2 {
0% { left: 0; top: var(--offset); }
8.33% { left: 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; }
0% {
left: 0;
top: var(--offset);
}
8.33% {
left: 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 {
0%, 100% { left: var(--offset); top: var(--offset); }
16.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); }
0%,
100% {
left: var(--offset);
top: var(--offset);
}
16.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 {
0% { left: calc(2 * var(--offset)); top: var(--offset); }
33.33% { 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); }
0% {
left: calc(2 * var(--offset));
top: var(--offset);
}
33.33% {
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 {
0% { left: calc(3 * var(--offset)); top: var(--offset); }
50% { 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); }
0% {
left: calc(3 * var(--offset));
top: var(--offset);
}
50% {
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>

View File

@ -29,14 +29,34 @@
animation: follow-track 1.25s infinite;
}
.sq:nth-child(1) { top: 0; animation-delay: 0.15s; }
.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; }
.sq:nth-child(1) {
top: 0;
animation-delay: 0.15s;
}
.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 {
0% { transform: rotate(0deg) translateY(-4px); }
60%, 100% { transform: rotate(360deg) translateY(-4px); }
0% {
transform: rotate(0deg) translateY(-4px);
}
60%,
100% {
transform: rotate(360deg) translateY(-4px);
}
}
</style>

View File

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

View File

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

View File

@ -35,7 +35,7 @@
}
.dot::before {
content: "";
content: '';
width: 3px;
height: 3px;
background: var(--claude-text-tertiary);
@ -52,10 +52,20 @@
}
@keyframes leap {
0% { transform: translateX(0) rotate(0deg); }
33.333% { 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); }
0% {
transform: translateX(0) rotate(0deg);
}
33.333% {
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>

View File

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

View File

@ -32,7 +32,7 @@
}
.box:before {
content: "";
content: '';
position: absolute;
left: 0;
top: 0;
@ -59,139 +59,355 @@
margin-bottom: 0;
}
.box:nth-child(1) { animation: moveBox-1 4s infinite; }
.box:nth-child(2) { animation: moveBox-2 4s infinite; }
.box:nth-child(3) { animation: moveBox-3 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; }
.box:nth-child(1) {
animation: moveBox-1 4s infinite;
}
.box:nth-child(2) {
animation: moveBox-2 4s infinite;
}
.box:nth-child(3) {
animation: moveBox-3 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 {
9% { transform: translate(-7px, 0); }
18% { transform: translate(0, 0); }
27% { transform: translate(0, 0); }
36% { 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); }
9% {
transform: translate(-7px, 0);
}
18% {
transform: translate(0, 0);
}
27% {
transform: translate(0, 0);
}
36% {
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 {
9% { transform: translate(0, 0); }
18% { transform: translate(7px, 0); }
27% { transform: translate(0, 0); }
36% { 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); }
9% {
transform: translate(0, 0);
}
18% {
transform: translate(7px, 0);
}
27% {
transform: translate(0, 0);
}
36% {
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 {
9% { transform: translate(-7px, 0); }
18% { transform: translate(-7px, 0); }
27% { transform: translate(0, 0); }
36% { 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); }
9% {
transform: translate(-7px, 0);
}
18% {
transform: translate(-7px, 0);
}
27% {
transform: translate(0, 0);
}
36% {
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 {
9% { transform: translate(-7px, 0); }
18% { transform: translate(-7px, 0); }
27% { transform: translate(-7px, -7px); }
36% { 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); }
9% {
transform: translate(-7px, 0);
}
18% {
transform: translate(-7px, 0);
}
27% {
transform: translate(-7px, -7px);
}
36% {
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 {
9% { transform: translate(0, 0); }
18% { transform: translate(0, 0); }
27% { transform: translate(0, 0); }
36% { 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); }
9% {
transform: translate(0, 0);
}
18% {
transform: translate(0, 0);
}
27% {
transform: translate(0, 0);
}
36% {
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 {
9% { transform: translate(0, 0); }
18% { transform: translate(-7px, 0); }
27% { transform: translate(-7px, 0); }
36% { 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); }
9% {
transform: translate(0, 0);
}
18% {
transform: translate(-7px, 0);
}
27% {
transform: translate(-7px, 0);
}
36% {
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 {
9% { transform: translate(7px, 0); }
18% { transform: translate(7px, 0); }
27% { transform: translate(7px, 0); }
36% { 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); }
9% {
transform: translate(7px, 0);
}
18% {
transform: translate(7px, 0);
}
27% {
transform: translate(7px, 0);
}
36% {
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 {
9% { transform: translate(0, 0); }
18% { transform: translate(-7px, 0); }
27% { transform: translate(-7px, -7px); }
36% { 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); }
9% {
transform: translate(0, 0);
}
18% {
transform: translate(-7px, 0);
}
27% {
transform: translate(-7px, -7px);
}
36% {
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 {
9% { transform: translate(-7px, 0); }
18% { transform: translate(-7px, 0); }
27% { transform: translate(0, 0); }
36% { 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); }
9% {
transform: translate(-7px, 0);
}
18% {
transform: translate(-7px, 0);
}
27% {
transform: translate(0, 0);
}
36% {
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>

View File

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

View File

@ -16,23 +16,74 @@
<style scoped>
@keyframes square-animation {
0% { left: 0; top: 0; }
10.5% { left: 0; top: 0; }
12.5% { left: 8px; top: 0; }
23% { left: 8px; 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; }
0% {
left: 0;
top: 0;
}
10.5% {
left: 0;
top: 0;
}
12.5% {
left: 8px;
top: 0;
}
23% {
left: 8px;
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 {

View File

@ -18,14 +18,68 @@
}
@keyframes roll {
0% { box-shadow: 0 0 transparent, 0 0 transparent, 0 0 transparent, 0 0 transparent; }
12% { 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; }
0% {
box-shadow:
0 0 transparent,
0 0 transparent,
0 0 transparent,
0 0 transparent;
}
12% {
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>

View File

@ -29,13 +29,26 @@
animation: wave var(--speed) ease-in-out infinite;
}
.sq:nth-child(1) { animation-delay: calc(var(--speed) * -0.45); }
.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; }
.sq:nth-child(1) {
animation-delay: calc(var(--speed) * -0.45);
}
.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 {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -29,7 +29,11 @@ export interface MonitorDriver {
showThinkingBubble(): void;
hideBubble(): 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;
preparePendingCreation?(path?: string | null): void;
}

View File

@ -3,7 +3,11 @@
<div v-if="node.type === 'folder'" class="file-node folder-node">
<button class="folder-header" type="button" :style="folderPadding" @click="toggle">
<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>
</button>
<div v-show="isExpanded" class="folder-children">

View File

@ -10,18 +10,43 @@
'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 v-if="selectedImages && selectedImages.length" class="image-inline-row">
<div class="image-thumbnail-wrapper" v-for="img in selectedImages" :key="img">
<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 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">
<img :src="getPreviewUrl(video)" :alt="formatImageName(video)" class="image-thumbnail" />
<button type="button" class="image-remove-btn-hover" @click.stop="$emit('remove-video', video)">×</button>
<img
: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 class="input-row">
@ -54,7 +79,10 @@
!isConnected ||
(inputLocked && !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>
@ -165,7 +193,14 @@ const props = defineProps<{
currentConversationId: string | null;
iconStyle: (key: string) => Record<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;
selectedImages?: string[];
selectedVideos?: string[];

View File

@ -60,7 +60,6 @@
<span class="entry-arrow"></span>
</button>
<transition name="submenu-slide">
<div class="quick-submenu tool-submenu" v-if="toolMenuOpen">
<div class="submenu-status" v-if="toolSettingsLoading">正在同步工具状态...</div>
@ -76,13 +75,15 @@
:disabled="streamingMessage || !isConnected || toolSettingsLoading"
>
<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>
<span class="entry-arrow">
<template v-if="category.locked">
被管理员锁定
</template>
<template v-if="category.locked"> 被管理员锁定 </template>
<template v-else>
{{ category.enabled ? '禁用' : '启用' }}
</template>
@ -149,7 +150,14 @@ const props = defineProps<{
modeMenuOpen: boolean;
runMode?: 'fast' | 'thinking' | 'deep';
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;
blockUpload?: boolean;
blockToolToggle?: boolean;
@ -176,41 +184,15 @@ defineEmits<{
(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 currentModelLabel = computed(() => {
const found = props.modelOptions?.find(m => m.key === props.currentModelKey);
return found ? found.label : '未选择';
});
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;
});
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;
});
</script>

View File

@ -3,16 +3,20 @@
<div v-if="activeCommand" class="subagent-activity-overlay" @click.self="close">
<div class="subagent-activity-modal bg-command-modal">
<div class="subagent-activity-header">
<div class="subagent-activity-title">
后台指令 {{ activeCommand.command_id }}
</div>
<div class="subagent-activity-title">后台指令 {{ activeCommand.command_id }}</div>
<button type="button" class="subagent-activity-close" @click="close">×</button>
</div>
<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' }}
</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 }}
</span>
</div>

View File

@ -8,7 +8,9 @@
</div>
<div class="header-actions">
<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>
@ -63,7 +65,9 @@
<div class="review-right">
<div class="preview-header">
<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 class="preview-box" :class="{ loading: previewLoading }">
<div v-if="previewLoading" class="placeholder">
@ -90,7 +94,11 @@
<div class="review-footer">
<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="label">是否发送给模型</span>
</label>
@ -110,7 +118,7 @@
<script setup lang="ts">
defineOptions({ name: 'ConversationReviewDialog' });
const props = defineProps<{
defineProps<{
open: boolean;
conversations: Array<{
id: string;
@ -134,9 +142,9 @@ const props = defineProps<{
}>();
const icons = {
loading: new URL('../../icons/clock.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
loading: new URL('../../../icons/clock.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
};
defineEmits<{
@ -277,7 +285,10 @@ const formatUpdatedAt = (value: string | number) => {
border-radius: 10px;
padding: 14px 14px;
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;
min-height: 64px;
line-height: 1.4;
@ -443,7 +454,10 @@ const formatUpdatedAt = (value: string | number) => {
padding: 10px 18px;
font-weight: 600;
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 {

View File

@ -1,16 +1,11 @@
<template>
<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="header">
<div class="header-left">
<div class="title">选择图片最多9张</div>
<button
type="button"
class="local-btn"
:disabled="uploading"
@click="triggerLocal"
>
<button type="button" class="local-btn" :disabled="uploading" @click="triggerLocal">
{{ uploading ? '上传中...' : '从本地发送' }}
</button>
<input
@ -25,30 +20,37 @@
<button class="close-btn" @click="close">×</button>
</div>
<div class="body">
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="!images.length" class="empty">未找到图片文件</div>
<div v-else class="grid">
<div
v-for="item in images"
:key="item.path"
class="card"
:class="{ selected: selectedSet.has(item.path) }"
@click="toggle(item.path)"
:title="item.path"
>
<img :src="previewUrl(item.path)" :alt="item.name" />
<div class="name">{{ item.name }}</div>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="!images.length" class="empty">未找到图片文件</div>
<div v-else class="grid">
<div
v-for="item in images"
:key="item.path"
class="card"
:class="{ selected: selectedSet.has(item.path) }"
@click="toggle(item.path)"
:title="item.path"
>
<img :src="previewUrl(item.path)" :alt="item.name" />
<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 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>
</transition>
</template>

View File

@ -1,7 +1,12 @@
<template>
<transition name="overlay-fade">
<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>
<h3>新用户 {{ username || '用户' }}</h3>
<p class="desc">是否需要通过新手教程来快速认识这个系统</p>
@ -87,7 +92,10 @@ button {
font-weight: 700;
font-size: 13px;
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 {

View File

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

View File

@ -10,12 +10,37 @@
@touchcancel="handleTouchEnd"
>
<div class="tutorial-click-blocker" @click.stop></div>
<div v-if="stepReady && maskVisible" class="tutorial-mask" :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-mask"
: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
v-if="clickEffect"
class="tutorial-click-effect"
@ -36,9 +61,13 @@
</header>
<h3 class="tutorial-popover__title">{{ step?.title || '新手教程' }}</h3>
<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-if="showPersonalScrollHint" class="tutorial-popover__hint">提示个人空间内容可上下滚动查看</p>
<p v-if="showPersonalScrollHint" class="tutorial-popover__hint">
提示个人空间内容可上下滚动查看
</p>
<footer class="tutorial-popover__actions">
<button type="button" class="tutorial-btn" :disabled="nextDisabled" @click="goNext">
@ -69,7 +98,12 @@ const GAP = 14;
const HIGHLIGHT_PADDING = 2;
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 targetRect = ref<RectLike | null>(null);
@ -89,7 +123,13 @@ const maskVisible = computed(() => Boolean(targetRect.value));
const nextDisabled = computed(() => isMustClick.value && !!targetRect.value);
const isPersonalTutorialPhase = computed(() => {
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 nextLabel = computed(() => {
@ -117,7 +157,12 @@ const maskTopStyle = computed(() => {
const maskLeftStyle = computed(() => {
const rect = clampedRect.value;
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(() => {
@ -228,10 +273,17 @@ const placePopover = (rect: RectLike | null) => {
const placements: Array<'top' | 'right' | 'bottom' | 'left'> =
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'];
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))) {
let top = 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 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 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 fits =
left >= edge &&
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;
if (fits) {
resolvedPlacement.value = p;
@ -292,7 +354,14 @@ const updateRect = () => {
const rect = el.getBoundingClientRect();
targetRect.value =
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;
placePopover(targetRect.value);
};
@ -321,12 +390,17 @@ const handleCaptureClick = (event: MouseEvent) => {
};
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);
if (pageScrollable) return pageScrollable;
const content = document.querySelector('.personalization-content') as HTMLElement | null;
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) => {
@ -397,7 +471,12 @@ const triggerAutoClick = () => {
const targetEl = menu || (document.body as HTMLElement);
playClickEffect(targetEl);
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);
}, 420);
return true;
@ -420,9 +499,12 @@ const triggerAutoClick = () => {
},
shouldScroll ? 220 : 0
);
window.setTimeout(() => {
(el as HTMLElement).click();
}, shouldScroll ? 640 : 420);
window.setTimeout(
() => {
(el as HTMLElement).click();
},
shouldScroll ? 640 : 420
);
return true;
};
@ -447,7 +529,7 @@ const handleExit = () => tutorialStore.exitTutorial();
watch(
() => [running.value, step.value?.id],
async ([isRunning, stepId], [prevRunning, prevStepId]) => {
async ([, stepId], [prevRunning, prevStepId]) => {
if (!running.value) {
targetRect.value = null;
stepReady.value = true;
@ -461,7 +543,9 @@ watch(
}
const nextStep = step.value;
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 () => {
await nextTick();
updateRect();

View File

@ -1,16 +1,11 @@
<template>
<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="header">
<div class="header-left">
<div class="title">选择视频一次最多 1 </div>
<button
type="button"
class="local-btn"
:disabled="uploading"
@click="triggerLocal"
>
<button type="button" class="local-btn" :disabled="uploading" @click="triggerLocal">
{{ uploading ? '上传中...' : '从本地发送' }}
</button>
<input
@ -47,7 +42,14 @@
<div class="count">已选 {{ selectedSet.size }} / 1</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>
<button
type="button"
class="btn primary"
:disabled="!selectedSet.size"
@click="confirm"
>
确认
</button>
</div>
</div>
</div>

View File

@ -1,5 +1,9 @@
<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">
<h3 class="icon-label">
<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="status-line">
<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">
<span class="brand-name">AI Agent</span>
<span class="agent-version" v-if="agentVersion">{{ agentVersion }}</span>
@ -42,7 +46,12 @@
<div class="sidebar-panel-card">
<div class="sidebar-header">
<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>
</button>
<transition name="fade">
@ -61,7 +70,11 @@
@click.stop="$emit('select-panel', 'todo')"
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
type="button"
@ -77,7 +90,11 @@
@click.stop="$emit('select-panel', 'backgroundCommands')"
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>
</div>
</transition>
@ -114,10 +131,19 @@
<div v-if="panelMode === 'todo'" class="todo-panel">
<div v-if="!todoList" class="todo-empty">暂无待办列表</div>
<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-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>
</div>
@ -127,7 +153,12 @@
<div v-else-if="panelMode === 'subAgents'" class="sub-agent-panel">
<div v-if="!subAgents.length" class="sub-agent-empty">暂无运行中的子智能体</div>
<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">
<span class="sub-agent-id">#{{ agent.agent_id }}</span>
<span class="sub-agent-status" :class="agent.status">{{ agent.status }}</span>
@ -147,10 +178,14 @@
@click="openBackgroundCommand(command)"
>
<div class="sub-agent-header">
<span class="sub-agent-id">{{ command.command_id }}</span>
<span class="sub-agent-status" :class="command.status">{{ command.status || 'running' }}</span>
<span class="sub-agent-id">状态</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 class="sub-agent-summary">{{ command.command || '(空命令)' }}</div>
<div class="sub-agent-tool">
{{ formatBackgroundCommandMeta(command) }}
</div>
@ -209,7 +244,7 @@ defineEmits<{
}>();
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(() => {
if (props.collapsed) {
return {
@ -266,7 +301,8 @@ const modeIndicatorTitle = computed(() => {
const fileStore = useFileStore();
const subAgentStore = useSubAgentStore();
const backgroundCommandStore = useBackgroundCommandStore();
const { fileTree, expandedFolders, todoList, fileTreeUnavailable, fileTreeMessage } = storeToRefs(fileStore);
const { fileTree, expandedFolders, todoList, fileTreeUnavailable, fileTreeMessage } =
storeToRefs(fileStore);
const { subAgents } = storeToRefs(subAgentStore);
const { commands: backgroundCommands } = storeToRefs(backgroundCommandStore);
@ -281,9 +317,22 @@ const openBackgroundCommand = (command: any) => {
const formatBackgroundCommandMeta = (command: any) => {
const code = command?.return_code;
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({

View File

@ -143,7 +143,7 @@ function confirmSelection() {
border-radius: 14px;
padding: 16px 14px;
cursor: pointer;
transition: .2s ease;
transition: 0.2s ease;
box-shadow: 0 8px 18px rgba(61, 57, 41, 0.06);
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)">
{{ dialog.cancelText || '取消' }}
</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 || '确认' }}
</button>
</div>

View File

@ -1,5 +1,10 @@
<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>
<script setup lang="ts">

View File

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

View File

@ -19,8 +19,18 @@
stroke-width="1.7"
stroke-linejoin="round"
/>
<path 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" />
<path
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>
</slot>
</span>
@ -62,7 +72,12 @@
@click="$emit('toggle-workspace')"
>
<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
type="button"
@ -72,8 +87,15 @@
:aria-disabled="displayModeDisabled"
:title="displayMode === 'monitor' ? '退出显示器' : '虚拟显示器'"
>
<span class="sr-only">{{ displayMode === 'monitor' ? '退出显示器' : '虚拟显示器' }}</span>
<span class="icon icon-md" data-tutorial="monitor-toggle" aria-hidden="true" :style="iconStyle('monitor')"></span>
<span class="sr-only">{{
displayMode === 'monitor' ? '退出显示器' : '虚拟显示器'
}}</span>
<span
class="icon icon-md"
data-tutorial="monitor-toggle"
aria-hidden="true"
:style="iconStyle('monitor')"
></span>
</button>
</div>
</template>
@ -85,7 +107,11 @@
@click="$emit('create')"
>
<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>
</template>
<template v-else>
@ -111,13 +137,22 @@
<span class="sr-only">{{ collapsed ? '展开对话记录' : '折叠对话记录' }}</span>
</template>
<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>
</template>
</template>
<template v-else>
<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>
</template>
<template v-else>
@ -136,13 +171,20 @@
:value="searchQuery"
placeholder="搜索对话..."
@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 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">
{{ searchActive ? '未找到匹配对话' : '暂无对话记录' }}
</div>
@ -158,7 +200,7 @@
<div class="conversation-meta">
<span class="conversation-time">{{ formatTime(conv.updated_at) }}</span>
<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>
</div>
@ -183,7 +225,12 @@
</div>
</div>
<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 ? '载入中...' : '加载更多' }}
</button>
</div>
@ -222,7 +269,12 @@
@click="$emit('personal')"
>
<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
fill-rule="evenodd"
clip-rule="evenodd"
@ -279,7 +331,11 @@ const uiStore = useUiStore();
const conversationStore = useConversationStore();
const personalizationStore = usePersonalizationStore();
const { sidebarCollapsed: collapsed, workspaceCollapsed, chatDisplayMode: uiChatDisplayMode } = storeToRefs(uiStore);
const {
sidebarCollapsed: collapsed,
workspaceCollapsed,
chatDisplayMode: uiChatDisplayMode
} = storeToRefs(uiStore);
const {
searchQuery,
searchResults,
@ -299,9 +355,14 @@ const iconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {})
const resolvedShowCollapseButton = computed(() => props.showCollapseButton);
const resolvedCollapseButtonVariant = computed(() => props.collapseButtonVariant);
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 displayConversations = computed(() => (searchActive.value ? searchResults.value : conversations.value));
const loading = computed(() => (searchActive.value ? searchInProgress.value : conversationsLoading.value));
const displayConversations = computed(() =>
searchActive.value ? searchResults.value : conversations.value
);
const loading = computed(() =>
searchActive.value ? searchInProgress.value : conversationsLoading.value
);
</script>

View File

@ -1,7 +1,13 @@
<template>
<div class="token-drawer" v-if="visible" :class="{ collapsed }" data-tutorial="token-drawer">
<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>
</button>
<div class="token-panel-content">
@ -11,7 +17,9 @@
<div class="stat-grid stat-grid--triple">
<div class="stat-block">
<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 class="stat-block">
<div class="stat-label">累计输入</div>
@ -39,18 +47,20 @@
<div class="stat-grid stat-grid--double">
<div class="stat-block">
<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 class="stat-block">
<div class="stat-label">内存</div>
<div class="stat-value stat-value--mono">
{{ formatBytes(containerStatus.stats.memory.used_bytes) }}
<template v-if="containerStatus.stats.memory.limit_bytes">
/ {{ formatBytes(containerStatus.stats.memory.limit_bytes) }}
</template>
</div>
<div class="stat-foot" v-if="containerStatus.stats.memory.percent">
{{ formatPercentage(containerStatus.stats.memory.percent) }}
<div class="stat-block">
<div class="stat-label">内存</div>
<div class="stat-value stat-value--mono">
{{ formatBytes(containerStatus.stats.memory.used_bytes) }}
<template v-if="containerStatus.stats.memory.limit_bytes">
/ {{ formatBytes(containerStatus.stats.memory.limit_bytes) }}
</template>
</div>
<div class="stat-foot" v-if="containerStatus.stats.memory.percent">
{{ formatPercentage(containerStatus.stats.memory.percent) }}
</div>
</div>
</div>
@ -65,7 +75,9 @@
<div class="stat-block" v-for="tier in quotaTiers" :key="tier.key">
<div class="stat-label">{{ tier.label }}</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>
@ -75,14 +87,18 @@
<div class="stat-block">
<div class="stat-label">网络</div>
<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 class="stat-block">
<div class="stat-label">存储</div>
<div class="stat-value stat-value--mono">
{{ 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 class="stat-foot" v-if="typeof projectStorage.usage_percent === 'number'">
{{ projectStorage.usage_percent.toFixed(1) }}%
@ -108,7 +124,10 @@ const emit = defineEmits<{
const props = defineProps<{
visible: boolean;
collapsed: boolean;
currentConversationTokens: { cumulative_input_tokens?: number; cumulative_output_tokens?: number };
currentConversationTokens: {
cumulative_input_tokens?: number;
cumulative_output_tokens?: number;
};
currentContextTokens: number;
containerStatus: any;
containerNetRate: { down_bps: number | null; up_bps: number | null };

View File

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