Release v1.4.3
This commit is contained in:
@@ -6,7 +6,7 @@ import Models from './views/Models.vue'
|
||||
import Requests from './views/Requests.vue'
|
||||
import Settings from './views/Settings.vue'
|
||||
import { EventsOff, EventsOn } from '../wailsjs/runtime'
|
||||
import { GetStatus, HideWindow, MinimizeWindow } from '../wailsjs/go/main/App.js'
|
||||
import { ClearLogs, GetLogs, GetStatus, HideWindow, MinimizeWindow } from '../wailsjs/go/main/App.js'
|
||||
import lingmaIcon from './assets/images/lingma-icon.png'
|
||||
|
||||
const currentTab = ref('dashboard')
|
||||
@@ -42,8 +42,13 @@ function showToast(message) {
|
||||
}, 2200)
|
||||
}
|
||||
|
||||
function clearLocalLogs() {
|
||||
logs.value = []
|
||||
async function clearLocalLogs() {
|
||||
try {
|
||||
await ClearLogs()
|
||||
logs.value = []
|
||||
} catch (e) {
|
||||
logs.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(nextStatus) {
|
||||
@@ -158,14 +163,25 @@ onMounted(() => {
|
||||
systemThemeQuery?.addEventListener?.('change', applyTheme)
|
||||
applyTheme()
|
||||
refreshStatus()
|
||||
GetLogs().then((items) => {
|
||||
logs.value = Array.isArray(items) ? items : []
|
||||
}).catch(() => {})
|
||||
safeEventsOn('models:updated', (data) => {
|
||||
status.value.models = Array.isArray(data) ? data.length : status.value.models
|
||||
addLog('info', `模型列表已更新:${status.value.models} 个模型`)
|
||||
})
|
||||
safeEventsOn('log', (data) => {
|
||||
addLog(data.level || 'info', data.message || '')
|
||||
if (data.time && data.message !== undefined) {
|
||||
logs.value.unshift(data)
|
||||
if (logs.value.length > 500) logs.value = logs.value.slice(0, 500)
|
||||
} else {
|
||||
addLog(data.level || 'info', data.message || '')
|
||||
}
|
||||
refreshStatus()
|
||||
})
|
||||
safeEventsOn('logs:updated', (data) => {
|
||||
logs.value = Array.isArray(data) ? data : []
|
||||
})
|
||||
safeEventsOn('quit:confirm', (message) => {
|
||||
showToast(message || '再按一次退出快捷键将停止代理并退出应用')
|
||||
})
|
||||
@@ -183,6 +199,7 @@ onUnmounted(() => {
|
||||
systemThemeQuery?.removeEventListener?.('change', applyTheme)
|
||||
safeEventsOff('models:updated')
|
||||
safeEventsOff('log')
|
||||
safeEventsOff('logs:updated')
|
||||
safeEventsOff('quit:confirm')
|
||||
safeEventsOff('status:updated')
|
||||
safeEventsOff('requests:updated')
|
||||
@@ -222,7 +239,7 @@ onUnmounted(() => {
|
||||
<span class="status-dot" :class="{ running: status.running }"></span>
|
||||
<div>
|
||||
<strong>{{ status.running ? 'Proxy Running' : 'Proxy Stopped' }}</strong>
|
||||
<small>v1.4.2</small>
|
||||
<small>v1.4.3</small>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import {reactive} from 'vue'
|
||||
import {Greet} from '../../wailsjs/go/main/App'
|
||||
|
||||
const data = reactive({
|
||||
name: "",
|
||||
resultText: "Please enter your name below 👇",
|
||||
})
|
||||
|
||||
function greet() {
|
||||
Greet(data.name).then(result => {
|
||||
data.resultText = result
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<div id="result" class="result">{{ data.resultText }}</div>
|
||||
<div id="input" class="input-box">
|
||||
<input id="name" v-model="data.name" autocomplete="off" class="input" type="text"/>
|
||||
<button class="btn" @click="greet">Greet</button>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.result {
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
margin: 1.5rem auto;
|
||||
}
|
||||
|
||||
.input-box .btn {
|
||||
width: 60px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
margin: 0 0 0 20px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input-box .btn:hover {
|
||||
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.input-box .input {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
padding: 0 10px;
|
||||
background-color: rgba(240, 240, 240, 1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.input-box .input:hover {
|
||||
border: none;
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.input-box .input:focus {
|
||||
border: none;
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,12 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'SF Pro Text',
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
color: #172033;
|
||||
background: #eef2f6;
|
||||
font-synthesis: none;
|
||||
@@ -26,7 +33,7 @@
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
:root[data-theme='dark'] {
|
||||
color: #edf3ff;
|
||||
background: #111827;
|
||||
--bg: #111827;
|
||||
@@ -68,7 +75,7 @@ body {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] body {
|
||||
:root[data-theme='dark'] body {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
@@ -106,7 +113,7 @@ button {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .app-shell {
|
||||
:root[data-theme='dark'] .app-shell {
|
||||
border-color: rgba(148, 163, 184, 0.22);
|
||||
background: rgba(16, 24, 36, 0.78);
|
||||
}
|
||||
@@ -123,7 +130,7 @@ button {
|
||||
box-shadow: inset -1px 0 0 rgba(125, 139, 158, 0.16);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .sidebar {
|
||||
:root[data-theme='dark'] .sidebar {
|
||||
border-right-color: rgba(148, 163, 184, 0.14);
|
||||
background: linear-gradient(180deg, rgba(28, 39, 56, 0.7), rgba(18, 27, 40, 0.66));
|
||||
box-shadow: inset -1px 0 0 rgba(148, 163, 184, 0.12);
|
||||
@@ -145,13 +152,13 @@ button {
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .brand:hover,
|
||||
:root[data-theme="dark"] .nav-item:hover,
|
||||
:root[data-theme="dark"] .sidebar-status {
|
||||
:root[data-theme='dark'] .brand:hover,
|
||||
:root[data-theme='dark'] .nav-item:hover,
|
||||
:root[data-theme='dark'] .sidebar-status {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .nav-item {
|
||||
:root[data-theme='dark'] .nav-item {
|
||||
color: #aebbd0;
|
||||
}
|
||||
|
||||
@@ -224,7 +231,7 @@ button {
|
||||
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .nav-item.active {
|
||||
:root[data-theme='dark'] .nav-item.active {
|
||||
color: #d8e6ff;
|
||||
background: rgba(67, 111, 190, 0.24);
|
||||
box-shadow: inset 0 0 0 1px rgba(105, 161, 255, 0.18);
|
||||
@@ -285,13 +292,13 @@ button {
|
||||
min-height: 46px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid rgba(112, 128, 148, 0.18);
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
backdrop-filter: blur(20px) saturate(1.08);
|
||||
background: #f6f9fd;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .topbar {
|
||||
:root[data-theme='dark'] .topbar {
|
||||
border-bottom-color: rgba(148, 163, 184, 0.14);
|
||||
background: rgba(20, 30, 45, 0.66);
|
||||
background: #162131;
|
||||
}
|
||||
|
||||
.topbar-spacer {
|
||||
@@ -372,10 +379,10 @@ button {
|
||||
backdrop-filter: blur(18px) saturate(1.12);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .glass-panel,
|
||||
:root[data-theme="dark"] .metric,
|
||||
:root[data-theme="dark"] .table-panel,
|
||||
:root[data-theme="dark"] .config-panel {
|
||||
:root[data-theme='dark'] .glass-panel,
|
||||
:root[data-theme='dark'] .metric,
|
||||
:root[data-theme='dark'] .table-panel,
|
||||
:root[data-theme='dark'] .config-panel {
|
||||
border-color: rgba(148, 163, 184, 0.14);
|
||||
background: var(--surface);
|
||||
}
|
||||
@@ -583,6 +590,10 @@ button {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -591,10 +602,11 @@ button {
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 0.95fr) minmax(300px, 0.95fr);
|
||||
grid-template-areas:
|
||||
"health models config"
|
||||
"requests requests config";
|
||||
'health models config'
|
||||
'requests requests usage';
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -604,16 +616,109 @@ button {
|
||||
|
||||
.area-models {
|
||||
grid-area: models;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.area-config {
|
||||
grid-area: config;
|
||||
}
|
||||
|
||||
.compact-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.compact-header p {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.area-usage {
|
||||
grid-area: usage;
|
||||
}
|
||||
|
||||
.area-requests {
|
||||
grid-area: requests;
|
||||
}
|
||||
|
||||
.usage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.usage-grid div {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.usage-grid label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.usage-grid strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
line-height: 1.15;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.usage-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
margin-top: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.config-summary-item {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.config-summary-item label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.config-summary-item strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-summary-item.span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.compact-link {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.activity-chart {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(36, minmax(3px, 1fr));
|
||||
@@ -637,13 +742,13 @@ button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .activity-chart,
|
||||
:root[data-theme="dark"] .data-table th,
|
||||
:root[data-theme="dark"] .field input,
|
||||
:root[data-theme="dark"] .field textarea,
|
||||
:root[data-theme="dark"] .search-input,
|
||||
:root[data-theme="dark"] .detail-panel pre,
|
||||
:root[data-theme="dark"] .code-block {
|
||||
:root[data-theme='dark'] .activity-chart,
|
||||
:root[data-theme='dark'] .data-table th,
|
||||
:root[data-theme='dark'] .field input,
|
||||
:root[data-theme='dark'] .field textarea,
|
||||
:root[data-theme='dark'] .search-input,
|
||||
:root[data-theme='dark'] .detail-panel pre,
|
||||
:root[data-theme='dark'] .code-block {
|
||||
color: var(--text);
|
||||
border-color: var(--line);
|
||||
background: rgba(15, 23, 42, 0.74);
|
||||
@@ -719,8 +824,8 @@ button {
|
||||
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .model-choice:hover,
|
||||
:root[data-theme="dark"] .model-choice:focus-visible {
|
||||
:root[data-theme='dark'] .model-choice:hover,
|
||||
:root[data-theme='dark'] .model-choice:focus-visible {
|
||||
color: #f3f7ff;
|
||||
border-color: rgba(105, 161, 255, 0.38);
|
||||
background: rgba(72, 118, 214, 0.34);
|
||||
@@ -728,7 +833,48 @@ button {
|
||||
|
||||
.models-list .model-row,
|
||||
.model-list-row {
|
||||
grid-template-columns: 22px minmax(220px, 1fr) auto;
|
||||
grid-template-columns: 22px minmax(220px, 1fr) minmax(260px, auto);
|
||||
}
|
||||
|
||||
.model-specs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.spec-chip {
|
||||
display: inline-flex;
|
||||
min-height: 22px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
color: var(--muted);
|
||||
background: var(--surface-soft);
|
||||
font-size: 11px;
|
||||
font-weight: 680;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.spec-chip.strong {
|
||||
color: #0d6a41;
|
||||
border-color: rgba(24, 160, 88, 0.18);
|
||||
background: var(--green-soft);
|
||||
}
|
||||
|
||||
.spec-chip.muted-chip {
|
||||
color: #8a5a08;
|
||||
border-color: rgba(217, 119, 6, 0.16);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .spec-chip.strong {
|
||||
color: #7ee0aa;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .spec-chip.muted-chip {
|
||||
color: #ffd27a;
|
||||
}
|
||||
|
||||
.model-brand-icon {
|
||||
@@ -744,6 +890,8 @@ button {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 295px;
|
||||
}
|
||||
|
||||
.model-card-list,
|
||||
@@ -754,7 +902,14 @@ button {
|
||||
}
|
||||
|
||||
.model-card-list {
|
||||
max-height: 248px;
|
||||
flex: 1 1 auto;
|
||||
max-height: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.model-card-list::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.model-page-list {
|
||||
@@ -870,6 +1025,66 @@ button {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.toolbar-header {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-count {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-search-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbar-search-input {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-sm-outline {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.16s ease;
|
||||
}
|
||||
|
||||
.btn-sm-outline:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.btn-sm-outline:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm-outline i {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .btn-sm-outline {
|
||||
color: #dce8fb;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .btn-sm-outline:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
flex: 0 0 auto;
|
||||
max-height: none;
|
||||
@@ -893,7 +1108,7 @@ button {
|
||||
|
||||
.area-requests .table-scroll {
|
||||
min-height: 0;
|
||||
max-height: 260px;
|
||||
max-height: 211px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -932,10 +1147,12 @@ button {
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
height: var(--request-row-height, 64px);
|
||||
height: var(--request-row-height, 42px);
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.34);
|
||||
transition: background-color 140ms ease, box-shadow 140ms ease;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
@@ -947,23 +1164,23 @@ button {
|
||||
box-shadow: inset 3px 0 0 var(--blue);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .data-table {
|
||||
:root[data-theme='dark'] .data-table {
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .data-table th {
|
||||
:root[data-theme='dark'] .data-table th {
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .data-table tbody tr {
|
||||
:root[data-theme='dark'] .data-table tbody tr {
|
||||
background: rgba(20, 31, 48, 0.7);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .data-table tbody tr:hover {
|
||||
:root[data-theme='dark'] .data-table tbody tr:hover {
|
||||
background: rgba(45, 65, 96, 0.9);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .data-table tbody tr.selected {
|
||||
:root[data-theme='dark'] .data-table tbody tr.selected {
|
||||
background: rgba(38, 65, 112, 0.96);
|
||||
box-shadow: inset 3px 0 0 #67a1ff;
|
||||
}
|
||||
@@ -1001,8 +1218,7 @@ button {
|
||||
background: var(--red-soft);
|
||||
}
|
||||
|
||||
.link-row,
|
||||
.table-footer button {
|
||||
.link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -1014,24 +1230,10 @@ button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .link-row,
|
||||
:root[data-theme="dark"] .table-footer button {
|
||||
:root[data-theme='dark'] .link-row {
|
||||
color: #dce8fb;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.table-footer button {
|
||||
width: auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.method-chip {
|
||||
color: #334155;
|
||||
@@ -1047,7 +1249,10 @@ button {
|
||||
min-height: 32px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease,
|
||||
box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
@@ -1132,12 +1337,92 @@ button:disabled {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.settings-fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.settings-fieldset:disabled {
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.compact-hint {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.compact-form-grid {
|
||||
row-gap: 14px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.switch-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.switch-field p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.switch span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.28);
|
||||
transition:
|
||||
background 0.16s ease,
|
||||
border-color 0.16s ease;
|
||||
}
|
||||
|
||||
.switch span::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.2);
|
||||
transition: transform 0.16s ease;
|
||||
}
|
||||
|
||||
.switch input:checked + span {
|
||||
border-color: rgba(37, 99, 235, 0.72);
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.switch input:checked + span::after {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea,
|
||||
.search-input {
|
||||
@@ -1151,6 +1436,14 @@ button:disabled {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field .switch input {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
min-height: 78px;
|
||||
padding-top: 9px;
|
||||
@@ -1230,14 +1523,14 @@ button:disabled {
|
||||
background: var(--blue-soft);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .custom-select > button {
|
||||
:root[data-theme='dark'] .custom-select > button {
|
||||
color: var(--text);
|
||||
border-color: var(--line);
|
||||
background: rgba(15, 23, 42, 0.74);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .select-menu button:hover,
|
||||
:root[data-theme="dark"] .select-menu button.selected {
|
||||
:root[data-theme='dark'] .select-menu button:hover,
|
||||
:root[data-theme='dark'] .select-menu button.selected {
|
||||
color: #dce9ff;
|
||||
background: rgba(72, 118, 214, 0.32);
|
||||
}
|
||||
@@ -1262,7 +1555,7 @@ button:disabled {
|
||||
|
||||
.hint-box code {
|
||||
color: var(--text);
|
||||
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
|
||||
font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -1276,7 +1569,7 @@ button:disabled {
|
||||
background: rgba(255, 255, 255, 0.54);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .detect-card {
|
||||
:root[data-theme='dark'] .detect-card {
|
||||
background: rgba(15, 23, 42, 0.52);
|
||||
}
|
||||
|
||||
@@ -1326,7 +1619,7 @@ button:disabled {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
|
||||
font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -1377,7 +1670,7 @@ button:disabled {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .detail-panel {
|
||||
:root[data-theme='dark'] .detail-panel {
|
||||
background: rgba(12, 18, 30, 0.96);
|
||||
}
|
||||
|
||||
@@ -1423,7 +1716,7 @@ button:disabled {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
|
||||
font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -1525,28 +1818,28 @@ button:disabled {
|
||||
border-color: rgba(44, 111, 231, 0.38);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .json-key {
|
||||
:root[data-theme='dark'] .json-key {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .json-string {
|
||||
:root[data-theme='dark'] .json-string {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .json-number {
|
||||
:root[data-theme='dark'] .json-number {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .json-boolean {
|
||||
:root[data-theme='dark'] .json-boolean {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .json-null,
|
||||
:root[data-theme="dark"] .json-punctuation {
|
||||
:root[data-theme='dark'] .json-null,
|
||||
:root[data-theme='dark'] .json-punctuation {
|
||||
color: #9aa8bd;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .json-summary {
|
||||
:root[data-theme='dark'] .json-summary {
|
||||
color: #b7c3d6;
|
||||
border-color: rgba(148, 163, 184, 0.24);
|
||||
background: rgba(30, 41, 59, 0.78);
|
||||
@@ -1563,19 +1856,17 @@ button:disabled {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .detail-panel pre,
|
||||
:root[data-theme="dark"] .code-block,
|
||||
:root[data-theme="dark"] .json-viewer {
|
||||
:root[data-theme='dark'] .detail-panel pre,
|
||||
:root[data-theme='dark'] .code-block,
|
||||
:root[data-theme='dark'] .json-viewer {
|
||||
color: var(--text);
|
||||
border-color: var(--line);
|
||||
background: rgba(17, 24, 39, 0.94);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.log-row {
|
||||
grid-template-columns: 82px 58px minmax(0, 1fr);
|
||||
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
|
||||
font-family: 'SF Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
@@ -1636,9 +1927,10 @@ button:disabled {
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-areas:
|
||||
"health models"
|
||||
"config config"
|
||||
"requests requests";
|
||||
'health models'
|
||||
'config config'
|
||||
'usage usage'
|
||||
'requests requests';
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
@@ -1651,37 +1943,41 @@ button:disabled {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.strip-actions {
|
||||
.strip-actions {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.config-summary {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .strip-actions,
|
||||
:root[data-theme="dark"] .secondary-button,
|
||||
:root[data-theme="dark"] .ghost-button,
|
||||
:root[data-theme="dark"] .icon-button,
|
||||
:root[data-theme="dark"] .segmented,
|
||||
:root[data-theme="dark"] .segmented button {
|
||||
:root[data-theme='dark'] .strip-actions,
|
||||
:root[data-theme='dark'] .secondary-button,
|
||||
:root[data-theme='dark'] .ghost-button,
|
||||
:root[data-theme='dark'] .icon-button,
|
||||
:root[data-theme='dark'] .segmented,
|
||||
:root[data-theme='dark'] .segmented button {
|
||||
color: var(--text);
|
||||
border-color: var(--line);
|
||||
background: rgba(30, 41, 59, 0.66);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .strip-actions {
|
||||
:root[data-theme='dark'] .strip-actions {
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .strip-actions button {
|
||||
:root[data-theme='dark'] .strip-actions button {
|
||||
color: #e6eefc;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .strip-actions button:disabled {
|
||||
:root[data-theme='dark'] .strip-actions button:disabled {
|
||||
color: #a9b7cc;
|
||||
background: rgba(15, 23, 42, 0.52);
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .segmented button.active {
|
||||
:root[data-theme='dark'] .segmented button.active {
|
||||
color: #f8fbff;
|
||||
background: rgba(72, 118, 214, 0.42);
|
||||
}
|
||||
@@ -1759,10 +2055,11 @@ button:disabled {
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-areas:
|
||||
"health"
|
||||
"models"
|
||||
"config"
|
||||
"requests";
|
||||
'health'
|
||||
'models'
|
||||
'config'
|
||||
'usage'
|
||||
'requests';
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
@@ -1775,6 +2072,14 @@ button:disabled {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.config-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.config-summary-item.span-2 {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import {
|
||||
GetModels,
|
||||
GetConfig,
|
||||
GetRequests,
|
||||
GetStatus,
|
||||
QuitApp,
|
||||
RefreshModels,
|
||||
StartProxy,
|
||||
StopProxy,
|
||||
} from '../../wailsjs/go/main/App.js'
|
||||
import { GetModels, GetConfig, GetRequests, GetStatus, GetTokenStats, QuitApp, RefreshModels, StartProxy, StopProxy } from '../../wailsjs/go/main/App.js'
|
||||
import { ClipboardSetText } from '../../wailsjs/runtime'
|
||||
import { modelIcon } from '../modelIcons'
|
||||
|
||||
const props = defineProps({
|
||||
shellStatus: {
|
||||
type: Object,
|
||||
default: () => ({ running: false, addr: '', models: 0 }),
|
||||
},
|
||||
default: () => ({ running: false, addr: '', models: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['log', 'status', 'notice', 'open-settings', 'open-requests', 'open-models'])
|
||||
@@ -25,9 +16,11 @@ const emit = defineEmits(['log', 'status', 'notice', 'open-settings', 'open-requ
|
||||
const status = ref(props.shellStatus)
|
||||
const models = ref([])
|
||||
const requests = ref([])
|
||||
const tokenStats = ref({ totalRequests: 0, successRequests: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 })
|
||||
const health = ref(null)
|
||||
const config = ref({})
|
||||
const loading = ref(false)
|
||||
const proxyLoading = ref(false)
|
||||
const modelsLoading = ref(false)
|
||||
const testing = ref(false)
|
||||
const now = ref(Date.now())
|
||||
let interval = null
|
||||
@@ -63,7 +56,7 @@ const healthStats = computed(() => {
|
||||
avg,
|
||||
p50: percentile(sorted, 0.5),
|
||||
p95: percentile(sorted, 0.95),
|
||||
max: sorted[sorted.length - 1],
|
||||
max: Math.round(sorted[sorted.length - 1])
|
||||
}
|
||||
})
|
||||
const chartBars = computed(() => {
|
||||
@@ -78,17 +71,22 @@ const displayRequests = computed(() => {
|
||||
})
|
||||
const displayModels = computed(() => {
|
||||
if (models.value.length > 0) {
|
||||
return models.value.slice(0, 5).map((model) => ({ ...model, online: true }))
|
||||
return models.value.map((model) => ({ ...model, online: true }))
|
||||
}
|
||||
return []
|
||||
})
|
||||
const successRate = computed(() => {
|
||||
const total = Number(tokenStats.value.totalRequests || 0)
|
||||
if (!total) return '0%'
|
||||
return `${Math.round((Number(tokenStats.value.successRequests || 0) / total) * 100)}%`
|
||||
})
|
||||
|
||||
function parseDurationMs(duration) {
|
||||
const text = String(duration || '').trim()
|
||||
if (!text) return 0
|
||||
if (text.endsWith('ms')) return Number.parseFloat(text)
|
||||
if (text.endsWith('s')) return Number.parseFloat(text) * 1000
|
||||
return Number.parseFloat(text) || 0
|
||||
if (text.endsWith('ms')) return Math.round(Number.parseFloat(text))
|
||||
if (text.endsWith('s')) return Math.round(Number.parseFloat(text) * 1000)
|
||||
return Math.round(Number.parseFloat(text) || 0)
|
||||
}
|
||||
|
||||
function percentile(sorted, p) {
|
||||
@@ -97,12 +95,20 @@ function percentile(sorted, p) {
|
||||
return Math.round(sorted[index])
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
const n = Number(value || 0)
|
||||
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`
|
||||
if (n >= 10000) return `${Math.round(n / 1000)}K`
|
||||
return n.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const nextStatus = await GetStatus()
|
||||
status.value = nextStatus
|
||||
emit('status', nextStatus)
|
||||
requests.value = await GetRequests()
|
||||
tokenStats.value = await GetTokenStats()
|
||||
config.value = await GetConfig()
|
||||
if (nextStatus.running) {
|
||||
models.value = await GetModels()
|
||||
@@ -113,7 +119,7 @@ async function refresh() {
|
||||
}
|
||||
|
||||
async function refreshModels() {
|
||||
loading.value = true
|
||||
modelsLoading.value = true
|
||||
try {
|
||||
models.value = await RefreshModels()
|
||||
emit('log', 'info', `模型探测完成:${models.value.length} 个`)
|
||||
@@ -121,7 +127,7 @@ async function refreshModels() {
|
||||
} catch (e) {
|
||||
emit('log', 'error', '模型探测失败:' + (e.message || String(e)) + '。请确认 Lingma 插件已启动并登录;自动探测失败时可到设置页手动填写 WebSocket:ws://127.0.0.1:36510/,或 Windows Named Pipe:\\\\.\\pipe\\lingma-xxxx。')
|
||||
} finally {
|
||||
loading.value = false
|
||||
modelsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +147,7 @@ async function copyModelName(model) {
|
||||
}
|
||||
|
||||
async function toggleProxy() {
|
||||
loading.value = true
|
||||
proxyLoading.value = true
|
||||
try {
|
||||
if (isRunning.value) {
|
||||
await StopProxy()
|
||||
@@ -154,13 +160,13 @@ async function toggleProxy() {
|
||||
} catch (e) {
|
||||
emit('log', 'error', '代理切换失败:' + (e.message || String(e)))
|
||||
} finally {
|
||||
loading.value = false
|
||||
proxyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restartProxy() {
|
||||
if (!isRunning.value) return
|
||||
loading.value = true
|
||||
proxyLoading.value = true
|
||||
try {
|
||||
await StopProxy()
|
||||
await StartProxy()
|
||||
@@ -169,7 +175,7 @@ async function restartProxy() {
|
||||
} catch (e) {
|
||||
emit('log', 'error', '代理重启失败:' + (e.message || String(e)))
|
||||
} finally {
|
||||
loading.value = false
|
||||
proxyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,9 +247,9 @@ onUnmounted(() => {
|
||||
<strong>{{ sessionLabel }}</strong>
|
||||
</div>
|
||||
<div class="strip-actions">
|
||||
<button :class="{ active: !isRunning }" type="button" :disabled="loading || isRunning" @click="toggleProxy">启动</button>
|
||||
<button :class="{ active: isRunning }" type="button" :disabled="loading || !isRunning" @click="toggleProxy">停止</button>
|
||||
<button type="button" :disabled="loading || !isRunning" @click="restartProxy">重启</button>
|
||||
<button :class="{ active: !isRunning }" type="button" :disabled="proxyLoading || isRunning" @click="toggleProxy">启动</button>
|
||||
<button :class="{ active: isRunning }" type="button" :disabled="proxyLoading || !isRunning" @click="toggleProxy">停止</button>
|
||||
<button type="button" :disabled="proxyLoading || !isRunning" @click="restartProxy">重启</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -257,12 +263,7 @@ onUnmounted(() => {
|
||||
<span class="status-chip ok">Healthy</span>
|
||||
</div>
|
||||
<div class="activity-chart" aria-label="延迟趋势图">
|
||||
<span
|
||||
v-for="(height, index) in chartBars"
|
||||
:key="index"
|
||||
class="bar"
|
||||
:style="{ height: `${height}%`, opacity: 0.55 + index / 45 }"
|
||||
></span>
|
||||
<span v-for="(height, index) in chartBars" :key="index" class="bar" :style="{ height: `${height}%`, opacity: 0.55 + index / 45 }"></span>
|
||||
<span v-if="chartBars.length === 0" class="chart-empty">暂无请求</span>
|
||||
</div>
|
||||
<div class="health-stats">
|
||||
@@ -278,22 +279,13 @@ onUnmounted(() => {
|
||||
<div>
|
||||
<h2>Models</h2>
|
||||
</div>
|
||||
<button class="secondary-button" type="button" :disabled="loading || !isRunning" @click="refreshModels">探测模型</button>
|
||||
<button class="btn-sm-outline" type="button" :disabled="modelsLoading || !isRunning" @click="refreshModels">
|
||||
{{ modelsLoading ? '探测中...' : '探测模型' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="model-card-list hidden-scrollbar">
|
||||
<button
|
||||
v-for="model in displayModels"
|
||||
:key="model.id"
|
||||
class="model-row model-choice"
|
||||
type="button"
|
||||
:title="`复制模型 ID:${model.id}`"
|
||||
@click="copyModelName(model)"
|
||||
>
|
||||
<span
|
||||
class="model-brand-icon"
|
||||
:style="{ '--model-icon': `url(${modelIcon(model).src})`, '--model-icon-color': modelIcon(model).color }"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<button v-for="model in displayModels" :key="model.id" class="model-row model-choice" type="button" :title="`复制模型 ID:${model.id}`" @click="copyModelName(model)">
|
||||
<span class="model-brand-icon" :style="{ '--model-icon': `url(${modelIcon(model).src})`, '--model-icon-color': modelIcon(model).color }" aria-hidden="true"></span>
|
||||
<div>
|
||||
<div class="model-name">{{ model.name || model.id }}</div>
|
||||
</div>
|
||||
@@ -301,107 +293,112 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="displayModels.length === 0" class="empty-state compact">暂无模型,启动代理后点击探测模型。</div>
|
||||
<button class="link-row" type="button" @click="emit('open-models')">查看全部模型 <i class="bi bi-chevron-right"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel area-config">
|
||||
<div class="panel-header">
|
||||
<div class="panel-header compact-header">
|
||||
<div>
|
||||
<h2>Configuration</h2>
|
||||
<p>首页只展示关键配置,完整项在设置页查看。</p>
|
||||
</div>
|
||||
<span class="status-chip ok">Valid</span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="cell-main">Host</div>
|
||||
<div class="cell-sub">{{ config.Host || '127.0.0.1' }}</div>
|
||||
<div class="config-summary">
|
||||
<div class="config-summary-item">
|
||||
<label>监听地址</label>
|
||||
<strong>{{ config.Host || '127.0.0.1' }}:{{ config.Port || 8095 }}</strong>
|
||||
</div>
|
||||
<div class="config-summary-item">
|
||||
<label>传输方式</label>
|
||||
<strong>{{ transportLabel }}</strong>
|
||||
</div>
|
||||
<div class="config-summary-item">
|
||||
<label>会话策略</label>
|
||||
<strong>{{ config.SessionMode || 'Reuse' }}</strong>
|
||||
</div>
|
||||
<div class="config-summary-item">
|
||||
<label>超时</label>
|
||||
<strong>{{ config.Timeout || 120 }} 秒</strong>
|
||||
</div>
|
||||
<div class="config-summary-item span-2">
|
||||
<label>工作目录</label>
|
||||
<strong :title="config.Cwd || '未配置'">{{ config.Cwd || '未配置' }}</strong>
|
||||
</div>
|
||||
<div v-if="config.CurrentFilePath" class="config-summary-item span-2">
|
||||
<label>当前文件</label>
|
||||
<strong :title="config.CurrentFilePath">{{ config.CurrentFilePath }}</strong>
|
||||
</div>
|
||||
<span class="status-chip ok"><i class="bi bi-check"></i></span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
</div>
|
||||
|
||||
<div class="glass-panel area-usage">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<div class="cell-main">Port</div>
|
||||
<div class="cell-sub">{{ config.Port || 8095 }}</div>
|
||||
<h2>Token 统计</h2>
|
||||
<p>按代理返回的 usage 累计,流式缺失字段时只统计可获得部分。</p>
|
||||
</div>
|
||||
<span class="status-chip ok"><i class="bi bi-check"></i></span>
|
||||
<span class="status-chip ok">Persisted</span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="usage-grid">
|
||||
<div>
|
||||
<div class="cell-main">Transport</div>
|
||||
<div class="cell-sub">{{ transportLabel }}</div>
|
||||
<label>总 Token</label>
|
||||
<strong>{{ formatNumber(tokenStats.totalTokens) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<label>输入</label>
|
||||
<strong>{{ formatNumber(tokenStats.inputTokens) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<label>输出</label>
|
||||
<strong>{{ formatNumber(tokenStats.outputTokens) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<label>成功率</label>
|
||||
<strong>{{ successRate }}</strong>
|
||||
</div>
|
||||
<span class="status-chip ok"><i class="bi bi-check"></i></span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="cell-main">Session</div>
|
||||
<div class="cell-sub">{{ config.SessionMode || 'Reuse' }}</div>
|
||||
</div>
|
||||
<span class="status-chip ok"><i class="bi bi-check"></i></span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="cell-main">Timeout (s)</div>
|
||||
<div class="cell-sub">{{ config.Timeout || 120 }} 秒</div>
|
||||
</div>
|
||||
<span class="status-chip ok"><i class="bi bi-check"></i></span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="cell-main">CWD</div>
|
||||
<div class="cell-sub">{{ config.Cwd || '未配置' }}</div>
|
||||
</div>
|
||||
<span class="status-chip ok"><i class="bi bi-check"></i></span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="cell-main">Current File</div>
|
||||
<div class="cell-sub">{{ config.CurrentFilePath || '未配置' }}</div>
|
||||
</div>
|
||||
<span class="status-chip ok"><i class="bi bi-check"></i></span>
|
||||
<div class="usage-foot">
|
||||
<span>累计请求 {{ formatNumber(tokenStats.totalRequests) }} 次</span>
|
||||
<span v-if="tokenStats.lastModel">最近模型 {{ tokenStats.lastModel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-panel area-requests">
|
||||
<div class="table-toolbar">
|
||||
<div>
|
||||
<div class="panel-header" style="margin: 0">
|
||||
<div class="table-toolbar">
|
||||
<div class="panel-header toolbar-header">
|
||||
<h2>Recent Requests</h2>
|
||||
</div>
|
||||
<button type="button" class="btn-sm-outline" @click="emit('open-requests')">
|
||||
查看全部请求 <i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button class="secondary-button" type="button" @click="emit('open-requests')">查看全部</button>
|
||||
</div>
|
||||
<div v-if="displayRequests.length > 0" class="table-scroll hidden-scrollbar">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Method</th>
|
||||
<th>Path</th>
|
||||
<th>Model</th>
|
||||
<th>Status</th>
|
||||
<th>Duration</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(request, index) in displayRequests" :key="index">
|
||||
<td>{{ request.time }}</td>
|
||||
<td>{{ request.method }}</td>
|
||||
<td>{{ request.path }}</td>
|
||||
<td>{{ request.model || '-' }}</td>
|
||||
<td><span class="status-chip" :class="statusClass(request.statusCode)">{{ request.statusCode }}</span></td>
|
||||
<td>{{ request.duration }}</td>
|
||||
<td>{{ request.size || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="empty-state compact">暂无请求记录。连接客户端后会显示真实调用。</div>
|
||||
<div class="table-footer">
|
||||
<span>Showing {{ displayRequests.length }} of {{ requests.length }}</span>
|
||||
<button type="button" @click="emit('open-requests')">查看全部请求 <i class="bi bi-chevron-right"></i></button>
|
||||
</div>
|
||||
<div v-if="displayRequests.length > 0" class="table-scroll hidden-scrollbar">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Method</th>
|
||||
<th>Path</th>
|
||||
<th>Model</th>
|
||||
<th>Status</th>
|
||||
<th>Duration</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(request, index) in displayRequests" :key="index">
|
||||
<td>{{ request.time }}</td>
|
||||
<td>{{ request.method }}</td>
|
||||
<td>{{ request.path }}</td>
|
||||
<td>{{ request.model || '-' }}</td>
|
||||
<td><span class="status-chip" :class="statusClass(request.statusCode)">{{ request.statusCode }}</span></td>
|
||||
<td>{{ request.duration }}</td>
|
||||
<td>{{ request.size || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="empty-state compact">暂无请求记录。连接客户端后会显示真实调用。</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -17,20 +17,81 @@ const filtered = computed(() => {
|
||||
return models.value.filter((model) => `${model.id} ${model.name}`.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
function modelTag(model) {
|
||||
function modelSpec(model) {
|
||||
const text = `${model.id} ${model.name}`.toLowerCase()
|
||||
if (text.includes('coder')) return '工具优先'
|
||||
if (text.includes('thinking')) return '推理'
|
||||
if (text.includes('kimi')) return '长文本'
|
||||
if (text.includes('minimax')) return '通用'
|
||||
return 'Lingma'
|
||||
if (text.includes('kmodel') || text.includes('kimi')) {
|
||||
return {
|
||||
context: '256K',
|
||||
capability: '文本/图像/视频/工具',
|
||||
source: 'Kimi 官方',
|
||||
}
|
||||
}
|
||||
if (text.includes('mmodel') || text.includes('minimax')) {
|
||||
return {
|
||||
context: '200K',
|
||||
capability: 'Agent / Tool Use',
|
||||
source: 'MiniMax 官方',
|
||||
}
|
||||
}
|
||||
if (text.includes('coder')) {
|
||||
return {
|
||||
context: '1M',
|
||||
capability: '思考 / Function Calling / 结构化输出',
|
||||
source: '阿里云百炼 Qwen3-Coder',
|
||||
}
|
||||
}
|
||||
if (text.includes('thinking')) {
|
||||
return {
|
||||
context: '256K',
|
||||
capability: '思考 / Function Calling / 推理',
|
||||
source: '阿里云百炼 Qwen3',
|
||||
}
|
||||
}
|
||||
if (text.includes('qwen_max') || text.includes('qwen3-max')) {
|
||||
return {
|
||||
context: '256K',
|
||||
capability: '思考 / Function Calling / 内置工具',
|
||||
source: '阿里云百炼 Qwen3-Max',
|
||||
}
|
||||
}
|
||||
if (text.includes('qmodel') || text.includes('qwen3.6')) {
|
||||
return {
|
||||
context: '1M',
|
||||
capability: 'Function Calling / 内置工具 / 结构化输出',
|
||||
source: '阿里云百炼 Qwen3.6-Plus',
|
||||
}
|
||||
}
|
||||
if (text.includes('auto')) {
|
||||
return {
|
||||
context: '自动',
|
||||
capability: 'Lingma 自动路由',
|
||||
source: '账号返回',
|
||||
}
|
||||
}
|
||||
return {
|
||||
context: '未公开',
|
||||
capability: '通用',
|
||||
source: '账号返回',
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCachedModels() {
|
||||
loading.value = true
|
||||
try {
|
||||
status.value = await GetStatus()
|
||||
models.value = await GetModels()
|
||||
} catch (e) {
|
||||
emit('log', 'error', '模型缓存读取失败:' + (e.message || String(e)))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
status.value = await GetStatus()
|
||||
models.value = status.value.running ? await RefreshModels() : await GetModels()
|
||||
models.value = await RefreshModels()
|
||||
emit('log', 'info', `模型列表刷新完成:${models.value.length} 个`)
|
||||
} catch (e) {
|
||||
emit('log', 'error', '模型列表刷新失败:' + (e.message || String(e)) + '。自动探测失败时请到设置页手动填写 WebSocket:ws://127.0.0.1:36510/,或 Windows Named Pipe:\\\\.\\pipe\\lingma-xxxx。')
|
||||
@@ -54,7 +115,7 @@ async function copyModelName(model) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
onMounted(loadCachedModels)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -111,7 +172,11 @@ onMounted(refresh)
|
||||
<div class="model-name">{{ model.name || model.id }}</div>
|
||||
<div class="model-meta">{{ model.id }}</div>
|
||||
</div>
|
||||
<span class="status-chip" :class="modelTag(model) === '工具优先' ? 'ok' : 'warn'">{{ modelTag(model) }}</span>
|
||||
<div class="model-specs">
|
||||
<span class="spec-chip strong">{{ modelSpec(model).context }}</span>
|
||||
<span class="spec-chip">{{ modelSpec(model).capability }}</span>
|
||||
<span class="spec-chip muted-chip">{{ modelSpec(model).source }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="empty-state">启动代理并刷新后会显示模型。</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ const activeStatus = ref('all')
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
return requests.value.filter((request) => {
|
||||
const matchesQuery = !q || `${request.method} ${request.path} ${request.statusCode}`.toLowerCase().includes(q)
|
||||
const matchesQuery = !q || `${request.method} ${request.path} ${request.statusCode} ${request.model || ''}`.toLowerCase().includes(q)
|
||||
const code = Number(request.statusCode)
|
||||
const matchesStatus =
|
||||
activeStatus.value === 'all' ||
|
||||
@@ -117,7 +117,10 @@ onUnmounted(() => {
|
||||
|
||||
<section class="table-panel requests-panel">
|
||||
<div class="table-toolbar">
|
||||
<input v-model="query" class="search-input" type="search" placeholder="搜索路径、方法或状态码" />
|
||||
<div class="toolbar-search-wrap">
|
||||
<input v-model="query" class="search-input toolbar-search-input" type="search" placeholder="搜索路径、方法或状态码" />
|
||||
<span class="muted toolbar-count">Showing {{ filtered.length }} of {{ requests.length }}</span>
|
||||
</div>
|
||||
<div class="segmented">
|
||||
<button :class="{ active: activeStatus === 'all' }" type="button" @click="activeStatus = 'all'">全部</button>
|
||||
<button :class="{ active: activeStatus === 'ok' }" type="button" @click="activeStatus = 'ok'">成功</button>
|
||||
@@ -133,6 +136,7 @@ onUnmounted(() => {
|
||||
<th>时间</th>
|
||||
<th>方法</th>
|
||||
<th>路径</th>
|
||||
<th>模型</th>
|
||||
<th>状态</th>
|
||||
<th>耗时</th>
|
||||
</tr>
|
||||
@@ -150,6 +154,7 @@ onUnmounted(() => {
|
||||
<div class="cell-main">{{ request.path }}</div>
|
||||
<div class="cell-sub">{{ request.reqBody ? '包含请求体' : '无请求体' }}</div>
|
||||
</td>
|
||||
<td>{{ request.model || '-' }}</td>
|
||||
<td><span class="status-chip" :class="statusClass(request.statusCode)">{{ request.statusCode }}</span></td>
|
||||
<td>{{ request.duration }}</td>
|
||||
</tr>
|
||||
|
||||
@@ -8,6 +8,8 @@ const config = ref({})
|
||||
const detection = ref(null)
|
||||
const saving = ref(false)
|
||||
const openSelect = ref('')
|
||||
const fallbackModelsText = ref('')
|
||||
const isIPCBackend = computed(() => (config.value.Backend || 'ipc') === 'ipc')
|
||||
|
||||
const selectOptions = {
|
||||
Backend: [
|
||||
@@ -54,6 +56,9 @@ function chooseOption(field, value) {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
config.value = await GetConfig()
|
||||
fallbackModelsText.value = Array.isArray(config.value.RemoteFallbackModels)
|
||||
? config.value.RemoteFallbackModels.join('\n')
|
||||
: ''
|
||||
await refreshDetection()
|
||||
} catch (e) {
|
||||
emit('log', 'error', '配置加载失败:' + (e.message || String(e)))
|
||||
@@ -71,6 +76,10 @@ async function refreshDetection() {
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
config.value.RemoteFallbackModels = fallbackModelsText.value
|
||||
.split(/\n|,/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
await UpdateConfig(config.value)
|
||||
await refreshDetection()
|
||||
emit('log', 'info', '配置已保存,代理已按需重启')
|
||||
@@ -95,7 +104,7 @@ async function save() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="grid-2">
|
||||
<section class="grid-2 settings-grid">
|
||||
<div class="glass-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
@@ -156,6 +165,23 @@ async function save() {
|
||||
<label>超时秒数</label>
|
||||
<input v-model.number="config.Timeout" type="number" min="1" />
|
||||
</div>
|
||||
<div class="field span-2 switch-field">
|
||||
<div>
|
||||
<label>远端超时兜底</label>
|
||||
<p>远端 API 超时、限流或 5xx 且尚未流式输出时,自动切换到下一个可用模型。</p>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input v-model="config.RemoteFallbackEnabled" type="checkbox" />
|
||||
<span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field span-2">
|
||||
<label>兜底模型顺序</label>
|
||||
<textarea
|
||||
v-model="fallbackModelsText"
|
||||
placeholder="kmodel mmodel dashscope_qwen3_coder dashscope_qmodel"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="field span-2">
|
||||
<label>WebSocket 地址</label>
|
||||
<input v-model="config.WebSocketURL" type="text" placeholder="留空自动探测 Lingma WebSocket" />
|
||||
@@ -231,10 +257,16 @@ async function save() {
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>会话与环境</h2>
|
||||
<p>影响 Lingma 会话上下文和工具执行环境。</p>
|
||||
<p>仅在 IPC 插件模式下生效,影响 Lingma 会话上下文和工具执行环境。</p>
|
||||
</div>
|
||||
<span class="status-chip" :class="isIPCBackend ? 'ok' : 'warn'">{{ isIPCBackend ? '仅 IPC 生效' : '远端模式忽略' }}</span>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div v-if="!isIPCBackend" class="hint-box compact-hint">
|
||||
<strong>当前为远端 API 模式</strong>
|
||||
<span>右侧这组参数不会参与远端请求,只在切换到 IPC 插件模式后生效。</span>
|
||||
</div>
|
||||
<fieldset class="settings-fieldset" :disabled="!isIPCBackend">
|
||||
<div class="form-grid compact-form-grid">
|
||||
<div class="field">
|
||||
<label>模式</label>
|
||||
<div class="custom-select" :class="{ open: openSelect === 'Mode' }">
|
||||
@@ -301,9 +333,10 @@ async function save() {
|
||||
</div>
|
||||
<div class="field span-2">
|
||||
<label>工作目录</label>
|
||||
<textarea v-model="config.Cwd" placeholder="Lingma 创建 session 时使用的 cwd"></textarea>
|
||||
<input v-model="config.Cwd" type="text" placeholder="Lingma 创建 session 时使用的 cwd" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
4
desktop/frontend/wailsjs/go/main/App.d.ts
vendored
4
desktop/frontend/wailsjs/go/main/App.d.ts
vendored
@@ -11,12 +11,16 @@ export function GetConfig():Promise<service.Config>;
|
||||
|
||||
export function GetDetectionInfo():Promise<main.DetectionInfo>;
|
||||
|
||||
export function GetLogs():Promise<Array<main.AppLog>>;
|
||||
|
||||
export function GetModels():Promise<Array<main.ModelInfo>>;
|
||||
|
||||
export function GetRequests():Promise<Array<main.RequestRecord>>;
|
||||
|
||||
export function GetStatus():Promise<main.ProxyStatus>;
|
||||
|
||||
export function GetTokenStats():Promise<main.TokenStats>;
|
||||
|
||||
export function HideWindow():Promise<void>;
|
||||
|
||||
export function MinimizeWindow():Promise<void>;
|
||||
|
||||
@@ -18,6 +18,10 @@ export function GetDetectionInfo() {
|
||||
return window['go']['main']['App']['GetDetectionInfo']();
|
||||
}
|
||||
|
||||
export function GetLogs() {
|
||||
return window['go']['main']['App']['GetLogs']();
|
||||
}
|
||||
|
||||
export function GetModels() {
|
||||
return window['go']['main']['App']['GetModels']();
|
||||
}
|
||||
@@ -30,6 +34,10 @@ export function GetStatus() {
|
||||
return window['go']['main']['App']['GetStatus']();
|
||||
}
|
||||
|
||||
export function GetTokenStats() {
|
||||
return window['go']['main']['App']['GetTokenStats']();
|
||||
}
|
||||
|
||||
export function HideWindow() {
|
||||
return window['go']['main']['App']['HideWindow']();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
export namespace main {
|
||||
|
||||
export class AppLog {
|
||||
time: string;
|
||||
level: string;
|
||||
message: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AppLog(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.time = source["time"];
|
||||
this.level = source["level"];
|
||||
this.message = source["message"];
|
||||
}
|
||||
}
|
||||
export class DetectionInfo {
|
||||
listenUrl: string;
|
||||
backend: string;
|
||||
@@ -86,6 +102,9 @@ export namespace main {
|
||||
statusCode: number;
|
||||
duration: string;
|
||||
size?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reqBody?: string;
|
||||
respBody?: string;
|
||||
|
||||
@@ -102,10 +121,39 @@ export namespace main {
|
||||
this.statusCode = source["statusCode"];
|
||||
this.duration = source["duration"];
|
||||
this.size = source["size"];
|
||||
this.inputTokens = source["inputTokens"];
|
||||
this.outputTokens = source["outputTokens"];
|
||||
this.totalTokens = source["totalTokens"];
|
||||
this.reqBody = source["reqBody"];
|
||||
this.respBody = source["respBody"];
|
||||
}
|
||||
}
|
||||
export class TokenStats {
|
||||
totalRequests: number;
|
||||
successRequests: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
byModel?: Record<string, number>;
|
||||
lastModel?: string;
|
||||
lastUpdated?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new TokenStats(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.totalRequests = source["totalRequests"];
|
||||
this.successRequests = source["successRequests"];
|
||||
this.inputTokens = source["inputTokens"];
|
||||
this.outputTokens = source["outputTokens"];
|
||||
this.totalTokens = source["totalTokens"];
|
||||
this.byModel = source["byModel"];
|
||||
this.lastModel = source["lastModel"];
|
||||
this.lastUpdated = source["lastUpdated"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -128,6 +176,8 @@ export namespace service {
|
||||
ShellType: string;
|
||||
SessionMode: string;
|
||||
Timeout: number;
|
||||
RemoteFallbackEnabled: boolean;
|
||||
RemoteFallbackModels: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Config(source);
|
||||
@@ -151,6 +201,8 @@ export namespace service {
|
||||
this.ShellType = source["ShellType"];
|
||||
this.SessionMode = source["SessionMode"];
|
||||
this.Timeout = source["Timeout"];
|
||||
this.RemoteFallbackEnabled = source["RemoteFallbackEnabled"];
|
||||
this.RemoteFallbackModels = source["RemoteFallbackModels"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user