feat: add desktop app release packaging
268
desktop/frontend/src/App.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
import Logs from './views/Logs.vue'
|
||||
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 lingmaIcon from './assets/images/lingma-icon.png'
|
||||
|
||||
const currentTab = ref('dashboard')
|
||||
const logs = ref([])
|
||||
const status = ref({ running: false, addr: '', models: 0 })
|
||||
const toast = ref('')
|
||||
const themeMode = ref(localStorage.getItem('lingma-theme-mode') || 'system')
|
||||
const appliedTheme = ref('light')
|
||||
let systemThemeQuery = null
|
||||
let toastTimer = null
|
||||
|
||||
const navigation = [
|
||||
{ key: 'dashboard', label: '仪表盘', icon: 'bi-house-door' },
|
||||
{ key: 'requests', label: '请求流', icon: 'bi-file-earmark-text' },
|
||||
{ key: 'models', label: '模型', icon: 'bi-box' },
|
||||
{ key: 'settings', label: '设置', icon: 'bi-gear' },
|
||||
{ key: 'logs', label: '日志', icon: 'bi-terminal' },
|
||||
]
|
||||
|
||||
function addLog(level, message) {
|
||||
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
logs.value.unshift({ time, level, message })
|
||||
if (logs.value.length > 500) {
|
||||
logs.value = logs.value.slice(0, 500)
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
toast.value = message
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => {
|
||||
toast.value = ''
|
||||
}, 2200)
|
||||
}
|
||||
|
||||
function clearLocalLogs() {
|
||||
logs.value = []
|
||||
}
|
||||
|
||||
function setStatus(nextStatus) {
|
||||
status.value = nextStatus
|
||||
}
|
||||
|
||||
function handleNotice(message) {
|
||||
showToast(message)
|
||||
addLog('info', message)
|
||||
}
|
||||
|
||||
function resolveTheme() {
|
||||
if (themeMode.value === 'system') {
|
||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return themeMode.value
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
appliedTheme.value = resolveTheme()
|
||||
document.documentElement.dataset.theme = appliedTheme.value
|
||||
localStorage.setItem('lingma-theme-mode', themeMode.value)
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const modes = ['system', 'light', 'dark']
|
||||
const index = modes.indexOf(themeMode.value)
|
||||
themeMode.value = modes[(index + 1) % modes.length]
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
function themeTitle() {
|
||||
if (themeMode.value === 'system') return `跟随系统(当前${appliedTheme.value === 'dark' ? '夜间' : '日间'})`
|
||||
return themeMode.value === 'dark' ? '夜间模式' : '日间模式'
|
||||
}
|
||||
|
||||
function themeIcon() {
|
||||
if (themeMode.value === 'system') return 'bi-circle-half'
|
||||
return themeMode.value === 'dark' ? 'bi-moon-stars' : 'bi-sun'
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
status.value = await GetStatus()
|
||||
} catch (e) {
|
||||
addLog('error', '状态刷新失败:' + (e.message || String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
async function copyEndpoint() {
|
||||
if (!status.value.addr) return
|
||||
const value = `http://${status.value.addr}`
|
||||
await navigator.clipboard?.writeText(value)
|
||||
handleNotice('已复制接口地址:' + value)
|
||||
}
|
||||
|
||||
function safeEventsOn(name, handler) {
|
||||
try {
|
||||
EventsOn(name, handler)
|
||||
} catch (e) {
|
||||
console.debug('Wails runtime event unavailable:', name)
|
||||
}
|
||||
}
|
||||
|
||||
function safeEventsOff(name) {
|
||||
try {
|
||||
EventsOff(name)
|
||||
} catch (e) {
|
||||
console.debug('Wails runtime event unavailable:', name)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAppShortcut(event) {
|
||||
const key = event.key.toLowerCase()
|
||||
if ((event.metaKey || event.ctrlKey) && key === 'w') {
|
||||
event.preventDefault()
|
||||
HideWindow()
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && key === 'm') {
|
||||
event.preventDefault()
|
||||
MinimizeWindow()
|
||||
}
|
||||
// Fallback copy for WebView where native Edit menu is unavailable
|
||||
if ((event.metaKey || event.ctrlKey) && key === 'c') {
|
||||
const selection = window.getSelection()?.toString()
|
||||
if (selection) {
|
||||
event.preventDefault()
|
||||
navigator.clipboard?.writeText(selection).catch(() => {})
|
||||
}
|
||||
}
|
||||
// Fallback select-all for log/request content areas
|
||||
if ((event.metaKey || event.ctrlKey) && key === 'a') {
|
||||
const active = document.activeElement
|
||||
const isEditable = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)
|
||||
if (!isEditable) {
|
||||
const panel = document.querySelector('.log-list, .request-list, .detail-panel')
|
||||
if (panel) {
|
||||
event.preventDefault()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(panel)
|
||||
const sel = window.getSelection()
|
||||
sel?.removeAllRanges()
|
||||
sel?.addRange(range)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleAppShortcut, true)
|
||||
systemThemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)')
|
||||
systemThemeQuery?.addEventListener?.('change', applyTheme)
|
||||
applyTheme()
|
||||
refreshStatus()
|
||||
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 || '')
|
||||
refreshStatus()
|
||||
})
|
||||
safeEventsOn('quit:confirm', (message) => {
|
||||
showToast(message || '再按一次退出快捷键将停止代理并退出应用')
|
||||
})
|
||||
safeEventsOn('status:updated', (nextStatus) => {
|
||||
status.value = nextStatus
|
||||
})
|
||||
safeEventsOn('requests:updated', () => {
|
||||
refreshStatus()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleAppShortcut, true)
|
||||
clearTimeout(toastTimer)
|
||||
systemThemeQuery?.removeEventListener?.('change', applyTheme)
|
||||
safeEventsOff('models:updated')
|
||||
safeEventsOff('log')
|
||||
safeEventsOff('quit:confirm')
|
||||
safeEventsOff('status:updated')
|
||||
safeEventsOff('requests:updated')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<button class="brand" type="button" @click="currentTab = 'dashboard'">
|
||||
<span class="brand-mark">
|
||||
<img :src="lingmaIcon" alt="" />
|
||||
</span>
|
||||
<span>
|
||||
<strong>灵码代理</strong>
|
||||
<small>IPC Proxy</small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<nav class="nav-list" aria-label="主导航">
|
||||
<button
|
||||
v-for="item in navigation"
|
||||
:key="item.key"
|
||||
class="nav-item"
|
||||
:class="{ active: currentTab === item.key }"
|
||||
type="button"
|
||||
@click="currentTab = item.key"
|
||||
>
|
||||
<span class="nav-icon">
|
||||
<i class="bi" :class="item.icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-status">
|
||||
<span class="status-dot" :class="{ running: status.running }"></span>
|
||||
<div>
|
||||
<strong>{{ status.running ? 'Proxy Running' : 'Proxy Stopped' }}</strong>
|
||||
<small>v1.2.0</small>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<header class="topbar">
|
||||
<span class="topbar-spacer" aria-hidden="true"></span>
|
||||
<div class="topbar-actions">
|
||||
<button class="icon-button" type="button" title="刷新状态" @click="refreshStatus">
|
||||
<i class="bi bi-arrow-clockwise" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="icon-button" type="button" title="复制接口地址" @click="copyEndpoint">
|
||||
<i class="bi bi-copy" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="icon-button" type="button" title="设置" @click="currentTab = 'settings'">
|
||||
<i class="bi bi-gear" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button class="icon-button" type="button" :title="themeTitle()" @click="toggleTheme">
|
||||
<i class="bi" :class="themeIcon()" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="view-stage">
|
||||
<Dashboard
|
||||
v-if="currentTab === 'dashboard'"
|
||||
:shell-status="status"
|
||||
@log="addLog"
|
||||
@status="setStatus"
|
||||
@notice="handleNotice"
|
||||
@open-settings="currentTab = 'settings'"
|
||||
@open-requests="currentTab = 'requests'"
|
||||
@open-models="currentTab = 'models'"
|
||||
/>
|
||||
<Requests v-else-if="currentTab === 'requests'" @notice="handleNotice" />
|
||||
<Models v-else-if="currentTab === 'models'" @log="addLog" @status="setStatus" @notice="handleNotice" />
|
||||
<Settings v-else-if="currentTab === 'settings'" @log="addLog" @status-refresh="refreshStatus" />
|
||||
<Logs v-else-if="currentTab === 'logs'" :logs="logs" @clear="clearLocalLogs" @notice="handleNotice" />
|
||||
</main>
|
||||
</section>
|
||||
<div v-if="toast" class="toast">{{ toast }}</div>
|
||||
</div>
|
||||
</template>
|
||||
93
desktop/frontend/src/assets/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
desktop/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
Normal file
1
desktop/frontend/src/assets/icons/claude.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
desktop/frontend/src/assets/icons/gemma.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemma</title><path d="M12.34 5.953a8.233 8.233 0 01-.247-1.125V3.72a8.25 8.25 0 015.562 2.232H12.34zm-.69 0c.113-.373.199-.755.257-1.145V3.72a8.25 8.25 0 00-5.562 2.232h5.304zm-5.433.187h5.373a7.98 7.98 0 01-.267.696 8.41 8.41 0 01-1.76 2.65L6.216 6.14zm-.264-.187H2.977v.187h2.915a8.436 8.436 0 00-2.357 5.767H0v.186h3.535a8.436 8.436 0 002.357 5.767H2.977v.186h2.976v2.977h.187v-2.915a8.436 8.436 0 005.767 2.357V24h.186v-3.535a8.436 8.436 0 005.767-2.357v2.915h.186v-2.977h2.977v-.186h-2.915a8.436 8.436 0 002.357-5.767H24v-.186h-3.535a8.436 8.436 0 00-2.357-5.767h2.915v-.187h-2.977V2.977h-.186v2.915a8.436 8.436 0 00-5.767-2.357V0h-.186v3.535A8.436 8.436 0 006.14 5.892V2.977h-.187v2.976zm6.14 14.326a8.25 8.25 0 005.562-2.233H12.34c-.108.367-.19.743-.247 1.126v1.107zm-.186-1.087a8.015 8.015 0 00-.258-1.146H6.345a8.25 8.25 0 005.562 2.233v-1.087zm-8.186-7.285h1.107a8.23 8.23 0 001.125-.247V6.345a8.25 8.25 0 00-2.232 5.562zm1.087.186H3.72a8.25 8.25 0 002.232 5.562v-5.304a8.012 8.012 0 00-1.145-.258zm15.47-.186a8.25 8.25 0 00-2.232-5.562v5.315c.367.108.743.19 1.126.247h1.107zm-1.086.186c-.39.058-.772.144-1.146.258v5.304a8.25 8.25 0 002.233-5.562h-1.087zm-1.332 5.69V12.41a7.97 7.97 0 00-.696.267 8.409 8.409 0 00-2.65 1.76l3.346 3.346zm0-6.18v-5.45l-.012-.013h-5.451c.076.235.162.468.26.696a8.698 8.698 0 001.819 2.688 8.698 8.698 0 002.688 1.82c.228.097.46.183.696.259zM6.14 17.848V12.41c.235.078.468.167.696.267a8.403 8.403 0 012.688 1.799 8.404 8.404 0 011.799 2.688c.1.228.19.46.267.696H6.152l-.012-.012zm0-6.245V6.326l3.29 3.29a8.716 8.716 0 01-2.594 1.728 8.14 8.14 0 01-.696.259zm6.257 6.257h5.277l-3.29-3.29a8.716 8.716 0 00-1.728 2.594 8.135 8.135 0 00-.259.696zm-2.347-7.81a9.435 9.435 0 01-2.88 1.96 9.14 9.14 0 012.88 1.94 9.14 9.14 0 011.94 2.88 9.435 9.435 0 011.96-2.88 9.14 9.14 0 012.88-1.94 9.435 9.435 0 01-2.88-1.96 9.434 9.434 0 01-1.96-2.88 9.14 9.14 0 01-1.94 2.88z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
1
desktop/frontend/src/assets/icons/kimi.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z"></path></svg>
|
||||
|
After Width: | Height: | Size: 786 B |
1
desktop/frontend/src/assets/icons/minimax.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
desktop/frontend/src/assets/icons/openai.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
desktop/frontend/src/assets/icons/qwen.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
desktop/frontend/src/assets/images/lingma-icon.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
desktop/frontend/src/assets/images/logo-universal.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
71
desktop/frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<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>
|
||||
6
desktop/frontend/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import {createApp} from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css';
|
||||
import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||
|
||||
createApp(App).mount('#app')
|
||||
24
desktop/frontend/src/modelIcons.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import autoIcon from 'bootstrap-icons/icons/shuffle.svg'
|
||||
import claudeIcon from './assets/icons/claude.svg'
|
||||
import gemmaIcon from './assets/icons/gemma.svg'
|
||||
import kimiIcon from './assets/icons/kimi.svg'
|
||||
import lingmaIcon from './assets/images/lingma-icon.png'
|
||||
import minimaxIcon from './assets/icons/minimax.svg'
|
||||
import openaiIcon from './assets/icons/openai.svg'
|
||||
import qwenIcon from './assets/icons/qwen.svg'
|
||||
|
||||
const ICONS = [
|
||||
{ match: ['auto', 'automatic', '自动'], src: autoIcon, color: '#2563eb' },
|
||||
{ match: ['qwen', 'qwq'], src: qwenIcon, color: '#5b6ee1' },
|
||||
{ match: ['kimi', 'moonshot'], src: kimiIcon, color: '#111827' },
|
||||
{ match: ['minimax', 'abab'], src: minimaxIcon, color: '#1677ff' },
|
||||
{ match: ['claude', 'anthropic'], src: claudeIcon, color: '#d97757' },
|
||||
{ match: ['gpt', 'openai'], src: openaiIcon, color: '#10a37f' },
|
||||
{ match: ['gemma', 'google'], src: gemmaIcon, color: '#4285f4' },
|
||||
]
|
||||
|
||||
export function modelIcon(model) {
|
||||
const text = `${model?.id || ''} ${model?.name || ''}`.toLowerCase()
|
||||
const matched = ICONS.find((item) => item.match.some((keyword) => text.includes(keyword)))
|
||||
return matched || { src: lingmaIcon, color: '#2563eb' }
|
||||
}
|
||||
1540
desktop/frontend/src/style.css
Normal file
402
desktop/frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,402 @@
|
||||
<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 { ClipboardSetText } from '../../wailsjs/runtime'
|
||||
import { modelIcon } from '../modelIcons'
|
||||
|
||||
const props = defineProps({
|
||||
shellStatus: {
|
||||
type: Object,
|
||||
default: () => ({ running: false, addr: '', models: 0 }),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['log', 'status', 'notice', 'open-settings', 'open-requests', 'open-models'])
|
||||
|
||||
const status = ref(props.shellStatus)
|
||||
const models = ref([])
|
||||
const requests = ref([])
|
||||
const health = ref(null)
|
||||
const config = ref({})
|
||||
const loading = ref(false)
|
||||
const testing = ref(false)
|
||||
const now = ref(Date.now())
|
||||
let interval = null
|
||||
let clockInterval = null
|
||||
|
||||
const endpoint = computed(() => (status.value.addr ? `http://${status.value.addr}` : '未启动'))
|
||||
const isRunning = computed(() => Boolean(status.value.running))
|
||||
const runningDuration = computed(() => {
|
||||
if (!isRunning.value || !status.value.startedAt) return '未运行'
|
||||
const startedAt = new Date(status.value.startedAt).getTime()
|
||||
if (!Number.isFinite(startedAt)) return '运行中'
|
||||
const seconds = Math.max(0, Math.floor((now.value - startedAt) / 1000))
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const rest = seconds % 60
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
|
||||
})
|
||||
const parsedDurations = computed(() => requests.value.map((request) => parseDurationMs(request.duration)).filter((value) => value > 0))
|
||||
const healthStats = computed(() => {
|
||||
const values = parsedDurations.value
|
||||
if (values.length === 0) {
|
||||
return { avg: 0, p50: 0, p95: 0, max: 0 }
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const avg = Math.round(values.reduce((sum, value) => sum + value, 0) / values.length)
|
||||
return {
|
||||
avg,
|
||||
p50: percentile(sorted, 0.5),
|
||||
p95: percentile(sorted, 0.95),
|
||||
max: sorted[sorted.length - 1],
|
||||
}
|
||||
})
|
||||
const chartBars = computed(() => {
|
||||
const values = parsedDurations.value.slice(0, 36).reverse()
|
||||
if (values.length === 0) return []
|
||||
const max = Math.max(...values)
|
||||
return values.map((value) => Math.max(12, Math.round((value / max) * 100)))
|
||||
})
|
||||
const displayRequests = computed(() => {
|
||||
if (requests.value.length > 0) return requests.value.slice(0, 7)
|
||||
return []
|
||||
})
|
||||
const displayModels = computed(() => {
|
||||
if (models.value.length > 0) {
|
||||
return models.value.slice(0, 5).map((model) => ({ ...model, online: true }))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function percentile(sorted, p) {
|
||||
if (sorted.length === 0) return 0
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * p) - 1))
|
||||
return Math.round(sorted[index])
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const nextStatus = await GetStatus()
|
||||
status.value = nextStatus
|
||||
emit('status', nextStatus)
|
||||
requests.value = await GetRequests()
|
||||
config.value = await GetConfig()
|
||||
if (nextStatus.running) {
|
||||
models.value = await GetModels()
|
||||
}
|
||||
} catch (e) {
|
||||
emit('log', 'error', '刷新仪表盘失败:' + (e.message || String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshModels() {
|
||||
loading.value = true
|
||||
try {
|
||||
models.value = await RefreshModels()
|
||||
emit('log', 'info', `模型探测完成:${models.value.length} 个`)
|
||||
await refresh()
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
async function copyModelName(model) {
|
||||
if (!model?.id) return
|
||||
try {
|
||||
await ClipboardSetText(model.id)
|
||||
emit('notice', `已复制模型 ID:${model.id}`)
|
||||
} catch (e) {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(model.id)
|
||||
emit('notice', `已复制模型 ID:${model.id}`)
|
||||
} catch (fallbackError) {
|
||||
emit('log', 'error', '模型 ID 复制失败:' + (fallbackError.message || String(fallbackError)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleProxy() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (isRunning.value) {
|
||||
await StopProxy()
|
||||
emit('log', 'info', '代理已停止')
|
||||
} else {
|
||||
await StartProxy()
|
||||
emit('log', 'info', '代理已启动')
|
||||
}
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
emit('log', 'error', '代理切换失败:' + (e.message || String(e)))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restartProxy() {
|
||||
if (!isRunning.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await StopProxy()
|
||||
await StartProxy()
|
||||
emit('log', 'info', '代理已重启')
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
emit('log', 'error', '代理重启失败:' + (e.message || String(e)))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
if (!isRunning.value || !status.value.addr) {
|
||||
emit('log', 'warn', '代理未运行,无法测试连接')
|
||||
return
|
||||
}
|
||||
testing.value = true
|
||||
try {
|
||||
const resp = await fetch(`${endpoint.value}/health`)
|
||||
const data = await resp.json()
|
||||
health.value = data
|
||||
emit('log', data.ok ? 'info' : 'warn', data.ok ? '健康检查通过' : '健康检查返回异常')
|
||||
} catch (e) {
|
||||
health.value = { ok: false, error: e.message || String(e) }
|
||||
emit('log', 'error', '健康检查失败:' + (e.message || String(e)))
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function quitApp() {
|
||||
if (confirm('确定退出应用?代理服务会一起停止。')) {
|
||||
await QuitApp()
|
||||
}
|
||||
}
|
||||
|
||||
function statusClass(code) {
|
||||
if (code >= 200 && code < 300) return 'ok'
|
||||
if (code >= 400) return 'err'
|
||||
return 'warn'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
interval = setInterval(refresh, 2500)
|
||||
clockInterval = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(interval)
|
||||
clearInterval(clockInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<section class="glass-panel status-strip">
|
||||
<div class="strip-cell">
|
||||
<span class="strip-dot" :class="{ stopped: !isRunning }"></span>
|
||||
<div>
|
||||
<strong>{{ isRunning ? 'Proxy Running' : 'Proxy Stopped' }}</strong>
|
||||
<span>{{ isRunning ? `运行 ${runningDuration}` : runningDuration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="strip-cell">
|
||||
<label>Endpoint</label>
|
||||
<a href="#" @click.prevent>{{ endpoint }}</a>
|
||||
</div>
|
||||
<div class="strip-cell">
|
||||
<label>Transport</label>
|
||||
<strong>{{ health?.state?.transport || 'WebSocket' }}</strong>
|
||||
</div>
|
||||
<div class="strip-cell">
|
||||
<label>Session</label>
|
||||
<strong>{{ health?.state?.session_mode || 'Reuse' }}</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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-grid">
|
||||
<div class="glass-panel area-health">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Health <span class="muted">(Last 60s)</span></h2>
|
||||
<p>Latency (ms)</p>
|
||||
</div>
|
||||
<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-if="chartBars.length === 0" class="chart-empty">暂无请求</span>
|
||||
</div>
|
||||
<div class="health-stats">
|
||||
<div><strong>{{ healthStats.avg }}</strong><span>Avg (ms)</span></div>
|
||||
<div><strong>{{ healthStats.p50 }}</strong><span>P50 (ms)</span></div>
|
||||
<div><strong>{{ healthStats.p95 }}</strong><span>P95 (ms)</span></div>
|
||||
<div><strong style="color: #d97706">{{ healthStats.max }}</strong><span>Max (ms)</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel area-models model-card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Models</h2>
|
||||
</div>
|
||||
<button class="secondary-button" type="button" :disabled="loading || !isRunning" @click="refreshModels">探测模型</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>
|
||||
<div>
|
||||
<div class="model-name">{{ model.name || model.id }}</div>
|
||||
</div>
|
||||
<span class="status-chip" :class="model.online ? 'ok' : 'warn'">{{ model.online ? 'Online' : 'Offline' }}</span>
|
||||
</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>
|
||||
<h2>Configuration</h2>
|
||||
</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>
|
||||
<span class="status-chip ok"><i class="bi bi-check"></i></span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="cell-main">Port</div>
|
||||
<div class="cell-sub">{{ config.Port || 8095 }}</div>
|
||||
</div>
|
||||
<span class="status-chip ok"><i class="bi bi-check"></i></span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="cell-main">Transport</div>
|
||||
<div class="cell-sub">{{ config.Transport || 'WebSocket' }}</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="table-panel area-requests">
|
||||
<div class="table-toolbar">
|
||||
<div>
|
||||
<div class="panel-header" style="margin: 0">
|
||||
<h2>Recent Requests</h2>
|
||||
</div>
|
||||
</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 || 'Qwen3-Coder' }}</td>
|
||||
<td><span class="status-chip" :class="statusClass(request.statusCode)">{{ request.statusCode }}</span></td>
|
||||
<td>{{ request.duration }}</td>
|
||||
<td>{{ request.size || '2.1 KB' }}</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>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
96
desktop/frontend/src/views/Logs.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { ClipboardSetText } from '../../wailsjs/runtime'
|
||||
|
||||
const props = defineProps({
|
||||
logs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['clear', 'notice'])
|
||||
|
||||
const filter = ref('all')
|
||||
const search = ref('')
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
return props.logs.filter((log) => {
|
||||
const matchesLevel = filter.value === 'all' || log.level === filter.value
|
||||
const matchesSearch = !q || `${log.time} ${log.level} ${log.message}`.toLowerCase().includes(q)
|
||||
return matchesLevel && matchesSearch
|
||||
})
|
||||
})
|
||||
|
||||
function levelClass(level) {
|
||||
return {
|
||||
info: 'level-info',
|
||||
warn: 'level-warn',
|
||||
error: 'level-error',
|
||||
}[level] || 'level-info'
|
||||
}
|
||||
|
||||
function levelLabel(level) {
|
||||
return {
|
||||
info: '信息',
|
||||
warn: '警告',
|
||||
error: '错误',
|
||||
}[level] || level
|
||||
}
|
||||
|
||||
function serializeLogs() {
|
||||
return filteredLogs.value.map((log) => `[${log.time}] ${levelLabel(log.level)} ${log.message}`).join('\n')
|
||||
}
|
||||
|
||||
async function copyLogs() {
|
||||
try {
|
||||
await ClipboardSetText(serializeLogs())
|
||||
emit('notice', `已复制 ${filteredLogs.value.length} 条日志`)
|
||||
} catch (e) {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(serializeLogs())
|
||||
emit('notice', `已复制 ${filteredLogs.value.length} 条日志`)
|
||||
} catch (fallbackError) {
|
||||
console.debug('Copy logs failed:', fallbackError)
|
||||
emit('notice', '日志复制失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page logs-page">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>日志</h1>
|
||||
<p>记录代理启动、模型同步、健康检查和配置保存事件。</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="secondary-button" type="button" :disabled="filteredLogs.length === 0" @click="copyLogs">复制日志</button>
|
||||
<button class="danger-button" type="button" @click="emit('clear')">清空日志</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="table-panel logs-panel">
|
||||
<div class="table-toolbar">
|
||||
<div class="segmented">
|
||||
<button :class="{ active: filter === 'all' }" type="button" @click="filter = 'all'">全部</button>
|
||||
<button :class="{ active: filter === 'info' }" type="button" @click="filter = 'info'">信息</button>
|
||||
<button :class="{ active: filter === 'warn' }" type="button" @click="filter = 'warn'">警告</button>
|
||||
<button :class="{ active: filter === 'error' }" type="button" @click="filter = 'error'">错误</button>
|
||||
</div>
|
||||
<input v-model="search" class="search-input" type="search" placeholder="搜索日志内容" />
|
||||
</div>
|
||||
|
||||
<div v-if="filteredLogs.length > 0" class="log-list hidden-scrollbar">
|
||||
<div v-for="(log, index) in filteredLogs" :key="index" class="log-row">
|
||||
<span class="muted">{{ log.time }}</span>
|
||||
<strong :class="levelClass(log.level)">{{ levelLabel(log.level) }}</strong>
|
||||
<span>{{ log.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无日志。</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
120
desktop/frontend/src/views/Models.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { GetModels, GetStatus, RefreshModels } from '../../wailsjs/go/main/App.js'
|
||||
import { ClipboardSetText } from '../../wailsjs/runtime'
|
||||
import { modelIcon } from '../modelIcons'
|
||||
|
||||
const emit = defineEmits(['log', 'status', 'notice'])
|
||||
|
||||
const models = ref([])
|
||||
const status = ref({ running: false, addr: '', models: 0 })
|
||||
const loading = ref(false)
|
||||
const query = ref('')
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
if (!q) return models.value
|
||||
return models.value.filter((model) => `${model.id} ${model.name}`.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
function modelTag(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'
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
status.value = await GetStatus()
|
||||
models.value = status.value.running ? await RefreshModels() : await GetModels()
|
||||
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。')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyModelName(model) {
|
||||
if (!model?.id) return
|
||||
try {
|
||||
await ClipboardSetText(model.id)
|
||||
emit('notice', `已复制模型 ID:${model.id}`)
|
||||
} catch (e) {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(model.id)
|
||||
emit('notice', `已复制模型 ID:${model.id}`)
|
||||
} catch (fallbackError) {
|
||||
emit('log', 'error', '模型 ID 复制失败:' + (fallbackError.message || String(fallbackError)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>模型</h1>
|
||||
<p>来自 Lingma 插件的可用模型列表,第三方客户端可以直接使用这些 ID。</p>
|
||||
</div>
|
||||
<button class="primary-button" type="button" :disabled="loading" @click="refresh">
|
||||
{{ loading ? '刷新中...' : '刷新模型' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="grid-3">
|
||||
<div class="metric">
|
||||
<label>代理状态</label>
|
||||
<strong>{{ status.running ? '运行中' : '未运行' }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<label>接口地址</label>
|
||||
<strong>{{ status.addr || '未启动' }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<label>模型数量</label>
|
||||
<strong>{{ models.length }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="glass-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>可用模型</h2>
|
||||
<p>推荐 Claude Code / Cline 优先选择 Qwen3-Coder。</p>
|
||||
</div>
|
||||
<input v-model="query" class="search-input" type="search" placeholder="搜索模型" style="max-width: 260px" />
|
||||
</div>
|
||||
|
||||
<div v-if="filtered.length > 0" class="models-list model-page-list hidden-scrollbar">
|
||||
<button
|
||||
v-for="model in filtered"
|
||||
:key="model.id"
|
||||
class="model-row model-list-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 class="model-meta">{{ model.id }}</div>
|
||||
</div>
|
||||
<span class="status-chip" :class="modelTag(model) === '工具优先' ? 'ok' : 'warn'">{{ modelTag(model) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="empty-state">启动代理并刷新后会显示模型。</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
181
desktop/frontend/src/views/Requests.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ClearRequests, GetRequests } from '../../wailsjs/go/main/App.js'
|
||||
import { ClipboardSetText, EventsOff, EventsOn } from '../../wailsjs/runtime'
|
||||
|
||||
const emit = defineEmits(['notice'])
|
||||
|
||||
const requests = ref([])
|
||||
const selected = ref(null)
|
||||
const query = ref('')
|
||||
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 code = Number(request.statusCode)
|
||||
const matchesStatus =
|
||||
activeStatus.value === 'all' ||
|
||||
(activeStatus.value === 'ok' && code >= 200 && code < 300) ||
|
||||
(activeStatus.value === 'err' && code >= 400) ||
|
||||
(activeStatus.value === 'warn' && code >= 300 && code < 400)
|
||||
return matchesQuery && matchesStatus
|
||||
})
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
requests.value = await GetRequests()
|
||||
} catch (e) {
|
||||
console.debug('Wails GetRequests unavailable in browser preview')
|
||||
}
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
try {
|
||||
await ClearRequests()
|
||||
} catch (e) {
|
||||
console.debug('Wails ClearRequests unavailable in browser preview')
|
||||
}
|
||||
requests.value = []
|
||||
selected.value = null
|
||||
}
|
||||
|
||||
function statusClass(code) {
|
||||
if (code >= 200 && code < 300) return 'ok'
|
||||
if (code >= 400) return 'err'
|
||||
return 'warn'
|
||||
}
|
||||
|
||||
function selectRow(index) {
|
||||
selected.value = selected.value === index ? null : index
|
||||
}
|
||||
|
||||
async function writeClipboard(text) {
|
||||
const value = text || ''
|
||||
try {
|
||||
await ClipboardSetText(value)
|
||||
return true
|
||||
} catch (e) {
|
||||
await navigator.clipboard?.writeText(value)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text, label) {
|
||||
try {
|
||||
await writeClipboard(text)
|
||||
emit('notice', `已复制${label}`)
|
||||
} catch (e) {
|
||||
console.debug('Copy failed:', e)
|
||||
emit('notice', `${label}复制失败`)
|
||||
}
|
||||
}
|
||||
|
||||
function safeEventsOn(name, handler) {
|
||||
try {
|
||||
EventsOn(name, handler)
|
||||
} catch (e) {
|
||||
console.debug('Wails runtime event unavailable:', name)
|
||||
}
|
||||
}
|
||||
|
||||
function safeEventsOff(name) {
|
||||
try {
|
||||
EventsOff(name)
|
||||
} catch (e) {
|
||||
console.debug('Wails runtime event unavailable:', name)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
safeEventsOn('requests:updated', (data) => {
|
||||
requests.value = data || []
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
safeEventsOff('requests:updated')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page requests-page">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>请求流</h1>
|
||||
<p>查看客户端调用 OpenAI / Anthropic 兼容接口的请求与响应。</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="secondary-button" type="button" @click="refresh">刷新</button>
|
||||
<button class="danger-button" type="button" @click="clear">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="table-panel requests-panel">
|
||||
<div class="table-toolbar">
|
||||
<input v-model="query" class="search-input" type="search" placeholder="搜索路径、方法或状态码" />
|
||||
<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>
|
||||
<button :class="{ active: activeStatus === 'warn' }" type="button" @click="activeStatus = 'warn'">跳转</button>
|
||||
<button :class="{ active: activeStatus === 'err' }" type="button" @click="activeStatus = 'err'">错误</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filtered.length > 0" class="table-scroll hidden-scrollbar">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>方法</th>
|
||||
<th>路径</th>
|
||||
<th>状态</th>
|
||||
<th>耗时</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(request, index) in filtered" :key="index" @click="selectRow(index)">
|
||||
<td>{{ request.time }}</td>
|
||||
<td><span class="method-chip">{{ request.method }}</span></td>
|
||||
<td>
|
||||
<div class="cell-main">{{ request.path }}</div>
|
||||
<div class="cell-sub">{{ request.reqBody ? '包含请求体' : '无请求体' }}</div>
|
||||
</td>
|
||||
<td><span class="status-chip" :class="statusClass(request.statusCode)">{{ request.statusCode }}</span></td>
|
||||
<td>{{ request.duration }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无匹配请求。</div>
|
||||
|
||||
<div v-if="selected !== null && filtered[selected]" class="detail-panel hidden-scrollbar">
|
||||
<div class="detail-section">
|
||||
<div class="detail-toolbar">
|
||||
<h3>请求内容</h3>
|
||||
<div class="detail-actions">
|
||||
<button type="button" class="ghost-button" @click="copyText(filtered[selected].reqBody, '请求内容')">
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre>{{ filtered[selected].reqBody || '空请求体' }}</pre>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<div class="detail-toolbar">
|
||||
<h3>响应内容</h3>
|
||||
<div class="detail-actions">
|
||||
<button type="button" class="ghost-button" @click="copyText(filtered[selected].respBody, '响应内容')">
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre>{{ filtered[selected].respBody || '空响应体' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
218
desktop/frontend/src/views/Settings.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { GetConfig, UpdateConfig } from '../../wailsjs/go/main/App.js'
|
||||
|
||||
const emit = defineEmits(['log', 'status-refresh'])
|
||||
|
||||
const config = ref({})
|
||||
const saving = ref(false)
|
||||
const openSelect = ref('')
|
||||
|
||||
const selectOptions = {
|
||||
Transport: [
|
||||
{ value: 'auto', label: '自动' },
|
||||
{ value: 'pipe', label: '命名管道' },
|
||||
{ value: 'websocket', label: 'WebSocket' },
|
||||
],
|
||||
Mode: [
|
||||
{ value: 'agent', label: 'Agent' },
|
||||
{ value: 'chat', label: 'Chat' },
|
||||
],
|
||||
ShellType: [
|
||||
{ value: 'zsh', label: 'zsh' },
|
||||
{ value: 'bash', label: 'bash' },
|
||||
{ value: 'powershell', label: 'PowerShell' },
|
||||
{ value: 'cmd', label: 'cmd' },
|
||||
],
|
||||
SessionMode: [
|
||||
{ value: 'auto', label: '自动' },
|
||||
{ value: 'reuse', label: '复用' },
|
||||
{ value: 'fresh', label: '每次新建' },
|
||||
],
|
||||
}
|
||||
|
||||
const selectLabel = computed(() => (field) => {
|
||||
const option = selectOptions[field]?.find((item) => item.value === config.value[field])
|
||||
return option?.label || '请选择'
|
||||
})
|
||||
|
||||
function toggleSelect(field) {
|
||||
openSelect.value = openSelect.value === field ? '' : field
|
||||
}
|
||||
|
||||
function chooseOption(field, value) {
|
||||
config.value[field] = value
|
||||
openSelect.value = ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
config.value = await GetConfig()
|
||||
} catch (e) {
|
||||
emit('log', 'error', '配置加载失败:' + (e.message || String(e)))
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await UpdateConfig(config.value)
|
||||
emit('log', 'info', '配置已保存,代理已按需重启')
|
||||
emit('status-refresh')
|
||||
} catch (e) {
|
||||
emit('log', 'error', '配置保存失败:' + (e.message || String(e)))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>设置</h1>
|
||||
<p>配置监听地址、Lingma 传输方式、会话复用和请求超时。</p>
|
||||
</div>
|
||||
<button class="primary-button" type="button" :disabled="saving" @click="save">
|
||||
{{ saving ? '保存中...' : '保存并重启' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="grid-2">
|
||||
<div class="glass-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>服务监听</h2>
|
||||
<p>第三方客户端连接本地代理使用这组地址。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>主机</label>
|
||||
<input v-model="config.Host" type="text" placeholder="127.0.0.1" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>端口</label>
|
||||
<input v-model.number="config.Port" type="number" placeholder="8095" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>传输方式</label>
|
||||
<div class="custom-select" :class="{ open: openSelect === 'Transport' }">
|
||||
<button type="button" @click="toggleSelect('Transport')">
|
||||
<span>{{ selectLabel('Transport') }}</span>
|
||||
<i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div v-if="openSelect === 'Transport'" class="select-menu">
|
||||
<button
|
||||
v-for="option in selectOptions.Transport"
|
||||
:key="option.value"
|
||||
:class="{ selected: option.value === config.Transport }"
|
||||
type="button"
|
||||
@click="chooseOption('Transport', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>超时秒数</label>
|
||||
<input v-model.number="config.Timeout" type="number" min="1" />
|
||||
</div>
|
||||
<div class="field span-2">
|
||||
<label>WebSocket 地址</label>
|
||||
<input v-model="config.WebSocketURL" type="text" placeholder="留空自动探测 Lingma WebSocket" />
|
||||
</div>
|
||||
<div class="field span-2">
|
||||
<label>命名管道</label>
|
||||
<input v-model="config.Pipe" type="text" placeholder="留空自动探测 Windows Named Pipe" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-box">
|
||||
<strong>自动探测失败时</strong>
|
||||
<span>先确认 VS Code / Lingma 插件已启动并登录。macOS 通常填写 WebSocket,例如 <code>ws://127.0.0.1:36510/</code>;Windows 可填写命名管道,例如 <code>\\.\pipe\lingma-xxxx</code>,也可填写 WebSocket,例如 <code>ws://127.0.0.1:36510/</code>。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>会话与环境</h2>
|
||||
<p>影响 Lingma 会话上下文和工具执行环境。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>模式</label>
|
||||
<div class="custom-select" :class="{ open: openSelect === 'Mode' }">
|
||||
<button type="button" @click="toggleSelect('Mode')">
|
||||
<span>{{ selectLabel('Mode') }}</span>
|
||||
<i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div v-if="openSelect === 'Mode'" class="select-menu">
|
||||
<button
|
||||
v-for="option in selectOptions.Mode"
|
||||
:key="option.value"
|
||||
:class="{ selected: option.value === config.Mode }"
|
||||
type="button"
|
||||
@click="chooseOption('Mode', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Shell 类型</label>
|
||||
<div class="custom-select" :class="{ open: openSelect === 'ShellType' }">
|
||||
<button type="button" @click="toggleSelect('ShellType')">
|
||||
<span>{{ selectLabel('ShellType') }}</span>
|
||||
<i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div v-if="openSelect === 'ShellType'" class="select-menu">
|
||||
<button
|
||||
v-for="option in selectOptions.ShellType"
|
||||
:key="option.value"
|
||||
:class="{ selected: option.value === config.ShellType }"
|
||||
type="button"
|
||||
@click="chooseOption('ShellType', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>会话策略</label>
|
||||
<div class="custom-select" :class="{ open: openSelect === 'SessionMode' }">
|
||||
<button type="button" @click="toggleSelect('SessionMode')">
|
||||
<span>{{ selectLabel('SessionMode') }}</span>
|
||||
<i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div v-if="openSelect === 'SessionMode'" class="select-menu">
|
||||
<button
|
||||
v-for="option in selectOptions.SessionMode"
|
||||
:key="option.value"
|
||||
:class="{ selected: option.value === config.SessionMode }"
|
||||
type="button"
|
||||
@click="chooseOption('SessionMode', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>当前文件</label>
|
||||
<input v-model="config.CurrentFilePath" type="text" placeholder="可选" />
|
||||
</div>
|
||||
<div class="field span-2">
|
||||
<label>工作目录</label>
|
||||
<textarea v-model="config.Cwd" placeholder="Lingma 创建 session 时使用的 cwd"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
7
desktop/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type {DefineComponent} from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||