feat: gap closure sprint — dark theme, @-mention, LocalComputerUse, tests
P0: U4 UsageStore + U5 CascadeStateStore independent test files (57 tests)
P1: Dark theme — tokens.css [data-theme="dark"] + theme.ts Pinia store
+ TopNav toggle button + App.vue dynamic Ant Design theme
P1: @-mention — MentionDropdown.vue + /skills/mention-suggest API
+ ChatInput integration with @ detection
P2: LocalComputerUseSession — pyautogui + screencapture (replaces Docker stub)
P2: Integration tests for gap closure (12 tests)
Fix: create_cascade_state_store() now passes session_ttl to InMemory fallback
This commit is contained in:
parent
0ccef7be5c
commit
6e0e081f23
|
|
@ -241,5 +241,4 @@ def create_cascade_state_store(
|
|||
return RedisCascadeStateStore(redis_url=redis_url, session_ttl=session_ttl)
|
||||
except ImportError:
|
||||
logger.warning("redis package not available, falling back to in-memory cascade store")
|
||||
return InMemoryCascadeStateStore()
|
||||
return InMemoryCascadeStateStore()
|
||||
return InMemoryCascadeStateStore(session_ttl=session_ttl)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<a-config-provider :locale="zhCN" :theme="themeConfig">
|
||||
<a-config-provider :locale="zhCN" :theme="themeStore.antThemeConfig.value">
|
||||
<SplashScreen v-if="loading" :status="loadingStatus" :error="loadError" />
|
||||
<router-view v-else />
|
||||
</a-config-provider>
|
||||
|
|
@ -9,11 +9,13 @@
|
|||
import { ref, onMounted } from 'vue'
|
||||
import { ConfigProvider as AConfigProvider } from 'ant-design-vue'
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN'
|
||||
import { themeConfig } from './styles'
|
||||
import { useThemeStore } from './stores/theme'
|
||||
import { isTauri, startBackend, checkBackendHealth } from './api/tauri'
|
||||
import { initApiBaseURL } from './api/base'
|
||||
import SplashScreen from './components/layout/SplashScreen.vue'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const loading = ref(isTauri())
|
||||
const loadingStatus = ref('正在初始化...')
|
||||
const loadError = ref('')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="chat-input">
|
||||
<div class="chat-input" style="position: relative;">
|
||||
<div v-if="contextPills.length > 0" class="chat-input__pills">
|
||||
<ContextPill
|
||||
v-for="(pill, idx) in contextPills"
|
||||
|
|
@ -10,13 +10,23 @@
|
|||
@remove="removePill(idx)"
|
||||
/>
|
||||
</div>
|
||||
<MentionDropdown
|
||||
:visible="mentionVisible"
|
||||
:query="mentionQuery"
|
||||
:skills="skillSuggestions"
|
||||
:position="mentionPosition"
|
||||
@select="insertMention"
|
||||
@close="closeMention"
|
||||
/>
|
||||
<div class="chat-input__row">
|
||||
<a-textarea
|
||||
ref="textareaRef"
|
||||
v-model:value="inputText"
|
||||
:placeholder="placeholder"
|
||||
:auto-size="{ minRows: 1, maxRows: 4 }"
|
||||
:disabled="disabled"
|
||||
@pressEnter="handlePressEnter"
|
||||
@input="handleInput"
|
||||
class="chat-input__textarea"
|
||||
/>
|
||||
<a-button
|
||||
|
|
@ -37,6 +47,8 @@ import { ref, computed, type Component } from 'vue'
|
|||
import { Input as AInput, Button as AButton } from 'ant-design-vue'
|
||||
import { SendOutlined } from '@ant-design/icons-vue'
|
||||
import ContextPill from './ContextPill.vue'
|
||||
import MentionDropdown from './MentionDropdown.vue'
|
||||
import { useSkillsStore } from '@/stores/skills'
|
||||
|
||||
const ATextarea = AInput.TextArea
|
||||
|
||||
|
|
@ -46,6 +58,11 @@ interface ContextPillData {
|
|||
removable?: boolean
|
||||
}
|
||||
|
||||
interface SkillSuggestion {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
|
|
@ -62,19 +79,86 @@ const emit = defineEmits<{
|
|||
|
||||
const inputText = ref('')
|
||||
const contextPills = ref<ContextPillData[]>([])
|
||||
const textareaRef = ref<InstanceType<typeof ATextarea> | null>(null)
|
||||
|
||||
// @-mention state
|
||||
const mentionVisible = ref(false)
|
||||
const mentionQuery = ref('')
|
||||
const mentionStartIndex = ref(-1)
|
||||
const mentionPosition = ref({ left: 0 })
|
||||
const skillsStore = useSkillsStore()
|
||||
|
||||
const skillSuggestions = computed<SkillSuggestion[]>(() => {
|
||||
return (skillsStore.skills || []).map((s: any) => ({
|
||||
name: s.name,
|
||||
description: s.description || '',
|
||||
}))
|
||||
})
|
||||
|
||||
const canSend = computed(() => {
|
||||
return inputText.value.trim().length > 0 && !props.disabled
|
||||
})
|
||||
|
||||
function handleInput(): void {
|
||||
detectMention()
|
||||
}
|
||||
|
||||
function detectMention(): void {
|
||||
const text = inputText.value
|
||||
const cursorPos = text.length // textarea cursor at end for v-model
|
||||
|
||||
// Find the last @ that starts a potential mention
|
||||
const lastAtIndex = text.lastIndexOf('@', cursorPos)
|
||||
if (lastAtIndex === -1) {
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if there's a space between @ and cursor (mention is complete)
|
||||
const textAfterAt = text.slice(lastAtIndex + 1, cursorPos)
|
||||
if (textAfterAt.includes(' ') || textAfterAt.includes('\n')) {
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if @ is at start or preceded by whitespace
|
||||
if (lastAtIndex > 0 && !/\s/.test(text[lastAtIndex - 1])) {
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
|
||||
mentionVisible.value = true
|
||||
mentionQuery.value = textAfterAt
|
||||
mentionStartIndex.value = lastAtIndex
|
||||
|
||||
// Estimate horizontal position based on character count
|
||||
mentionPosition.value = { left: Math.min(lastAtIndex * 8, 200) }
|
||||
}
|
||||
|
||||
function insertMention(skill: SkillSuggestion): void {
|
||||
const text = inputText.value
|
||||
const before = text.slice(0, mentionStartIndex.value)
|
||||
const after = text.slice(mentionStartIndex.value + 1 + mentionQuery.value.length)
|
||||
inputText.value = `${before}@${skill.name} ${after}`
|
||||
closeMention()
|
||||
}
|
||||
|
||||
function closeMention(): void {
|
||||
mentionVisible.value = false
|
||||
mentionQuery.value = ''
|
||||
mentionStartIndex.value = -1
|
||||
}
|
||||
|
||||
function handleSend(): void {
|
||||
const message = inputText.value.trim()
|
||||
if (!message) return
|
||||
closeMention()
|
||||
emit('send', message)
|
||||
inputText.value = ''
|
||||
setTimeout(() => { inputText.value = '' }, 0)
|
||||
}
|
||||
|
||||
function handlePressEnter(event: KeyboardEvent): void {
|
||||
if (mentionVisible.value) return // Let MentionDropdown handle Enter
|
||||
if (event.shiftKey) return
|
||||
event.preventDefault()
|
||||
handleSend()
|
||||
|
|
@ -89,9 +173,9 @@ function removePill(idx: number): void {
|
|||
.chat-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bg-primary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-top: 1px solid var(--border-color-split);
|
||||
}
|
||||
|
||||
.chat-input__pills {
|
||||
|
|
@ -105,6 +189,16 @@ function removePill(idx: number): void {
|
|||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.chat-input__row:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-light);
|
||||
}
|
||||
|
||||
.chat-input__textarea {
|
||||
|
|
@ -117,11 +211,34 @@ function removePill(idx: number): void {
|
|||
line-height: 1.5;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none !important;
|
||||
font-size: var(--font-base);
|
||||
}
|
||||
|
||||
.chat-input__textarea :deep(.ant-input:focus) {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.chat-input__textarea :deep(.ant-input::placeholder) {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.chat-input__send {
|
||||
flex-shrink: 0;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
border-radius: var(--radius-md) !important;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.chat-input__send:not(:disabled):hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.chat-input__send:not(:disabled):active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
<template>
|
||||
<div v-if="visible && filteredSkills.length > 0" class="mention-dropdown" :style="positionStyle">
|
||||
<div class="mention-dropdown__header">
|
||||
<span class="mention-dropdown__title">技能</span>
|
||||
<span class="mention-dropdown__hint">选择或继续输入筛选</span>
|
||||
</div>
|
||||
<div class="mention-dropdown__list">
|
||||
<button
|
||||
v-for="(skill, idx) in filteredSkills"
|
||||
:key="skill.name"
|
||||
class="mention-dropdown__item"
|
||||
:class="{ 'mention-dropdown__item--active': idx === activeIndex }"
|
||||
@click="selectSkill(skill)"
|
||||
@mouseenter="activeIndex = idx"
|
||||
>
|
||||
<span class="mention-dropdown__item-name">{{ skill.name }}</span>
|
||||
<span class="mention-dropdown__item-desc">{{ skill.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
interface SkillSuggestion {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
visible: boolean
|
||||
query: string
|
||||
skills: SkillSuggestion[]
|
||||
position?: { top: number; left: number }
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
position: () => ({ top: 0, left: 0 }),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [skill: SkillSuggestion]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const activeIndex = ref(0)
|
||||
|
||||
const filteredSkills = computed(() => {
|
||||
const q = props.query.toLowerCase()
|
||||
if (!q) return props.skills.slice(0, 8)
|
||||
return props.skills
|
||||
.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q))
|
||||
.slice(0, 8)
|
||||
})
|
||||
|
||||
const positionStyle = computed(() => ({
|
||||
position: 'absolute' as const,
|
||||
bottom: `calc(100% + 4px)`,
|
||||
left: `${props.position.left}px`,
|
||||
}))
|
||||
|
||||
watch(() => props.query, () => {
|
||||
activeIndex.value = 0
|
||||
})
|
||||
|
||||
function selectSkill(skill: SkillSuggestion) {
|
||||
emit('select', skill)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (!props.visible || filteredSkills.value.length === 0) return
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = (activeIndex.value + 1) % filteredSkills.value.length
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = (activeIndex.value - 1 + filteredSkills.value.length) % filteredSkills.value.length
|
||||
} else if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
selectSkill(filteredSkills.value[activeIndex.value])
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mention-dropdown {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-width: 280px;
|
||||
max-width: 400px;
|
||||
z-index: var(--z-dropdown);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mention-dropdown__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--border-color-split);
|
||||
}
|
||||
|
||||
.mention-dropdown__title {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.mention-dropdown__hint {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.mention-dropdown__list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.mention-dropdown__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.mention-dropdown__item:hover,
|
||||
.mention-dropdown__item--active {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.mention-dropdown__item-name {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mention-dropdown__item-desc {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
<template>
|
||||
<header class="top-nav">
|
||||
<div class="top-nav__left">
|
||||
<button class="top-nav__icon-btn" @click="emit('toggleIconNav')" title="切换导航">
|
||||
<MenuFoldOutlined v-if="!iconNavCollapsed" />
|
||||
<MenuUnfoldOutlined v-else />
|
||||
</button>
|
||||
<div class="top-nav__logo" @click="router.push('/agent')">
|
||||
<span class="top-nav__logo-text">Fischer</span>
|
||||
<span class="top-nav__logo-badge">AgentKit</span>
|
||||
|
|
@ -17,6 +21,12 @@
|
|||
:text="wsConnected ? '已连接' : '未连接'"
|
||||
/>
|
||||
</div>
|
||||
<a-tooltip :title="isDark ? '切换亮色模式' : '切换暗色模式'">
|
||||
<button class="top-nav__icon-btn" @click="themeStore.toggle()">
|
||||
<BulbOutlined v-if="isDark" />
|
||||
<BulbOutlined v-else style="opacity: 0.5" />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="设置">
|
||||
<button class="top-nav__icon-btn" @click="router.push('/agent/monitor?tab=settings')">
|
||||
<SettingOutlined />
|
||||
|
|
@ -30,12 +40,24 @@
|
|||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Badge as ABadge, Tooltip as ATooltip } from 'ant-design-vue'
|
||||
import { SettingOutlined } from '@ant-design/icons-vue'
|
||||
import { SettingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, BulbOutlined } from '@ant-design/icons-vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const props = defineProps<{
|
||||
iconNavCollapsed?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleIconNav: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const chatStore = useChatStore()
|
||||
const themeStore = useThemeStore()
|
||||
const wsConnected = computed(() => chatStore.isWsConnected)
|
||||
const iconNavCollapsed = computed(() => props.iconNavCollapsed ?? false)
|
||||
const isDark = computed(() => themeStore.resolvedMode === 'dark')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -44,11 +66,11 @@ const wsConnected = computed(() => chatStore.isWsConnected)
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--topnav-height);
|
||||
padding: 0 var(--space-4);
|
||||
padding: 0 var(--space-5);
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-color-split);
|
||||
flex-shrink: 0;
|
||||
z-index: var(--z-sticky);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.top-nav__left {
|
||||
|
|
@ -63,6 +85,13 @@ const wsConnected = computed(() => chatStore.isWsConnected)
|
|||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.top-nav__logo:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.top-nav__logo-text {
|
||||
|
|
@ -79,9 +108,9 @@ const wsConnected = computed(() => chatStore.isWsConnected)
|
|||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-inverse);
|
||||
background: var(--gradient-brand);
|
||||
padding: 1px var(--space-2);
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.top-nav__center {
|
||||
|
|
@ -96,11 +125,14 @@ const wsConnected = computed(() => chatStore.isWsConnected)
|
|||
.top-nav__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.top-nav__status {
|
||||
font-size: var(--font-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.top-nav__status :deep(.ant-badge-status-text) {
|
||||
|
|
@ -112,8 +144,8 @@ const wsConnected = computed(() => chatStore.isWsConnected)
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* Theme Store — Dark mode toggle with system preference detection.
|
||||
*
|
||||
* Persists preference to localStorage and syncs with the `data-theme`
|
||||
* attribute on <html>. Also provides a reactive Ant Design theme config
|
||||
* that updates when the mode changes.
|
||||
*/
|
||||
|
||||
import { ref, computed, watchEffect } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { ThemeConfig } from 'ant-design-vue/es/config-provider/context'
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
|
||||
const STORAGE_KEY = 'agentkit-theme-mode'
|
||||
|
||||
function readToken(varName: string, fallback: string): string {
|
||||
if (typeof document === 'undefined') return fallback
|
||||
const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim()
|
||||
return val || fallback
|
||||
}
|
||||
|
||||
function getSystemPreference(): 'light' | 'dark' {
|
||||
if (typeof window === 'undefined') return 'light'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function getStoredMode(): ThemeMode {
|
||||
if (typeof localStorage === 'undefined') return 'system'
|
||||
return (localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'system'
|
||||
}
|
||||
|
||||
function applyTheme(resolved: 'light' | 'dark') {
|
||||
if (typeof document === 'undefined') return
|
||||
document.documentElement.setAttribute('data-theme', resolved)
|
||||
}
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const mode = ref<ThemeMode>(getStoredMode())
|
||||
|
||||
const resolvedMode = computed<'light' | 'dark'>(() => {
|
||||
if (mode.value === 'system') return getSystemPreference()
|
||||
return mode.value
|
||||
})
|
||||
|
||||
function setMode(newMode: ThemeMode) {
|
||||
mode.value = newMode
|
||||
localStorage.setItem(STORAGE_KEY, newMode)
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (resolvedMode.value === 'light') {
|
||||
setMode('dark')
|
||||
} else {
|
||||
setMode('light')
|
||||
}
|
||||
}
|
||||
|
||||
// Apply theme whenever resolvedMode changes
|
||||
watchEffect(() => {
|
||||
applyTheme(resolvedMode.value)
|
||||
})
|
||||
|
||||
// Listen for system preference changes
|
||||
if (typeof window !== 'undefined') {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
// Force re-evaluation when system preference changes
|
||||
if (mode.value === 'system') {
|
||||
applyTheme(getSystemPreference())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Ant Design Vue theme config — reactive to dark mode
|
||||
const antThemeConfig = computed<ThemeConfig>(() => {
|
||||
const isDark = resolvedMode.value === 'dark'
|
||||
|
||||
return {
|
||||
algorithm: isDark ? undefined : undefined, // Ant Design 4.x uses token-based theming
|
||||
token: {
|
||||
colorPrimary: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
|
||||
colorInfo: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
|
||||
colorSuccess: readToken('--color-success', isDark ? '#4ade80' : '#22c55e'),
|
||||
colorWarning: readToken('--color-warning', isDark ? '#fbbf24' : '#f59e0b'),
|
||||
colorError: readToken('--color-error', isDark ? '#f87171' : '#ef4444'),
|
||||
colorText: readToken('--text-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
|
||||
colorTextSecondary: readToken('--text-secondary', isDark ? '#cececd' : '#4a4a4a'),
|
||||
colorTextTertiary: readToken('--text-tertiary', isDark ? '#9b9b9a' : '#6b6b6a'),
|
||||
colorTextQuaternary: readToken('--text-placeholder', isDark ? '#6b6b6a' : '#9b9b9a'),
|
||||
colorBgContainer: readToken('--bg-primary', isDark ? '#1a1a1a' : '#ffffff'),
|
||||
colorBgLayout: readToken('--bg-secondary', isDark ? '#1f1f1f' : '#fbfbfa'),
|
||||
colorBgElevated: readToken('--bg-elevated', isDark ? '#252525' : '#ffffff'),
|
||||
colorBorder: readToken('--border-color', isDark ? '#3a3a3a' : '#ededec'),
|
||||
colorBorderSecondary: readToken('--border-color-split', isDark ? '#2f2f2f' : '#f2f2f0'),
|
||||
fontSize: 14,
|
||||
fontSizeSM: 12,
|
||||
fontSizeLG: 16,
|
||||
fontSizeXL: 20,
|
||||
borderRadius: 8,
|
||||
borderRadiusSM: 6,
|
||||
borderRadiusLG: 12,
|
||||
controlHeight: 32,
|
||||
controlHeightSM: 24,
|
||||
controlHeightLG: 40,
|
||||
boxShadow: isDark
|
||||
? '0 1px 2px rgba(0, 0, 0, 0.2)'
|
||||
: '0 1px 2px rgba(0, 0, 0, 0.04)',
|
||||
boxShadowSecondary: isDark
|
||||
? '0 2px 8px rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.15)'
|
||||
: '0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)',
|
||||
},
|
||||
components: {
|
||||
Menu: {
|
||||
itemSelectedBg: readToken('--color-primary-light', isDark ? '#1e1b4b' : '#eef2ff'),
|
||||
itemSelectedColor: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
|
||||
itemHoverBg: isDark ? '#1e1b4b' : '#f5f3ff',
|
||||
itemHoverColor: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
|
||||
itemColor: readToken('--text-secondary', isDark ? '#cececd' : '#4a4a4a'),
|
||||
} as Record<string, unknown>,
|
||||
Tabs: {
|
||||
itemSelectedColor: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
|
||||
itemHoverColor: readToken('--color-primary-hover', isDark ? '#6366f1' : '#4f46e5'),
|
||||
} as Record<string, unknown>,
|
||||
Select: {
|
||||
colorPrimary: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
|
||||
colorPrimaryHover: readToken('--color-primary-hover', isDark ? '#6366f1' : '#4f46e5'),
|
||||
} as Record<string, unknown>,
|
||||
Button: { borderRadius: 8, controlHeight: 32 } as Record<string, unknown>,
|
||||
Card: { borderRadiusLG: 10 } as Record<string, unknown>,
|
||||
Input: { borderRadius: 8 } as Record<string, unknown>,
|
||||
Modal: { borderRadiusLG: 12 } as Record<string, unknown>,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
mode,
|
||||
resolvedMode,
|
||||
antThemeConfig,
|
||||
setMode,
|
||||
toggle,
|
||||
}
|
||||
})
|
||||
|
|
@ -1,63 +1,70 @@
|
|||
/**
|
||||
* Fischer AgentKit Design Tokens
|
||||
* Fischer AgentKit Design Tokens — Notion-Inspired Light Theme
|
||||
*
|
||||
* CSS Custom Properties as the single source of truth.
|
||||
* Ant Design Vue theme tokens are mapped from these values in theme.ts.
|
||||
*
|
||||
* Design Philosophy:
|
||||
* - Warm neutrals (Notion-style) instead of cold grays
|
||||
* - Softer shadows with more spread, less offset
|
||||
* - Generous border-radius for friendly feel
|
||||
* - Subtle borders that fade into background
|
||||
* - Refined hover/active states with gentle transitions
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ── Brand Colors ── */
|
||||
--color-primary: #7c3aed;
|
||||
--color-primary-hover: #6d28d9;
|
||||
--color-primary-active: #5b21b6;
|
||||
--color-primary-light: #ede9fe;
|
||||
--color-primary: #6366f1;
|
||||
--color-primary-hover: #4f46e5;
|
||||
--color-primary-active: #4338ca;
|
||||
--color-primary-light: #eef2ff;
|
||||
--color-primary-bg: #f5f3ff;
|
||||
|
||||
/* ── Gradient ── */
|
||||
--gradient-brand: linear-gradient(135deg, #7c3aed 0%, #1e1b4b 100%);
|
||||
--gradient-brand: linear-gradient(135deg, #6366f1 0%, #312e81 100%);
|
||||
|
||||
/* ── Semantic Colors ── */
|
||||
--color-success: #10b981;
|
||||
--color-success-light: #d1fae5;
|
||||
--color-success: #22c55e;
|
||||
--color-success-light: #dcfce7;
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-light: #fef3c7;
|
||||
--color-warning-light: #fef9c3;
|
||||
--color-error: #ef4444;
|
||||
--color-error-light: #fee2e2;
|
||||
--color-info: #3b82f6;
|
||||
--color-info-light: #dbeafe;
|
||||
|
||||
/* ── Neutral / Gray Scale ── */
|
||||
--color-gray-50: #fafafa;
|
||||
--color-gray-100: #f5f5f5;
|
||||
--color-gray-200: #e5e5e5;
|
||||
--color-gray-300: #d4d4d4;
|
||||
--color-gray-400: #a3a3a3;
|
||||
--color-gray-500: #737373;
|
||||
--color-gray-600: #525252;
|
||||
--color-gray-700: #404040;
|
||||
--color-gray-800: #262626;
|
||||
--color-gray-900: #171717;
|
||||
/* ── Neutral / Warm Gray Scale (Notion-inspired) ── */
|
||||
--color-gray-50: #fbfbfa;
|
||||
--color-gray-100: #f7f7f5;
|
||||
--color-gray-200: #ededec;
|
||||
--color-gray-300: #dfdfde;
|
||||
--color-gray-400: #cececd;
|
||||
--color-gray-500: #9b9b9a;
|
||||
--color-gray-600: #6b6b6a;
|
||||
--color-gray-700: #4a4a4a;
|
||||
--color-gray-800: #2f2f2f;
|
||||
--color-gray-900: #1a1a1a;
|
||||
|
||||
/* ── Background ── */
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #fafafa;
|
||||
--bg-tertiary: #f5f5f5;
|
||||
--bg-secondary: #fbfbfa;
|
||||
--bg-tertiary: #f7f7f5;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-code: #282c34;
|
||||
--bg-code: #1e1e2e;
|
||||
|
||||
/* ── Foreground / Text ── */
|
||||
--text-primary: #171717;
|
||||
--text-secondary: #525252;
|
||||
--text-tertiary: #737373;
|
||||
--text-placeholder: #a3a3a3;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #4a4a4a;
|
||||
--text-tertiary: #6b6b6a;
|
||||
--text-placeholder: #9b9b9a;
|
||||
--text-inverse: #ffffff;
|
||||
--text-code: #abb2bf;
|
||||
--text-code: #cdd6f4;
|
||||
|
||||
/* ── Border ── */
|
||||
--border-color: #e5e5e5;
|
||||
--border-color-hover: #d4d4d4;
|
||||
--border-color: #ededec;
|
||||
--border-color-hover: #dfdfde;
|
||||
--border-color-active: var(--color-primary);
|
||||
--border-color-split: #f0f0f0;
|
||||
--border-color-split: #f2f2f0;
|
||||
|
||||
/* ── Spacing ── */
|
||||
--space-1: 4px;
|
||||
|
|
@ -73,8 +80,8 @@
|
|||
/* ── Border Radius ── */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-lg: 10px;
|
||||
--radius-xl: 14px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ── Font Size ── */
|
||||
|
|
@ -96,11 +103,11 @@
|
|||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.75;
|
||||
|
||||
/* ── Shadow ── */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
|
||||
/* ── Shadow (Notion-style: softer, more ambient) ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.06), 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* ── Transition ── */
|
||||
--transition-fast: 150ms ease;
|
||||
|
|
@ -121,16 +128,91 @@
|
|||
--sidebar-width: 280px;
|
||||
--quadrant-min-size: 320px;
|
||||
|
||||
/* ── Code Theme (One Dark Pro) ── */
|
||||
--code-bg: #282c34;
|
||||
--code-fg: #abb2bf;
|
||||
--code-keyword: #c678dd;
|
||||
--code-string: #98c379;
|
||||
--code-number: #d19a66;
|
||||
--code-comment: #5c6370;
|
||||
--code-function: #61afef;
|
||||
--code-variable: #e06c75;
|
||||
--code-type: #e5c07b;
|
||||
--code-added-bg: rgba(16, 185, 129, 0.15);
|
||||
--code-removed-bg: rgba(239, 68, 68, 0.15);
|
||||
/* ── Code Theme (Catppuccin Mocha — warmer dark theme) ── */
|
||||
--code-bg: #1e1e2e;
|
||||
--code-fg: #cdd6f4;
|
||||
--code-keyword: #cba6f7;
|
||||
--code-string: #a6e3a1;
|
||||
--code-number: #fab387;
|
||||
--code-comment: #6c7086;
|
||||
--code-function: #89b4fa;
|
||||
--code-variable: #f38ba8;
|
||||
--code-type: #f9e2af;
|
||||
--code-added-bg: rgba(166, 227, 161, 0.15);
|
||||
--code-removed-bg: rgba(243, 139, 168, 0.15);
|
||||
}
|
||||
|
||||
/* ── Dark Theme ── */
|
||||
[data-theme="dark"] {
|
||||
/* ── Brand Colors ── */
|
||||
--color-primary: #818cf8;
|
||||
--color-primary-hover: #6366f1;
|
||||
--color-primary-active: #4f46e5;
|
||||
--color-primary-light: #1e1b4b;
|
||||
--color-primary-bg: #1e1b4b;
|
||||
|
||||
/* ── Gradient ── */
|
||||
--gradient-brand: linear-gradient(135deg, #818cf8 0%, #4338ca 100%);
|
||||
|
||||
/* ── Semantic Colors ── */
|
||||
--color-success: #4ade80;
|
||||
--color-success-light: #14532d;
|
||||
--color-warning: #fbbf24;
|
||||
--color-warning-light: #422006;
|
||||
--color-error: #f87171;
|
||||
--color-error-light: #450a0a;
|
||||
--color-info: #60a5fa;
|
||||
--color-info-light: #172554;
|
||||
|
||||
/* ── Neutral / Warm Gray Scale (Dark) ── */
|
||||
--color-gray-50: #1a1a1a;
|
||||
--color-gray-100: #2f2f2f;
|
||||
--color-gray-200: #3a3a3a;
|
||||
--color-gray-300: #4a4a4a;
|
||||
--color-gray-400: #6b6b6a;
|
||||
--color-gray-500: #9b9b9a;
|
||||
--color-gray-600: #b0b0af;
|
||||
--color-gray-700: #cececd;
|
||||
--color-gray-800: #ededec;
|
||||
--color-gray-900: #fbfbfa;
|
||||
|
||||
/* ── Background ── */
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #1f1f1f;
|
||||
--bg-tertiary: #2a2a2a;
|
||||
--bg-elevated: #252525;
|
||||
--bg-code: #11111b;
|
||||
|
||||
/* ── Foreground / Text ── */
|
||||
--text-primary: #fbfbfa;
|
||||
--text-secondary: #cececd;
|
||||
--text-tertiary: #9b9b9a;
|
||||
--text-placeholder: #6b6b6a;
|
||||
--text-inverse: #1a1a1a;
|
||||
--text-code: #cdd6f4;
|
||||
|
||||
/* ── Border ── */
|
||||
--border-color: #3a3a3a;
|
||||
--border-color-hover: #4a4a4a;
|
||||
--border-color-active: var(--color-primary);
|
||||
--border-color-split: #2f2f2f;
|
||||
|
||||
/* ── Shadow (darker ambient for dark mode) ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
|
||||
/* ── Code Theme (Catppuccin Mocha — unchanged, already dark) ── */
|
||||
--code-bg: #11111b;
|
||||
--code-fg: #cdd6f4;
|
||||
--code-keyword: #cba6f7;
|
||||
--code-string: #a6e3a1;
|
||||
--code-number: #fab387;
|
||||
--code-comment: #6c7086;
|
||||
--code-function: #89b4fa;
|
||||
--code-variable: #f38ba8;
|
||||
--code-type: #f9e2af;
|
||||
--code-added-bg: rgba(166, 227, 161, 0.2);
|
||||
--code-removed-bg: rgba(243, 139, 168, 0.2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,6 +160,30 @@ async def list_skills(req: Request):
|
|||
]
|
||||
|
||||
|
||||
@router.get("/skills/mention-suggest")
|
||||
async def mention_suggest(q: str = "", req: Request = None):
|
||||
"""Suggest skills for @-mention autocomplete.
|
||||
|
||||
Returns up to 8 skills matching the query string, with name and description
|
||||
for the MentionDropdown component.
|
||||
"""
|
||||
skill_registry = req.app.state.skill_registry
|
||||
skills = skill_registry.list_skills()
|
||||
query = q.lower()
|
||||
|
||||
if query:
|
||||
skills = [
|
||||
s for s in skills
|
||||
if query in s.name.lower()
|
||||
or (s.config.description and query in s.config.description.lower())
|
||||
]
|
||||
|
||||
return [
|
||||
{"name": s.name, "description": s.config.description or ""}
|
||||
for s in skills[:8]
|
||||
]
|
||||
|
||||
|
||||
@router.post("/skills/install")
|
||||
async def install_skill(request: InstallSkillRequest, req: Request):
|
||||
"""Search for and install a skill by name.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
"""ComputerUseSession - 虚拟桌面会话管理
|
||||
|
||||
管理虚拟桌面会话(Docker 沙箱),维护操作上下文。
|
||||
提供 InMemoryComputerUseSession 用于测试,DockerComputerUseSession 用于生产。
|
||||
管理虚拟桌面会话,维护操作上下文。
|
||||
提供 InMemoryComputerUseSession 用于测试,LocalComputerUseSession 用于本地桌面,
|
||||
DockerComputerUseSession 用于 Docker 沙箱(stub)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import platform
|
||||
import time
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
|
|
@ -248,6 +252,124 @@ class InMemoryComputerUseSession(ComputerUseSession):
|
|||
)
|
||||
|
||||
|
||||
class LocalComputerUseSession(ComputerUseSession):
|
||||
"""本地桌面会话,使用 pyautogui + screencapture。
|
||||
|
||||
在本地桌面执行真实的 UI 操作。需要辅助功能权限(macOS)。
|
||||
适用于开发/演示环境,生产环境应使用 DockerComputerUseSession。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str | None = None,
|
||||
screen_width: int = 1280,
|
||||
screen_height: int = 720,
|
||||
):
|
||||
super().__init__(
|
||||
session_id=session_id,
|
||||
screen_width=screen_width,
|
||||
screen_height=screen_height,
|
||||
)
|
||||
self._pyautogui: Any = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""启动本地桌面会话"""
|
||||
try:
|
||||
import pyautogui
|
||||
self._pyautogui = pyautogui
|
||||
pyautogui.FAILSAFE = True
|
||||
pyautogui.PAUSE = 0.1
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"pyautogui is required for LocalComputerUseSession. "
|
||||
"Install with: pip install pyautogui"
|
||||
)
|
||||
self._started = True
|
||||
logger.info("Local desktop session started: %s", self.session_id[:8])
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""停止本地桌面会话"""
|
||||
self._started = False
|
||||
self._pyautogui = None
|
||||
logger.info("Local desktop session stopped: %s", self.session_id[:8])
|
||||
|
||||
async def screenshot(self) -> ActionResult:
|
||||
"""截取本地桌面屏幕"""
|
||||
if not self._started:
|
||||
return ActionResult(success=False, action="screenshot", error="Session not started")
|
||||
|
||||
try:
|
||||
img = self._pyautogui.screenshot()
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
screenshot_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
return ActionResult(
|
||||
success=True,
|
||||
action="screenshot",
|
||||
output=f"Screenshot {img.size[0]}x{img.size[1]}",
|
||||
screenshot_base64=screenshot_b64,
|
||||
)
|
||||
except Exception as e:
|
||||
return ActionResult(success=False, action="screenshot", error=str(e))
|
||||
|
||||
async def execute_action(self, action: str, **params: Any) -> ActionResult:
|
||||
"""在本地桌面执行 UI 操作"""
|
||||
if not self._started:
|
||||
return ActionResult(success=False, action=action, error="Session not started")
|
||||
|
||||
try:
|
||||
result = self._execute_local_action(action, **params)
|
||||
self.record_action(action, params, result)
|
||||
return result
|
||||
except Exception as e:
|
||||
return ActionResult(success=False, action=action, error=str(e))
|
||||
|
||||
def _execute_local_action(self, action: str, **params: Any) -> ActionResult:
|
||||
"""执行本地 UI 操作"""
|
||||
pg = self._pyautogui
|
||||
|
||||
if action == "click":
|
||||
x, y = params.get("x", 0), params.get("y", 0)
|
||||
button = params.get("button", "left")
|
||||
pg.click(x, y, button=button)
|
||||
return ActionResult(success=True, action="click", output=f"Clicked at ({x}, {y})")
|
||||
|
||||
if action == "type":
|
||||
text = params.get("text", "")
|
||||
pg.typewrite(text) if platform.system() != "Darwin" else pg.write(text)
|
||||
return ActionResult(success=True, action="type", output=f"Typed: {text}")
|
||||
|
||||
if action == "scroll":
|
||||
direction = params.get("direction", "down")
|
||||
amount = params.get("amount", 3)
|
||||
clicks = amount if direction == "down" else -amount
|
||||
pg.scroll(clicks)
|
||||
return ActionResult(success=True, action="scroll", output=f"Scrolled {direction} by {amount}")
|
||||
|
||||
if action == "drag":
|
||||
sx, sy = params.get("start_x", 0), params.get("start_y", 0)
|
||||
ex, ey = params.get("end_x", 0), params.get("end_y", 0)
|
||||
pg.moveTo(sx, sy)
|
||||
pg.dragTo(ex, ey, duration=0.5)
|
||||
return ActionResult(success=True, action="drag", output=f"Dragged from ({sx},{sy}) to ({ex},{ey})")
|
||||
|
||||
if action == "key":
|
||||
key_name = params.get("key_name", "")
|
||||
keys = key_name.split("+")
|
||||
if len(keys) > 1:
|
||||
pg.hotkey(*keys)
|
||||
else:
|
||||
pg.press(key_name)
|
||||
return ActionResult(success=True, action="key", output=f"Pressed key: {key_name}")
|
||||
|
||||
if action == "wait":
|
||||
duration = params.get("duration", 1.0)
|
||||
time.sleep(duration)
|
||||
return ActionResult(success=True, action="wait", output=f"Waited {duration}s")
|
||||
|
||||
return ActionResult(success=False, action=action, error=f"Unknown action: {action}")
|
||||
|
||||
|
||||
class DockerComputerUseSession(ComputerUseSession):
|
||||
"""Docker 沙箱虚拟桌面会话
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
"""Gap closure integration tests — dark theme, @-mention API, LocalComputerUseSession."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from agentkit.quality.cascade_state_store import (
|
||||
InMemoryCascadeStateStore,
|
||||
create_cascade_state_store,
|
||||
)
|
||||
from agentkit.llm.providers.usage_store import (
|
||||
InMemoryUsageStore,
|
||||
create_usage_store,
|
||||
)
|
||||
from agentkit.tools.computer_use_session import (
|
||||
InMemoryComputerUseSession,
|
||||
LocalComputerUseSession,
|
||||
ComputerUseSessionManager,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dark theme: CSS token validation (smoke test)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDarkThemeTokens:
|
||||
"""Verify dark theme CSS tokens exist in tokens.css."""
|
||||
|
||||
def test_dark_theme_tokens_file_contains_dark_selector(self):
|
||||
import pathlib
|
||||
tokens_path = pathlib.Path(__file__).parent.parent.parent / (
|
||||
"src/agentkit/server/frontend/src/styles/tokens.css"
|
||||
)
|
||||
if not tokens_path.exists():
|
||||
pytest.skip("Frontend tokens.css not found")
|
||||
content = tokens_path.read_text()
|
||||
assert '[data-theme="dark"]' in content
|
||||
assert '--bg-primary: #1a1a1a' in content
|
||||
assert '--text-primary: #fbfbfa' in content
|
||||
assert '--border-color: #3a3a3a' in content
|
||||
|
||||
def test_theme_store_exists(self):
|
||||
import pathlib
|
||||
store_path = pathlib.Path(__file__).parent.parent.parent / (
|
||||
"src/agentkit/server/frontend/src/stores/theme.ts"
|
||||
)
|
||||
if not store_path.exists():
|
||||
pytest.skip("Theme store not found")
|
||||
content = store_path.read_text()
|
||||
assert 'useThemeStore' in content
|
||||
assert 'toggle' in content
|
||||
assert 'resolvedMode' in content
|
||||
assert 'localStorage' in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# @-mention API: mention-suggest endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMentionSuggestAPI:
|
||||
"""Test the /skills/mention-suggest endpoint logic."""
|
||||
|
||||
def _make_skill(self, name, description):
|
||||
skill = MagicMock()
|
||||
skill.name = name
|
||||
skill.config.description = description
|
||||
return skill
|
||||
|
||||
def test_mention_suggest_filters_by_name(self):
|
||||
"""Verify the filtering logic used by the endpoint."""
|
||||
skills = [
|
||||
self._make_skill("geo_pipeline", "GEO pipeline skill"),
|
||||
self._make_skill("code_reviewer", "Code review skill"),
|
||||
self._make_skill("data_analyst", "Data analysis skill"),
|
||||
]
|
||||
query = "geo"
|
||||
filtered = [
|
||||
s for s in skills
|
||||
if query in s.name.lower()
|
||||
or (s.config.description and query in s.config.description.lower())
|
||||
]
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].name == "geo_pipeline"
|
||||
|
||||
def test_mention_suggest_filters_by_description(self):
|
||||
skills = [
|
||||
self._make_skill("skill_a", "GEO pipeline skill"),
|
||||
self._make_skill("skill_b", "Code review skill"),
|
||||
]
|
||||
query = "review"
|
||||
filtered = [
|
||||
s for s in skills
|
||||
if query in s.name.lower()
|
||||
or (s.config.description and query in s.config.description.lower())
|
||||
]
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].name == "skill_b"
|
||||
|
||||
def test_mention_suggest_limits_to_8(self):
|
||||
skills = [self._make_skill(f"skill_{i}", "") for i in range(20)]
|
||||
result = skills[:8]
|
||||
assert len(result) == 8
|
||||
|
||||
def test_mention_suggest_empty_query(self):
|
||||
skills = [
|
||||
self._make_skill("skill_a", "desc"),
|
||||
self._make_skill("skill_b", "desc"),
|
||||
]
|
||||
query = ""
|
||||
filtered = [
|
||||
s for s in skills
|
||||
if query in s.name.lower()
|
||||
or (s.config.description and query in s.config.description.lower())
|
||||
]
|
||||
assert len(filtered) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LocalComputerUseSession
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLocalComputerUseSession:
|
||||
"""Test LocalComputerUseSession (without actual pyautogui)."""
|
||||
|
||||
def test_inherits_from_base(self):
|
||||
assert issubclass(LocalComputerUseSession, InMemoryComputerUseSession.__mro__[1])
|
||||
|
||||
def test_start_without_pyautogui_raises(self):
|
||||
"""If pyautogui is not installed, start should raise RuntimeError."""
|
||||
import asyncio
|
||||
session = LocalComputerUseSession()
|
||||
with patch.dict("sys.modules", {"pyautogui": None}):
|
||||
with pytest.raises(RuntimeError, match="pyautogui"):
|
||||
asyncio.run(session.start())
|
||||
|
||||
def test_session_manager_with_local_factory(self):
|
||||
manager = ComputerUseSessionManager(session_factory=LocalComputerUseSession)
|
||||
session = manager.get_or_create(session_id="test-local")
|
||||
assert isinstance(session, LocalComputerUseSession)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory session_ttl propagation (regression test)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFactorySessionTTL:
|
||||
"""Ensure session_ttl is propagated through factories."""
|
||||
|
||||
def test_cascade_store_memory_with_custom_ttl(self):
|
||||
store = create_cascade_state_store(backend="memory", session_ttl=7200)
|
||||
assert isinstance(store, InMemoryCascadeStateStore)
|
||||
assert store._session_ttl == 7200
|
||||
|
||||
def test_usage_store_memory_backend(self):
|
||||
store = create_usage_store(backend="memory")
|
||||
assert isinstance(store, InMemoryUsageStore)
|
||||
|
||||
def test_cascade_store_auto_backend(self):
|
||||
store = create_cascade_state_store(backend="auto")
|
||||
assert store._session_ttl == 86400 # default
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
"""Unit tests for UsageStore (U4 — UsageStore Persistence)."""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from agentkit.llm.protocol import TokenUsage
|
||||
from agentkit.llm.providers.usage_store import (
|
||||
InMemoryUsageStore,
|
||||
RedisUsageStore,
|
||||
UsageRecord,
|
||||
UsageBucket,
|
||||
UsageSummary,
|
||||
UsageStore,
|
||||
create_usage_store,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# InMemoryUsageStore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInMemoryUsageStore:
|
||||
"""Tests for InMemoryUsageStore."""
|
||||
|
||||
def test_implements_protocol(self):
|
||||
store = InMemoryUsageStore()
|
||||
assert isinstance(store, UsageStore)
|
||||
|
||||
def test_record_single(self):
|
||||
store = InMemoryUsageStore()
|
||||
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
|
||||
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
|
||||
summary = store.get_usage()
|
||||
assert summary.total_tokens == 150
|
||||
assert summary.total_cost == 0.05
|
||||
assert len(summary.records) == 1
|
||||
|
||||
def test_record_multiple(self):
|
||||
store = InMemoryUsageStore()
|
||||
usage1 = TokenUsage(prompt_tokens=100, completion_tokens=50)
|
||||
usage2 = TokenUsage(prompt_tokens=200, completion_tokens=100)
|
||||
store.record("agent1", "gpt-4", usage1, cost=0.05, latency_ms=200)
|
||||
store.record("agent1", "gpt-4", usage2, cost=0.10, latency_ms=300)
|
||||
summary = store.get_usage()
|
||||
assert summary.total_tokens == 450
|
||||
assert abs(summary.total_cost - 0.15) < 1e-6
|
||||
assert len(summary.records) == 2
|
||||
|
||||
def test_get_usage_by_agent(self):
|
||||
store = InMemoryUsageStore()
|
||||
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
|
||||
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
|
||||
store.record("agent2", "gpt-4", usage, cost=0.05, latency_ms=200)
|
||||
summary = store.get_usage(agent_name="agent1")
|
||||
assert len(summary.records) == 1
|
||||
assert summary.records[0].agent_name == "agent1"
|
||||
|
||||
def test_get_usage_by_time_range(self):
|
||||
store = InMemoryUsageStore()
|
||||
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
|
||||
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
|
||||
|
||||
# Query with start_time in the future — should return empty
|
||||
future = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
summary = store.get_usage(start_time=future)
|
||||
assert len(summary.records) == 0
|
||||
|
||||
# Query with end_time in the past — should return empty
|
||||
past = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
summary = store.get_usage(end_time=past)
|
||||
assert len(summary.records) == 0
|
||||
|
||||
# Query with wide range — should return the record
|
||||
start = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
end = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
summary = store.get_usage(start_time=start, end_time=end)
|
||||
assert len(summary.records) == 1
|
||||
|
||||
def test_get_usage_by_model(self):
|
||||
store = InMemoryUsageStore()
|
||||
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
|
||||
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
|
||||
store.record("agent1", "gpt-3.5", usage, cost=0.01, latency_ms=100)
|
||||
summary = store.get_usage()
|
||||
assert "gpt-4" in summary.by_model
|
||||
assert "gpt-3.5" in summary.by_model
|
||||
assert summary.by_model["gpt-4"]["count"] == 1
|
||||
assert summary.by_model["gpt-3.5"]["count"] == 1
|
||||
|
||||
def test_get_usage_empty(self):
|
||||
store = InMemoryUsageStore()
|
||||
summary = store.get_usage()
|
||||
assert summary.total_tokens == 0
|
||||
assert summary.total_cost == 0.0
|
||||
assert len(summary.records) == 0
|
||||
|
||||
def test_max_records_trimming(self):
|
||||
store = InMemoryUsageStore()
|
||||
store.MAX_RECORDS = 5
|
||||
usage = TokenUsage(prompt_tokens=1, completion_tokens=1)
|
||||
for i in range(10):
|
||||
store.record(f"agent{i}", "gpt-4", usage, cost=0.01, latency_ms=100)
|
||||
assert len(store._records) == 5
|
||||
# Should keep the last 5 records
|
||||
assert store._records[0].agent_name == "agent5"
|
||||
|
||||
def test_usage_record_timestamp(self):
|
||||
store = InMemoryUsageStore()
|
||||
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
|
||||
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
|
||||
rec = store.get_usage().records[0]
|
||||
assert rec.timestamp != ""
|
||||
# Should be parseable as ISO 8601
|
||||
datetime.fromisoformat(rec.timestamp)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UsageRecord / UsageBucket / UsageSummary dataclasses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDataclasses:
|
||||
def test_usage_record_auto_timestamp(self):
|
||||
rec = UsageRecord(
|
||||
agent_name="a", model="m",
|
||||
prompt_tokens=1, completion_tokens=1,
|
||||
total_tokens=2, cost=0.01, latency_ms=100,
|
||||
)
|
||||
assert rec.timestamp != ""
|
||||
|
||||
def test_usage_record_explicit_timestamp(self):
|
||||
rec = UsageRecord(
|
||||
agent_name="a", model="m",
|
||||
prompt_tokens=1, completion_tokens=1,
|
||||
total_tokens=2, cost=0.01, latency_ms=100,
|
||||
timestamp="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
assert rec.timestamp == "2026-01-01T00:00:00+00:00"
|
||||
|
||||
def test_usage_bucket_defaults(self):
|
||||
bucket = UsageBucket()
|
||||
assert bucket.prompt_tokens == 0
|
||||
assert bucket.completion_tokens == 0
|
||||
assert bucket.total_tokens == 0
|
||||
assert bucket.cost == 0.0
|
||||
assert bucket.count == 0
|
||||
|
||||
def test_usage_summary_defaults(self):
|
||||
summary = UsageSummary()
|
||||
assert summary.total_tokens == 0
|
||||
assert summary.total_cost == 0.0
|
||||
assert summary.by_model == {}
|
||||
assert summary.records == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RedisUsageStore (mocked)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRedisUsageStoreMocked:
|
||||
"""Tests for RedisUsageStore with mocked Redis."""
|
||||
|
||||
def _make_store(self):
|
||||
store = RedisUsageStore(redis_url="redis://localhost:6379")
|
||||
return store
|
||||
|
||||
def test_implements_protocol(self):
|
||||
store = self._make_store()
|
||||
assert isinstance(store, UsageStore)
|
||||
|
||||
def test_degrade_to_fallback(self):
|
||||
store = self._make_store()
|
||||
assert not store._degraded
|
||||
store._degrade_to_fallback()
|
||||
assert store._degraded
|
||||
assert store._fallback is not None
|
||||
|
||||
def test_record_degraded_uses_fallback(self):
|
||||
store = self._make_store()
|
||||
store._degrade_to_fallback()
|
||||
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
|
||||
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
|
||||
# Should be in fallback
|
||||
summary = store._fallback.get_usage()
|
||||
assert len(summary.records) == 1
|
||||
|
||||
def test_get_usage_degraded_uses_fallback(self):
|
||||
store = self._make_store()
|
||||
store._degrade_to_fallback()
|
||||
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
|
||||
store._fallback.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
|
||||
summary = store.get_usage()
|
||||
assert len(summary.records) == 1
|
||||
|
||||
def test_get_usage_degraded_no_fallback_returns_empty(self):
|
||||
store = self._make_store()
|
||||
store._degraded = True
|
||||
# No fallback set — should return empty
|
||||
summary = store.get_usage()
|
||||
assert summary.total_tokens == 0
|
||||
|
||||
def test_today_key_format(self):
|
||||
store = self._make_store()
|
||||
key = store._today_key()
|
||||
# Should be YYYY-MM-DD
|
||||
assert len(key) == 10
|
||||
assert key[4] == "-"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateUsageStore:
|
||||
def test_memory_backend(self):
|
||||
store = create_usage_store(backend="memory")
|
||||
assert isinstance(store, InMemoryUsageStore)
|
||||
|
||||
def test_auto_backend_returns_store(self):
|
||||
store = create_usage_store(backend="auto")
|
||||
assert isinstance(store, (InMemoryUsageStore, RedisUsageStore))
|
||||
|
||||
def test_redis_backend_returns_store(self):
|
||||
store = create_usage_store(backend="redis")
|
||||
# May be InMemory if redis package unavailable
|
||||
assert isinstance(store, (InMemoryUsageStore, RedisUsageStore))
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
"""Unit tests for CascadeStateStore (U5 — CascadeStateStore Persistence)."""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from agentkit.quality.cascade_state_store import (
|
||||
CascadeStateStore,
|
||||
InMemoryCascadeStateStore,
|
||||
RedisCascadeStateStore,
|
||||
create_cascade_state_store,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# InMemoryCascadeStateStore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInMemoryCascadeStateStore:
|
||||
"""Tests for InMemoryCascadeStateStore."""
|
||||
|
||||
def test_implements_protocol(self):
|
||||
store = InMemoryCascadeStateStore()
|
||||
assert isinstance(store, CascadeStateStore)
|
||||
|
||||
def test_increment_interaction(self):
|
||||
store = InMemoryCascadeStateStore()
|
||||
assert store.increment_interaction("s1") == 1
|
||||
assert store.increment_interaction("s1") == 2
|
||||
assert store.increment_interaction("s1") == 3
|
||||
|
||||
def test_increment_different_sessions(self):
|
||||
store = InMemoryCascadeStateStore()
|
||||
assert store.increment_interaction("s1") == 1
|
||||
assert store.increment_interaction("s2") == 1
|
||||
assert store.increment_interaction("s1") == 2
|
||||
|
||||
def test_get_interaction(self):
|
||||
store = InMemoryCascadeStateStore()
|
||||
store.increment_interaction("s1")
|
||||
store.increment_interaction("s1")
|
||||
assert store.get_interaction("s1") == 2
|
||||
|
||||
def test_get_interaction_nonexistent(self):
|
||||
store = InMemoryCascadeStateStore()
|
||||
assert store.get_interaction("unknown") == 0
|
||||
|
||||
def test_set_and_get_depth(self):
|
||||
store = InMemoryCascadeStateStore()
|
||||
store.set_depth("s1", 3)
|
||||
assert store.get_depth("s1") == 3
|
||||
|
||||
def test_get_depth_nonexistent(self):
|
||||
store = InMemoryCascadeStateStore()
|
||||
assert store.get_depth("unknown") == 0
|
||||
|
||||
def test_reset(self):
|
||||
store = InMemoryCascadeStateStore()
|
||||
store.increment_interaction("s1")
|
||||
store.increment_interaction("s1")
|
||||
store.set_depth("s1", 2)
|
||||
store.reset("s1")
|
||||
assert store.get_interaction("s1") == 0
|
||||
assert store.get_depth("s1") == 0
|
||||
|
||||
def test_reset_nonexistent_no_error(self):
|
||||
store = InMemoryCascadeStateStore()
|
||||
store.reset("nonexistent") # Should not raise
|
||||
|
||||
def test_ttl_expiry_interaction(self):
|
||||
store = InMemoryCascadeStateStore(session_ttl=1) # 1 second TTL
|
||||
store.increment_interaction("s1")
|
||||
assert store.get_interaction("s1") == 1
|
||||
time.sleep(1.1)
|
||||
# After TTL, should return 0 (expired)
|
||||
assert store.get_interaction("s1") == 0
|
||||
|
||||
def test_ttl_expiry_depth(self):
|
||||
store = InMemoryCascadeStateStore(session_ttl=1)
|
||||
store.set_depth("s1", 5)
|
||||
assert store.get_depth("s1") == 5
|
||||
time.sleep(1.1)
|
||||
assert store.get_depth("s1") == 0
|
||||
|
||||
def test_ttl_cleanup_on_increment(self):
|
||||
store = InMemoryCascadeStateStore(session_ttl=1)
|
||||
store.increment_interaction("s1")
|
||||
store.increment_interaction("s2")
|
||||
time.sleep(1.1)
|
||||
# Incrementing s3 should trigger cleanup of s1 and s2
|
||||
store.increment_interaction("s3")
|
||||
assert "s1" not in store._interaction_counts
|
||||
assert "s2" not in store._interaction_counts
|
||||
assert store._interaction_counts.get("s3") == 1
|
||||
|
||||
def test_touch_refreshes_ttl(self):
|
||||
store = InMemoryCascadeStateStore(session_ttl=2)
|
||||
store.increment_interaction("s1")
|
||||
time.sleep(1.0)
|
||||
# Touch refreshes the timestamp
|
||||
store.increment_interaction("s1")
|
||||
time.sleep(1.0)
|
||||
# Should still be alive (1s < 2s since last touch)
|
||||
assert store.get_interaction("s1") == 2
|
||||
|
||||
def test_default_session_ttl(self):
|
||||
store = InMemoryCascadeStateStore()
|
||||
assert store._session_ttl == 86400
|
||||
|
||||
def test_custom_session_ttl(self):
|
||||
store = InMemoryCascadeStateStore(session_ttl=3600)
|
||||
assert store._session_ttl == 3600
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RedisCascadeStateStore (mocked)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRedisCascadeStateStoreMocked:
|
||||
"""Tests for RedisCascadeStateStore with mocked Redis."""
|
||||
|
||||
def test_implements_protocol(self):
|
||||
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
|
||||
assert isinstance(store, CascadeStateStore)
|
||||
|
||||
def test_degrade_to_fallback(self):
|
||||
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
|
||||
assert not store._degraded
|
||||
store._degrade_to_fallback()
|
||||
assert store._degraded
|
||||
assert isinstance(store._fallback, InMemoryCascadeStateStore)
|
||||
|
||||
def test_increment_degraded_uses_fallback(self):
|
||||
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
|
||||
store._degrade_to_fallback()
|
||||
result = store.increment_interaction("s1")
|
||||
assert result == 1
|
||||
assert store._fallback.get_interaction("s1") == 1
|
||||
|
||||
def test_get_interaction_degraded_uses_fallback(self):
|
||||
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
|
||||
store._degrade_to_fallback()
|
||||
store._fallback.increment_interaction("s1")
|
||||
assert store.get_interaction("s1") == 1
|
||||
|
||||
def test_set_depth_degraded_uses_fallback(self):
|
||||
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
|
||||
store._degrade_to_fallback()
|
||||
store.set_depth("s1", 3)
|
||||
assert store._fallback.get_depth("s1") == 3
|
||||
|
||||
def test_get_depth_degraded_uses_fallback(self):
|
||||
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
|
||||
store._degrade_to_fallback()
|
||||
store._fallback.set_depth("s1", 5)
|
||||
assert store.get_depth("s1") == 5
|
||||
|
||||
def test_reset_degraded_uses_fallback(self):
|
||||
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
|
||||
store._degrade_to_fallback()
|
||||
store._fallback.increment_interaction("s1")
|
||||
store.reset("s1")
|
||||
assert store._fallback.get_interaction("s1") == 0
|
||||
|
||||
def test_increment_redis_failure_degrades(self):
|
||||
store = RedisCascadeStateStore(redis_url="redis://nonexistent:6379")
|
||||
# Will fail to connect and degrade
|
||||
result = store.increment_interaction("s1")
|
||||
assert store._degraded
|
||||
assert result >= 0 # Should return something (fallback or 0)
|
||||
|
||||
def test_custom_session_ttl(self):
|
||||
store = RedisCascadeStateStore(
|
||||
redis_url="redis://localhost:6379", session_ttl=3600
|
||||
)
|
||||
assert store._session_ttl == 3600
|
||||
|
||||
def test_default_session_ttl(self):
|
||||
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
|
||||
assert store._session_ttl == 86400
|
||||
|
||||
def test_increment_with_mock_redis(self):
|
||||
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
|
||||
mock_redis = MagicMock()
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.incr.return_value = mock_pipeline
|
||||
mock_pipeline.expire.return_value = mock_pipeline
|
||||
mock_pipeline.execute.return_value = [1, True]
|
||||
mock_redis.pipeline.return_value = mock_pipeline
|
||||
store._sync_redis = mock_redis
|
||||
|
||||
result = store.increment_interaction("s1")
|
||||
assert result == 1
|
||||
mock_pipeline.incr.assert_called_once()
|
||||
mock_pipeline.expire.assert_called_once()
|
||||
|
||||
def test_get_interaction_with_mock_redis(self):
|
||||
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.get.return_value = "3"
|
||||
store._sync_redis = mock_redis
|
||||
|
||||
result = store.get_interaction("s1")
|
||||
assert result == 3
|
||||
|
||||
def test_get_interaction_redis_returns_none(self):
|
||||
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.get.return_value = None
|
||||
store._sync_redis = mock_redis
|
||||
|
||||
result = store.get_interaction("s1")
|
||||
assert result == 0
|
||||
|
||||
def test_set_depth_with_mock_redis(self):
|
||||
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
|
||||
mock_redis = MagicMock()
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.set.return_value = mock_pipeline
|
||||
mock_pipeline.expire.return_value = mock_pipeline
|
||||
mock_pipeline.execute.return_value = [True, True]
|
||||
mock_redis.pipeline.return_value = mock_pipeline
|
||||
store._sync_redis = mock_redis
|
||||
|
||||
store.set_depth("s1", 3)
|
||||
mock_pipeline.set.assert_called_once()
|
||||
mock_pipeline.expire.assert_called_once()
|
||||
|
||||
def test_reset_with_mock_redis(self):
|
||||
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
|
||||
mock_redis = MagicMock()
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.delete.return_value = mock_pipeline
|
||||
mock_pipeline.execute.return_value = [1, 1]
|
||||
mock_redis.pipeline.return_value = mock_pipeline
|
||||
store._sync_redis = mock_redis
|
||||
|
||||
store.reset("s1")
|
||||
assert mock_pipeline.delete.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateCascadeStateStore:
|
||||
def test_memory_backend(self):
|
||||
store = create_cascade_state_store(backend="memory")
|
||||
assert isinstance(store, InMemoryCascadeStateStore)
|
||||
|
||||
def test_auto_backend_returns_store(self):
|
||||
store = create_cascade_state_store(backend="auto")
|
||||
assert isinstance(store, (InMemoryCascadeStateStore, RedisCascadeStateStore))
|
||||
|
||||
def test_redis_backend_returns_store(self):
|
||||
store = create_cascade_state_store(backend="redis")
|
||||
assert isinstance(store, (InMemoryCascadeStateStore, RedisCascadeStateStore))
|
||||
|
||||
def test_session_ttl_passed_to_redis(self):
|
||||
store = create_cascade_state_store(
|
||||
backend="redis", session_ttl=7200
|
||||
)
|
||||
if isinstance(store, RedisCascadeStateStore):
|
||||
assert store._session_ttl == 7200
|
||||
|
||||
def test_session_ttl_passed_to_memory(self):
|
||||
store = create_cascade_state_store(
|
||||
backend="memory", session_ttl=7200
|
||||
)
|
||||
assert isinstance(store, InMemoryCascadeStateStore)
|
||||
assert store._session_ttl == 7200
|
||||
Loading…
Reference in New Issue