feat: add model selector, skills/memory pages, and config management

- Add model selector in sidebar that discovers models from auth.json credential pool
- Add per-session model display (badge in chat header and session list)
- Add skills browser page and memory editor page
- Add BFF routes for skills, memory, and config model management
- Model switching updates config.yaml provider field to bypass env auto-detection
- Refactor Settings page, simplify ChatInput with file upload
- Add attachment upload support via BFF /upload endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-12 23:23:50 +08:00
parent ee9f56dfbd
commit 5887462f7d
21 changed files with 1941 additions and 106 deletions
+322
View File
@@ -0,0 +1,322 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { NButton, useMessage } from 'naive-ui'
import MarkdownRenderer from '@/components/chat/MarkdownRenderer.vue'
import { fetchMemory, saveMemory, type MemoryData } from '@/api/skills'
const message = useMessage()
const loading = ref(false)
const data = ref<MemoryData | null>(null)
const editingSection = ref<'memory' | 'user' | null>(null)
const editContent = ref('')
const saving = ref(false)
onMounted(loadMemory)
async function loadMemory() {
loading.value = true
try {
data.value = await fetchMemory()
} catch (err: any) {
console.error('Failed to load memory:', err)
message.error('Failed to load memory')
} finally {
loading.value = false
}
}
function startEdit(section: 'memory' | 'user') {
editingSection.value = section
editContent.value = data.value?.[section] || ''
}
function cancelEdit() {
editingSection.value = null
editContent.value = ''
}
async function handleSave() {
if (!editingSection.value) return
saving.value = true
try {
await saveMemory(editingSection.value, editContent.value)
await loadMemory()
editingSection.value = null
editContent.value = ''
message.success('Saved')
} catch (err: any) {
message.error(`Save failed: ${err.message}`)
} finally {
saving.value = false
}
}
function formatTime(ts: number | null): string {
if (!ts) return ''
return new Date(ts).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const memoryEmpty = computed(() => !data.value?.memory?.trim())
const userEmpty = computed(() => !data.value?.user?.trim())
const displayMemory = computed(() => (data.value?.memory || '').replace(/§/g, '\n\n'))
const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n'))
</script>
<template>
<div class="memory-view">
<header class="memory-header">
<h2 class="header-title">Memory</h2>
<NButton size="small" quaternary @click="loadMemory">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</template>
Refresh
</NButton>
</header>
<div class="memory-content">
<div v-if="loading && !data" class="memory-loading">Loading...</div>
<div v-else class="memory-sections">
<!-- My Notes -->
<div class="memory-section">
<div class="section-header">
<div class="section-title-row">
<span class="section-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</span>
<span class="section-title">My Notes</span>
<span v-if="data?.memory_mtime" class="section-mtime">{{ formatTime(data.memory_mtime) }}</span>
</div>
<NButton v-if="editingSection !== 'memory'" size="tiny" quaternary @click="startEdit('memory')">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</template>
Edit
</NButton>
</div>
<!-- View mode -->
<div v-if="editingSection !== 'memory'" class="section-body">
<MarkdownRenderer v-if="!memoryEmpty" :content="displayMemory" />
<p v-else class="empty-text">No notes yet.</p>
</div>
<!-- Edit mode -->
<div v-else class="section-edit">
<textarea
v-model="editContent"
class="edit-textarea"
placeholder="Write your notes..."
spellcheck="false"
></textarea>
<div class="edit-actions">
<NButton size="small" @click="cancelEdit">Cancel</NButton>
<NButton size="small" type="primary" :loading="saving" @click="handleSave">Save</NButton>
</div>
</div>
</div>
<!-- User Profile -->
<div class="memory-section">
<div class="section-header">
<div class="section-title-row">
<span class="section-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
<span class="section-title">User Profile</span>
<span v-if="data?.user_mtime" class="section-mtime">{{ formatTime(data.user_mtime) }}</span>
</div>
<NButton v-if="editingSection !== 'user'" size="tiny" quaternary @click="startEdit('user')">
<template #icon>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</template>
Edit
</NButton>
</div>
<!-- View mode -->
<div v-if="editingSection !== 'user'" class="section-body">
<MarkdownRenderer v-if="!userEmpty" :content="displayUser" />
<p v-else class="empty-text">No profile yet.</p>
</div>
<!-- Edit mode -->
<div v-else class="section-edit">
<textarea
v-model="editContent"
class="edit-textarea"
placeholder="Write your profile..."
spellcheck="false"
></textarea>
<div class="edit-actions">
<NButton size="small" @click="cancelEdit">Cancel</NButton>
<NButton size="small" type="primary" :loading="saving" @click="handleSave">Save</NButton>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.memory-view {
height: 100vh;
display: flex;
flex-direction: column;
}
.memory-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: $text-primary;
}
.memory-content {
flex: 1;
overflow: hidden;
padding: 20px;
display: flex;
flex-direction: column;
}
.memory-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: $text-muted;
}
.memory-sections {
display: flex;
gap: 16px;
flex: 1;
min-height: 0;
}
.memory-section {
flex: 1;
min-height: 0;
border: 1px solid $border-color;
border-radius: $radius-md;
overflow: hidden;
display: flex;
flex-direction: column;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: $bg-secondary;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
.section-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.section-icon {
color: $text-secondary;
display: flex;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: $text-primary;
}
.section-mtime {
font-size: 11px;
color: $text-muted;
}
.section-body {
flex: 1;
overflow-y: auto;
padding: 16px;
min-height: 0;
}
.empty-text {
color: $text-muted;
font-style: italic;
font-size: 13px;
}
.section-edit {
flex: 1;
display: flex;
flex-direction: column;
padding: 12px 16px;
min-height: 0;
}
.edit-textarea {
flex: 1;
width: 100%;
min-height: 0;
padding: 12px;
border: 1px solid $border-color;
border-radius: $radius-sm;
background: $bg-input;
color: $text-primary;
font-family: $font-code;
font-size: 13px;
line-height: 1.6;
resize: none;
outline: none;
&:focus {
border-color: $accent-primary;
}
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
</style>