feat: Add database table for model context length configuration (#477)

* feat: add database table for model context length configuration

- Add model_context table with provider/model/context_limit fields
- Implement UPSERT endpoint for model context configuration
- Add priority lookup: database > config.yaml > custom_providers > cache
- Add frontend click-to-edit UI in ChatInput with tooltip
- Add i18n support for context editing dialog (all 8 locales)
- Use context_limit field consistently across frontend and backend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: use useMessage() composable instead of window.$message in ChatInput

- Remove incorrect NMessage import (not a component)
- Use useMessage() composable from naive-ui
- Replace window.$message?.xxx() with message.xxx()
- Fixes TypeScript build errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-06 15:05:44 +08:00
committed by GitHub
parent f338aeea18
commit 479e1feef6
14 changed files with 406 additions and 3 deletions
@@ -0,0 +1,41 @@
import { request } from '../client'
export interface ModelContext {
id: number
provider: string
model: string
context_limit: number
}
/**
* 根据 provider 和 model 查询模型上下文配置
*/
export async function getModelContext(provider: string, model: string): Promise<ModelContext | null> {
try {
const res = await request<{ data: ModelContext }>(
`/api/hermes/model-context?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(model)}`
)
return res.data
} catch (err: any) {
if (err.status === 404) return null
throw err
}
}
/**
* 设置模型上下文配置(UPSERT:存在则更新,不存在则插入)
*/
export async function setModelContext(
provider: string,
model: string,
contextLimit: number
): Promise<ModelContext> {
const res = await request<{ success: boolean; data: ModelContext }>(
`/api/hermes/model-context/${encodeURIComponent(provider)}/${encodeURIComponent(model)}`,
{
method: 'PUT',
body: JSON.stringify({ provider, model, context_limit: contextLimit }),
}
)
return res.data
}
@@ -4,12 +4,14 @@ import { useChatStore } from '@/stores/hermes/chat'
import { useAppStore } from '@/stores/hermes/app'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { fetchContextLength } from '@/api/hermes/sessions'
import { NButton, NTooltip, NSwitch } from 'naive-ui'
import { setModelContext } from '@/api/hermes/model-context'
import { NButton, NTooltip, NSwitch, NModal, NInputNumber, useMessage } from 'naive-ui'
import { computed, ref, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const chatStore = useChatStore()
const { t } = useI18n()
const message = useMessage()
const inputText = ref('')
const textareaRef = ref<HTMLTextAreaElement>()
const fileInputRef = ref<HTMLInputElement>()
@@ -45,6 +47,44 @@ const canSend = computed(() => inputText.value.trim() || attachments.value.lengt
const contextLength = ref(200000)
const FALLBACK_CONTEXT = 200000
// Context length editing
const showContextEditModal = ref(false)
const editingContextLimit = ref(200000)
const isSavingContextLimit = ref(false)
async function handleEditContextLimit() {
editingContextLimit.value = contextLength.value
showContextEditModal.value = true
}
async function saveContextLimit() {
if (!editingContextLimit.value || editingContextLimit.value <= 0) {
message.error(t('chat.contextEditInvalid'))
return
}
isSavingContextLimit.value = true
try {
const appStore = useAppStore()
const provider = appStore.selectedProvider || ''
const model = appStore.selectedModel || ''
if (!provider || !model) {
message.error(t('chat.contextEditFailed'))
return
}
await setModelContext(provider, model, editingContextLimit.value)
contextLength.value = editingContextLimit.value
showContextEditModal.value = false
message.success(t('chat.contextEditSuccess'))
} catch (err: any) {
message.error(`${t('chat.contextEditFailed')}: ${err.message || ''}`)
} finally {
isSavingContextLimit.value = false
}
}
async function loadContextLength() {
try {
const profile = useProfilesStore().activeProfileName || undefined
@@ -247,7 +287,16 @@ function isImage(type: string): boolean {
</div>
<span v-if="totalTokens > 0" class="context-info" :class="{ 'context-warning': usagePercent > 80 }">
{{ formatTokens(totalTokens) }} / {{ formatTokens(contextLength) }} · {{ t('chat.contextRemaining') }} {{ formatTokens(remainingTokens) }}
{{ formatTokens(totalTokens) }} /
<NTooltip trigger="hover">
<template #trigger>
<span class="context-limit-editable" @click="handleEditContextLimit">
{{ formatTokens(contextLength) }}
</span>
</template>
<span>{{ t('chat.contextClickToEdit') }}</span>
</NTooltip>
· {{ t('chat.contextRemaining') }} {{ formatTokens(remainingTokens) }}
</span>
<div v-if="totalTokens > 0" class="context-bar">
<div
@@ -335,6 +384,47 @@ function isImage(type: string): boolean {
</NButton>
</div>
</div>
<!-- Context Length Edit Modal -->
<NModal
v-model:show="showContextEditModal"
:title="t('chat.contextEditTitle')"
:mask-closable="true"
preset="card"
style="width: 400px"
>
<div class="context-edit-content">
<p style="margin-bottom: 16px; color: #666;">
{{ t('chat.contextEditDesc') }}
</p>
<NInputNumber
v-model:value="editingContextLimit"
:min="1000"
:max="10000000"
:step="1000"
:show-button="false"
:placeholder="t('chat.contextEditPlaceholder')"
style="width: 100%"
>
<template #suffix>
<span style="color: #999;">tokens</span>
</template>
</NInputNumber>
<div style="margin-top: 12px; font-size: 12px; color: #999;">
{{ t('chat.contextEditHint') }}
</div>
</div>
<template #footer>
<div style="display: flex; justify-content: flex-end; gap: 8px;">
<NButton @click="showContextEditModal = false" :disabled="isSavingContextLimit">
{{ t('chat.contextEditCancel') }}
</NButton>
<NButton type="primary" @click="saveContextLimit" :loading="isSavingContextLimit">
{{ t('chat.contextEditSave') }}
</NButton>
</div>
</template>
</NModal>
</div>
</template>
@@ -383,6 +473,19 @@ function isImage(type: string): boolean {
}
}
.context-limit-editable {
cursor: pointer;
border-bottom: 1px dashed transparent;
transition: all 0.2s ease;
padding: 0 2px;
&:hover {
border-bottom-color: $text-muted;
background: rgba(128, 128, 128, 0.1);
border-radius: 2px;
}
}
.context-bar {
width: 60px;
height: 4px;
+10
View File
@@ -104,6 +104,16 @@ export default {
// Chat
chat: {
contextRemaining: 'übrig',
contextClickToEdit: 'Klicken zum Bearbeiten der Kontextlänge',
contextEditTitle: 'Kontextlänge bearbeiten',
contextEditDesc: 'Kontextlängenlimit für aktuelles Modell festlegen (in Tokens)',
contextEditPlaceholder: 'Kontextlänge eingeben',
contextEditHint: 'Häufige Werte: 200k (Claude), 128k (GPT-4), 32k (GPT-3.5)',
contextEditSave: 'Speichern',
contextEditCancel: 'Abbrechen',
contextEditInvalid: 'Bitte geben Sie eine gültige Kontextlänge ein',
contextEditSuccess: 'Kontextlänge aktualisiert',
contextEditFailed: 'Aktualisierung fehlgeschlagen',
emptyState: 'Starten Sie eine Konversation mit Hermes Agent',
inputPlaceholder: 'Nachricht eingeben... (Enter zum Senden, Shift+Enter fur neue Zeile)',
attachFiles: 'Dateien anhangen',
+10
View File
@@ -114,6 +114,16 @@ export default {
// Chat
chat: {
contextRemaining: 'remaining',
contextClickToEdit: 'Click to edit context length',
contextEditTitle: 'Edit Context Length',
contextEditDesc: 'Set context length limit for current model (in tokens)',
contextEditPlaceholder: 'Enter context length',
contextEditHint: 'Common values: 200k (Claude), 128k (GPT-4), 32k (GPT-3.5)',
contextEditSave: 'Save',
contextEditCancel: 'Cancel',
contextEditInvalid: 'Please enter a valid context length',
contextEditSuccess: 'Context length updated',
contextEditFailed: 'Update failed',
emptyState: 'Start a conversation with Hermes Agent',
inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)',
attachFiles: 'Attach files',
+10
View File
@@ -104,6 +104,16 @@ export default {
// Chat
chat: {
contextRemaining: 'restante',
contextClickToEdit: 'Haz clic para editar la longitud del contexto',
contextEditTitle: 'Editar longitud del contexto',
contextEditDesc: 'Establecer el límite de longitud del contexto para el modelo actual (en tokens)',
contextEditPlaceholder: 'Ingresa la longitud del contexto',
contextEditHint: 'Valores comunes: 200k (Claude), 128k (GPT-4), 32k (GPT-3.5)',
contextEditSave: 'Guardar',
contextEditCancel: 'Cancelar',
contextEditInvalid: 'Por favor ingresa una longitud de contexto válida',
contextEditSuccess: 'Longitud del contexto actualizada',
contextEditFailed: 'Error en la actualización',
emptyState: 'Inicia una conversacion con Hermes Agent',
inputPlaceholder: 'Escribe un mensaje... (Enter para enviar, Shift+Enter para nueva linea)',
attachFiles: 'Adjuntar archivos',
+10
View File
@@ -104,6 +104,16 @@ export default {
// Chat
chat: {
contextRemaining: 'restant',
contextClickToEdit: 'Cliquez pour modifier la longueur du contexte',
contextEditTitle: 'Modifier la longueur du contexte',
contextEditDesc: 'Définir la limite de longueur du contexte pour le modèle actuel (en tokens)',
contextEditPlaceholder: 'Entrez la longueur du contexte',
contextEditHint: 'Valeurs courantes : 200k (Claude), 128k (GPT-4), 32k (GPT-3.5)',
contextEditSave: 'Enregistrer',
contextEditCancel: 'Annuler',
contextEditInvalid: 'Veuillez entrer une longueur de contexte valide',
contextEditSuccess: 'Longueur du contexte mise à jour',
contextEditFailed: 'Échec de la mise à jour',
emptyState: 'Demarrer une conversation avec Hermes Agent',
inputPlaceholder: 'Tapez un message... (Entree pour envoyer, Shift+Entree pour un saut de ligne)',
attachFiles: 'Joindre des fichiers',
+10
View File
@@ -104,6 +104,16 @@ export default {
// チャット
chat: {
contextRemaining: '残り',
contextClickToEdit: 'クリックしてコンテキスト長を編集',
contextEditTitle: 'コンテキスト長を編集',
contextEditDesc: '現在のモデルのコンテキスト長制限を設定(トークン数)',
contextEditPlaceholder: 'コンテキスト長を入力',
contextEditHint: '一般的な値:200k (Claude), 128k (GPT-4), 32k (GPT-3.5)',
contextEditSave: '保存',
contextEditCancel: 'キャンセル',
contextEditInvalid: '有効なコンテキスト長を入力してください',
contextEditSuccess: 'コンテキスト長を更新しました',
contextEditFailed: '更新に失敗しました',
emptyState: 'Hermes Agent と会話を開始しましょう',
inputPlaceholder: 'メッセージを入力... (Enter で送信、Shift+Enter で改行)',
attachFiles: 'ファイルを添付',
+10
View File
@@ -104,6 +104,16 @@ export default {
// 채팅
chat: {
contextRemaining: '남음',
contextClickToEdit: '클릭하여 컨텍스트 길이 편집',
contextEditTitle: '컨텍스트 길이 편집',
contextEditDesc: '현재 모델의 컨텍스트 길이 제한 설정 (토큰 수)',
contextEditPlaceholder: '컨텍스트 길이 입력',
contextEditHint: '일반적인 값: 200k (Claude), 128k (GPT-4), 32k (GPT-3.5)',
contextEditSave: '저장',
contextEditCancel: '취소',
contextEditInvalid: '유효한 컨텍스트 길이를 입력하세요',
contextEditSuccess: '컨텍스트 길이가 업데이트되었습니다',
contextEditFailed: '업데이트 실패',
emptyState: 'Hermes Agent와 대화를 시작하세요',
inputPlaceholder: '메시지를 입력하세요... (Enter로 전송, Shift+Enter로 줄바꿈)',
attachFiles: '파일 첨부',
+10
View File
@@ -104,6 +104,16 @@ export default {
// Chat
chat: {
contextRemaining: 'restante',
contextClickToEdit: 'Clique para editar o tamanho do contexto',
contextEditTitle: 'Editar tamanho do contexto',
contextEditDesc: 'Definir o limite de tamanho do contexto para o modelo atual (em tokens)',
contextEditPlaceholder: 'Digite o tamanho do contexto',
contextEditHint: 'Valores comuns: 200k (Claude), 128k (GPT-4), 32k (GPT-3.5)',
contextEditSave: 'Salvar',
contextEditCancel: 'Cancelar',
contextEditInvalid: 'Por favor, insira um tamanho de contexto válido',
contextEditSuccess: 'Tamanho do contexto atualizado',
contextEditFailed: 'Falha na atualização',
emptyState: 'Inicie uma conversa com o Hermes Agent',
inputPlaceholder: 'Digite uma mensagem... (Enter para enviar, Shift+Enter para nova linha)',
attachFiles: 'Anexar arquivos',
+10
View File
@@ -114,6 +114,16 @@ export default {
// 对话
chat: {
contextRemaining: '剩余',
contextClickToEdit: '点击编辑上下文长度',
contextEditTitle: '编辑上下文长度',
contextEditDesc: '设置当前模型的上下文长度限制(token 数量)',
contextEditPlaceholder: '请输入上下文长度',
contextEditHint: '常见值:200k (Claude), 128k (GPT-4), 32k (GPT-3.5)',
contextEditSave: '保存',
contextEditCancel: '取消',
contextEditInvalid: '请输入有效的上下文长度',
contextEditSuccess: '上下文长度已更新',
contextEditFailed: '更新失败',
emptyState: '开始与 Hermes Agent 对话',
inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)',
attachFiles: '添加附件',
@@ -5,6 +5,8 @@ import { readConfigYaml, writeConfigYaml, fetchProviderModels, buildModelGroups,
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models'
import { readAppConfig } from '../../services/app-config'
import { getDb } from '../../db'
import { MODEL_CONTEXT_TABLE } from '../../db/hermes/schemas'
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
@@ -236,3 +238,126 @@ export async function setConfigModel(ctx: any) {
ctx.body = { error: err.message }
}
}
/**
* 设置模型上下文配置(UPSERT:存在则更新,不存在则插入)
* 支持路径参数和查询参数两种方式
*/
export async function updateModelContext(ctx: any) {
// 支持两种方式:
// 1. 路径参数: /api/hermes/model-context/:provider/:model
// 2. 查询参数: /api/hermes/model-context?provider=xxx&model=xxx
let provider: string | undefined
let model: string | undefined
// 优先从路径参数获取
if (ctx.params.provider && ctx.params.model) {
provider = ctx.params.provider
model = ctx.params.model
} else {
// 从查询参数获取
const query = ctx.query as { provider?: string; model?: string }
provider = query.provider
model = query.model
}
// 如果没有参数,从请求体获取
if (!provider || !model) {
const body = ctx.request.body as { provider?: string; model?: string; context_limit?: number }
provider = body.provider
model = body.model
}
const { context_limit } = ctx.request.body as { context_limit: number }
if (!provider || !model || !context_limit) {
ctx.status = 400
ctx.body = { error: 'Missing required fields: provider, model, context_limit' }
return
}
if (typeof context_limit !== 'number' || context_limit <= 0) {
ctx.status = 400
ctx.body = { error: 'Context limit must be a positive number' }
return
}
try {
const db = getDb()
if (!db) {
ctx.status = 500
ctx.body = { error: 'Database not available' }
return
}
// 使用 REPLACE 实现 UPSERT:存在则替换,不存在则插入
db.prepare(
`REPLACE INTO ${MODEL_CONTEXT_TABLE} (provider, model, context_limit) VALUES (?, ?, ?)`
).run(provider, model, context_limit)
// 查询并返回更新后的数据
const row = db.prepare(
`SELECT id, provider, model, context_limit FROM ${MODEL_CONTEXT_TABLE} WHERE provider = ? AND model = ?`
).get(provider, model) as { id: number; provider: string; model: string; context_limit: number }
ctx.body = {
success: true,
data: row
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
/**
* 查询模型上下文配置
*/
export async function getModelContext(ctx: any) {
// 支持两种方式:
// 1. 路径参数: /api/hermes/model-context/:provider/:model
// 2. 查询参数: /api/hermes/model-context?provider=xxx&model=xxx
let provider: string | undefined
let model: string | undefined
// 优先从路径参数获取
if (ctx.params.provider && ctx.params.model) {
provider = ctx.params.provider
model = ctx.params.model
} else {
// 从查询参数获取
const query = ctx.query as { provider?: string; model?: string }
provider = query.provider
model = query.model
}
if (!provider || !model) {
ctx.status = 400
ctx.body = { error: 'Missing provider or model parameter' }
return
}
try {
const db = getDb()
if (!db) {
ctx.status = 500
ctx.body = { error: 'Database not available' }
return
}
const row = db.prepare(
`SELECT id, provider, model, context_limit FROM ${MODEL_CONTEXT_TABLE} WHERE provider = ? AND model = ?`
).get(provider, model) as { id: number; provider: string; model: string; context_limit: number } | undefined
if (!row) {
ctx.status = 404
ctx.body = { error: 'Model context not found' }
return
}
ctx.body = { data: { ...row, limit: row.context_limit } }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
+22
View File
@@ -89,6 +89,21 @@ export const COMPRESSION_SNAPSHOT_SCHEMA: Record<string, string> = {
updated_at: 'INTEGER NOT NULL',
}
// ============================================================================
// Model Context (model-context.ts)
// ============================================================================
export const MODEL_CONTEXT_TABLE = 'model_context'
export const MODEL_CONTEXT_SCHEMA: Record<string, string> = {
id: 'INTEGER PRIMARY KEY AUTOINCREMENT',
provider: 'TEXT NOT NULL',
model: 'TEXT NOT NULL',
context_limit: 'INTEGER NOT NULL',
}
export const MODEL_CONTEXT_INDEX = 'CREATE UNIQUE INDEX IF NOT EXISTS idx_model_context_provider_model ON model_context(provider, model)'
// ============================================================================
// Group Chat (services/hermes/group-chat/index.ts)
// ============================================================================
@@ -469,6 +484,13 @@ export function initAllHermesTables(retryCount = 0): void {
// Compression snapshot
syncTable(COMPRESSION_SNAPSHOT_TABLE, COMPRESSION_SNAPSHOT_SCHEMA)
// Model context
syncTable(MODEL_CONTEXT_TABLE, MODEL_CONTEXT_SCHEMA, {
indexes: {
idx_model_context_provider_model: MODEL_CONTEXT_INDEX,
}
})
// Group chat - basic tables
syncTable(GC_ROOMS_TABLE, GC_ROOMS_SCHEMA)
syncTable(GC_MESSAGES_TABLE, GC_MESSAGES_SCHEMA)
@@ -6,3 +6,9 @@ export const modelRoutes = new Router()
modelRoutes.get('/api/hermes/available-models', ctrl.getAvailable)
modelRoutes.get('/api/hermes/config/models', ctrl.getConfigModels)
modelRoutes.put('/api/hermes/config/model', ctrl.setConfigModel)
// Model context routes
modelRoutes.get('/api/hermes/model-context', ctrl.getModelContext)
modelRoutes.get('/api/hermes/model-context/:provider/:model', ctrl.getModelContext)
modelRoutes.put('/api/hermes/model-context/:provider/:model', ctrl.updateModelContext)
modelRoutes.put('/api/hermes/model-context', ctrl.updateModelContext)
@@ -2,6 +2,8 @@ import { resolve, join } from 'path'
import { homedir } from 'os'
import { readFileSync, existsSync, statSync } from 'fs'
import yaml from 'js-yaml'
import { getDb } from '../../db'
import { MODEL_CONTEXT_TABLE } from '../../db/hermes/schemas'
const HERMES_BASE = resolve(homedir(), '.hermes')
const MODELS_DEV_CACHE = resolve(HERMES_BASE, 'models_dev_cache.json')
@@ -234,6 +236,25 @@ function lookupContextFromCache(modelName: string, provider: string | null): num
* 3. models_dev_cache.json, scoped to model.provider when configured
* 4. DEFAULT_CONTEXT_LENGTH (200K hardcoded fallback)
*/
/**
* 从数据库 model_context 表查找上下文长度(最高优先级)
*/
function lookupContextFromDatabase(modelName: string, provider: string | null): number | null {
const db = getDb()
if (!db) return null
try {
// 尝试精确匹配 provider 和 model
const row = db
.prepare(`SELECT context_limit FROM ${MODEL_CONTEXT_TABLE} WHERE provider = ? AND model = ?`)
.get(provider || 'default', modelName) as { context_limit: number } | undefined
return row?.context_limit || null
} catch {
return null
}
}
export function getModelContextLength(profile?: string): number {
const profileDir = getProfileDir(profile)
const config = loadConfig(profileDir)
@@ -242,12 +263,17 @@ export function getModelContextLength(profile?: string): number {
const model = getDefaultModel(config)
if (!model) return DEFAULT_CONTEXT_LENGTH
const provider = getDefaultProvider(config)
// 0. Database model_context table (highest priority)
const dbCtx = lookupContextFromDatabase(model, provider)
if (dbCtx && dbCtx > 0) return dbCtx
// 1. Global context_length override in config.yaml
const configCtx = getConfigContextLength(config)
if (configCtx && configCtx > 0) return configCtx
// 2. Custom provider context_length
const provider = getDefaultProvider(config)
const customCtx = lookupCustomProviderContextLength(config, model, provider)
if (customCtx && customCtx > 0) return customCtx