{{ item.id }}:{{ t('启动恢复未完成,请检查包文件并重新安装;原授权不会自动用于变更后的包。', 'Startup recovery failed. Check and reinstall the package; previous grants are not applied to changed packages.') }}
+
+
diff --git a/frontend/src/components/common/MarkdownContent.vue b/frontend/src/components/common/MarkdownContent.vue
index dd11226..ebb1576 100644
--- a/frontend/src/components/common/MarkdownContent.vue
+++ b/frontend/src/components/common/MarkdownContent.vue
@@ -1,4 +1,5 @@
-
+
diff --git a/frontend/src/features/chat/ChatView.spec.ts b/frontend/src/features/chat/ChatView.spec.ts
index be8001f..dfaa01f 100644
--- a/frontend/src/features/chat/ChatView.spec.ts
+++ b/frontend/src/features/chat/ChatView.spec.ts
@@ -29,12 +29,23 @@ beforeEach(() => {
vi.spyOn(useSkillStore(), 'loadSkills').mockResolvedValue(undefined)
})
+it('reuses the settings model cache and renders the shared select style', async () => {
+ const providers = useProviderStore()
+ providers.modelsByProvider.a = [{model_id:'a-default',name:'A model',capabilities:{chat:true}}]
+ const wrapper = mount(ChatView)
+ await flushPromises()
+ expect(providers.loadModels).not.toHaveBeenCalled()
+ expect(wrapper.get('select#chat-model-select').classes()).toContain('select')
+ expect(wrapper.get('select#chat-model-select').text()).toContain('A model')
+ wrapper.unmount()
+})
+
it('preserves the selected provider and manual model after leaving and returning to chat', async () => {
const chat = useChatStore()
const first = mount(ChatView)
await flushPromises()
await first.get('select').setValue('b')
- await first.get('input[list="chat-models"]').setValue('b-manual')
+ await first.get('input[data-field="manual-model"]').setValue('b-manual')
first.unmount()
const returned = mount(ChatView)
await flushPromises()
@@ -91,3 +102,21 @@ it.each(['providers', 'skills'])('ignores initialization after unmount while %s
expect(returned.get('button.button-primary').attributes('disabled')).toBeUndefined()
returned.unmount()
})
+
+
+it('sends on Enter but preserves Shift+Enter and IME confirmation', async () => {
+ const chat = useChatStore()
+ const send = vi.spyOn(chat, 'sendMessage').mockResolvedValue(undefined)
+ const wrapper = mount(ChatView)
+ await flushPromises()
+ const input = wrapper.get('textarea')
+ await input.setValue('问题')
+ await input.trigger('keydown', { key: 'Enter', isComposing: true })
+ await input.trigger('keydown', { key: 'Enter', shiftKey: true })
+ expect(send).not.toHaveBeenCalled()
+ await input.trigger('keydown', { key: 'Enter' })
+ expect(send).toHaveBeenCalledWith('问题')
+ await input.trigger('keydown', { key: 'Enter', repeat: true })
+ expect(send).toHaveBeenCalledTimes(1)
+ wrapper.unmount()
+})
diff --git a/frontend/src/features/chat/ChatView.vue b/frontend/src/features/chat/ChatView.vue
index e0948a4..fd8beaa 100644
--- a/frontend/src/features/chat/ChatView.vue
+++ b/frontend/src/features/chat/ChatView.vue
@@ -7,8 +7,12 @@ import { useSkillStore } from '@/stores/skill'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import { useCitationNavigation } from '@/composables/useCitationNavigation'
import { t } from '@/i18n'
+import ChatPersonaDialog from './ChatPersonaDialog.vue'
+import { useChatPreferences } from '@/stores/chatPreferences'
const chatStore = useChatStore()
+const preferences = useChatPreferences()
+const showPersona = ref(false)
const providerStore = useProviderStore()
const skillStore = useSkillStore()
const { openCitation } = useCitationNavigation()
@@ -36,7 +40,7 @@ onMounted(async () => {
async function refreshModels(providerId: string) {
loadError.value = ''
- if (!providerId) return
+ if (!providerId || providerStore.modelsByProvider[providerId] !== undefined) return
try { await providerStore.loadModels(providerId) }
catch (error) { if (!disposed && chatStore.selectedProviderId === providerId) loadError.value = error instanceof Error ? error.message : t('模型列表加载失败,请手动填写模型 ID。', 'Unable to load models. Enter a model ID manually.') }
}
@@ -47,6 +51,11 @@ watch(() => chatStore.selectedProviderId, async (providerId) => {
})
function send() { void chatStore.sendMessage(chatStore.inputText) }
+function composerKeydown(event: KeyboardEvent) {
+ if (event.key !== 'Enter' || event.shiftKey || event.isComposing || event.keyCode === 229) return
+ event.preventDefault()
+ if (!event.repeat) send()
+}
async function openCitationCard(citation: Citation) {
loadError.value = ''
@@ -64,17 +73,25 @@ async function openCitationCard(citation: Citation) {
-
+
+
+
+
+
{{ t('开启后,将相关笔记片段发送给所选模型,并显示来源。技能调用请使用智能体。', 'When enabled, relevant note excerpts are sent to the selected model and citations are shown. Use Agent for skills.') }}
+
+
+
+
+
+
diff --git a/frontend/src/features/settings/ProviderForm.spec.ts b/frontend/src/features/settings/ProviderForm.spec.ts
index 119a3eb..1258e13 100644
--- a/frontend/src/features/settings/ProviderForm.spec.ts
+++ b/frontend/src/features/settings/ProviderForm.spec.ts
@@ -33,6 +33,34 @@ beforeEach(() => {
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
describe('ProviderForm', () => {
+ it('uses a top-layer dialog and restores the underlying scroll container', async () => {
+ const host = document.createElement('div')
+ host.style.overflow = 'auto'
+ document.body.appendChild(host)
+ const wrapper = mount(ProviderForm, {attachTo:host})
+ await flushPromises()
+ expect(wrapper.get('dialog').element.open).toBe(true)
+ expect(host.style.overflow).toBe('hidden')
+ wrapper.unmount()
+ expect(host.style.overflow).toBe('auto')
+ host.remove()
+ })
+ it('saves model-scoped context settings and restores them on edit', async () => {
+ const policy = {model:'old-model',context_window:65536,output_reserve:8192,threshold:0.8,mode:'detect' as const,prompt:'保留已确认事实'}
+ const wrapper = await render({...existing,context_policies:[policy]})
+ expect(wrapper.get('textarea').element.value).toBe(policy.prompt)
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ expect(service.updateProvider).toHaveBeenCalledWith('p1', expect.objectContaining({context_policies:[policy]}))
+ })
+
+ it('does not reuse another endpoint context settings', async () => {
+ const wrapper = await render({...existing,context_policies:[{model:'old-model',context_window:65536,output_reserve:8192,threshold:0.8,mode:'detect',prompt:'摘要'}]})
+ await wrapper.get('[data-field="base-url"]').setValue('https://new.example.test/v1')
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ expect(service.updateProvider).toHaveBeenCalledWith('p1', expect.objectContaining({context_policies:[]}))
+ })
it('invalidates a pending inference result when JSON becomes invalid', async () => {
const wrapper = await render(existing)
let finish!: (value: {message: string}) => void
diff --git a/frontend/src/features/settings/ProviderForm.vue b/frontend/src/features/settings/ProviderForm.vue
index 5f600a2..4118694 100644
--- a/frontend/src/features/settings/ProviderForm.vue
+++ b/frontend/src/features/settings/ProviderForm.vue
@@ -4,6 +4,8 @@ import type { ModelInfo, ProviderConfig, ProviderPreset, ProviderType, RequestOv
import * as service from '@/services/providerService'
import ProviderPresetSelector from './ProviderPresetSelector.vue'
import RequestJsonEditor from './RequestJsonEditor.vue'
+import ProviderContextSettings from './ProviderContextSettings.vue'
+import type { ModelContextPolicy } from '@/contracts'
import { apiClient } from '@/services/apiClient'
import { t } from '@/i18n'
@@ -26,6 +28,7 @@ const presetsError = ref('')
const saving = ref(false)
const error = ref('')
const requestOverrides = ref(JSON.parse(JSON.stringify(props.provider?.request_overrides || [])))
+const contextPolicies = ref(JSON.parse(JSON.stringify(props.provider?.context_policies || [])))
const requestJsonValid = ref(true)
const requestPreview = ref('')
const probeResult = ref('')
@@ -33,7 +36,7 @@ const probing = ref(false)
const previewCapability = ref('chat')
const previewStream = ref(true)
let draftGeneration = 0
-watch([form, requestOverrides, requestJsonValid, apiKey, previewStream, previewCapability], () => { draftGeneration++; requestPreview.value = ''; probeResult.value = '' }, {deep:true, flush:'sync'})
+watch([form, contextPolicies, requestOverrides, requestJsonValid, apiKey, previewStream, previewCapability], () => { draftGeneration++; requestPreview.value = ''; probeResult.value = '' }, {deep:true, flush:'sync'})
async function previewRequest() {
const generation = draftGeneration
error.value = ''
@@ -41,7 +44,7 @@ async function previewRequest() {
if (!requestJsonValid.value) throw new Error(t('请先修正 JSON。', 'Fix the JSON first.'))
const response = await apiClient.post<{body:Record}>('/api/providers/request-preview', {
provider: {provider_type:form.provider_type,name:form.name || t('预览', 'Preview'),base_url:form.base_url || null,
- default_model:form.default_model || null,request_overrides:requestOverrides.value}, stream:previewStream.value, capability:previewCapability.value,
+ default_model:form.default_model || null,context_policies:JSON.parse(JSON.stringify(contextPolicies.value)),request_overrides:requestOverrides.value}, stream:previewStream.value, capability:previewCapability.value,
})
if (active && generation === draftGeneration) requestPreview.value = JSON.stringify(response.body, null, 2)
} catch(e) { if (active && generation === draftGeneration) error.value = (e as Error).message }
@@ -55,7 +58,7 @@ async function probeRequest() {
if (apiKey.value.trim()) throw new Error(t('请先保存新的 API Key,再进行推理验证。', 'Save the new API key before testing inference.'))
const result = await apiClient.post<{message:string}>('/api/providers/request-probe', {
provider: {provider_type:form.provider_type,name:form.name || t('推理验证', 'Inference test'),base_url:form.base_url || null,
- default_model:form.default_model || null,request_overrides:JSON.parse(JSON.stringify(requestOverrides.value)),
+ default_model:form.default_model || null,context_policies:JSON.parse(JSON.stringify(contextPolicies.value)),request_overrides:JSON.parse(JSON.stringify(requestOverrides.value)),
credential_id:configured.value ? credentialId.value : null}, stream:previewStream.value,
})
if (active && generation === draftGeneration) probeResult.value = result.message
@@ -64,6 +67,8 @@ async function probeRequest() {
}
const contextChanged = ref(false)
const dialog = ref()
+const backdrop = ref()
+const scrollLocks: Array<{element: HTMLElement; overflow: string}> = []
const previousFocus = document.activeElement as HTMLElement | null
let active = true
let credentialGeneration = 0
@@ -81,6 +86,11 @@ async function loadPresets() {
}
onMounted(async () => {
+ backdrop.value?.showModal()
+ for (let element = backdrop.value?.parentElement; element; element = element.parentElement) {
+ scrollLocks.push({element, overflow: element.style.overflow})
+ element.style.overflow = 'hidden'
+ }
void loadPresets()
if (props.provider?.credential_id) {
const generation = credentialGeneration
@@ -106,6 +116,7 @@ function detachCredential() {
credentialLoading.value = false
credentialError.value = ''
form.default_model = ''
+ contextPolicies.value = []
contextChanged.value = true
error.value = ''
}
@@ -130,6 +141,8 @@ function close() {
}
onBeforeUnmount(() => {
+ backdrop.value?.close()
+ for (const lock of scrollLocks) lock.element.style.overflow = lock.overflow
active = false
apiKey.value = ''
previousFocus?.focus()
@@ -138,7 +151,7 @@ onBeforeUnmount(() => {
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); close() }
if (event.key !== 'Tab') return
- const elements = Array.from(dialog.value?.querySelectorAll('button, input, select, [tabindex="0"]') ?? []).filter(element => !element.matches(':disabled'))
+ const elements = Array.from(dialog.value?.querySelectorAll('button, input, select, textarea, [tabindex="0"]') ?? []).filter(element => !element.matches(':disabled'))
const first = elements[0], last = elements[elements.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
@@ -153,7 +166,7 @@ async function save() {
if (!requestJsonValid.value) throw new Error(t('请先修正自定义请求 JSON。', 'Fix the custom request JSON first.'))
if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error(t('请输入 API Key。密钥将由后端加密保存。', 'Enter an API key. It will be encrypted by the backend.'))
// Snapshot before awaiting: closing/unmounting must never create a provider with a changed draft.
- const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false, request_overrides: requestOverrides.value }
+ const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false, request_overrides: requestOverrides.value, context_policies: JSON.parse(JSON.stringify(contextPolicies.value)) }
if (apiKey.value.trim()) {
// Rotate even an existing reference: older installations may share preset credential IDs.
const nextId = newCredentialId()
@@ -178,7 +191,7 @@ async function save() {
-
+
-
+
diff --git a/frontend/src/features/settings/RequestJsonEditor.vue b/frontend/src/features/settings/RequestJsonEditor.vue
index 1f80060..a8bf3c3 100644
--- a/frontend/src/features/settings/RequestJsonEditor.vue
+++ b/frontend/src/features/settings/RequestJsonEditor.vue
@@ -72,7 +72,7 @@ async function exportRules() {
-
+
{{ rule.error }}
@@ -82,4 +82,4 @@ async function exportRules() {
{{ t('导入替换当前请求规则,保存提供商后生效。导出仅包含请求规则,不包含凭据引用和 API Key。', 'Importing replaces the current request rules and takes effect after saving the provider. Exports contain rules only, without credential references or API keys.') }}
{{ t('请求', 'Requests') }} {{ data.request_count }}, {{ t('其中完整结束', 'completed') }} {{ data.complete_requests }}. {{ t('输入总量包含厂商已报告的缓存,推理 Token 不重复加入输出。', 'Input totals include provider-reported cache tokens; reasoning tokens are not added to output twice.') }}
+ {{ t('缓存统计如何计算?', 'How are cache statistics calculated?') }}
{{ t('命中、写入优先使用厂商报告字段;有输入总量和命中数时,未命中可由输入减命中得到。命中率为同时提供命中与未命中的请求中,命中 Token 合计 ÷ 输入 Token 合计;缺少输入总量时使用命中加未命中作为分母。Anthropic 输入总量包含读取及写入缓存。', 'Cache hits and writes use reported counters. Misses can be input minus hits. The hit rate divides hits by input tokens for requests reporting both hits and misses, using hits plus misses when input is absent. Anthropic input includes cache reads and writes.') }}
{{ t('0 表示已报告零值;未提供表示字段缺失。聊天次数不会自动折算为缓存,是否命中由厂商决定。本地 Embedding 通常不报告缓存字段。', 'Zero means a reported zero; unavailable means missing. Conversation counts do not imply cache hits. Local embedding typically does not report cache counters.') }}
{{ t('统计为本应用观测值,不是厂商账户账单。缺失指标显示“未提供”,历史未记录的数据不补估。', 'Statistics are application observations, not provider billing. Missing metrics stay unavailable and historical gaps are not estimated.') }}
{{ activeBucket.date }}{{ description(activeBucket, source) }}{{ modelDescription(item) }}{{ t('悬停或聚焦日期查看数据', 'Hover or focus a date for details') }}
+
+
{{ t('同一来源内,颜色越深表示所选时段该模型的累计消耗越多。', 'Within each source, darker shades indicate greater model usage over the selected period.') }}
+
{{ item.model }}
+
{{ t('按本机时区分组;柱高仅汇总已报告值,悬停可查看覆盖请求数。虚线表示有请求但未提供该指标,不作为零消耗。', 'Grouped by your local UTC offset. Bars sum reported values; hover for coverage. Dashed markers mean requests with unavailable counters, not zero usage.') }}