wip: 主题系统与设置面板改动 checkpoint

This commit is contained in:
Rick Ye
2026-09-07 01:21:21 +08:00
parent f8c785afbc
commit 100de55714
12 changed files with 162 additions and 21 deletions
+5
View File
@@ -147,6 +147,11 @@ export function registerIpc(): AgentService {
return pass(() => service.setSystemPrompt(text))
})
ipcMain.handle('settings:setTheme', (_event, id: unknown) => {
if (typeof id !== 'string' || !id.trim()) throw new Error('invalid theme id')
service.setThemeColor(id.trim())
})
// ---- 工作区 ----
ipcMain.handle('workspaces:list', () =>
service.listWorkspaces().catch((err: unknown) => {
+12 -4
View File
@@ -89,7 +89,8 @@ export class AgentService {
: undefined,
// 历史版本存过 autoEdit/plan,统一收敛回 confirm
permissionMode:
raw.permissionMode === 'full' ? 'full' : 'confirm'
raw.permissionMode === 'full' ? 'full' : 'confirm',
themeColor: typeof raw.themeColor === 'string' ? raw.themeColor : undefined
}
} catch {
this.settings = { systemPrompt: '', permissionMode: this.permissionMode }
@@ -649,13 +650,14 @@ export class AgentService {
async listSessions(): Promise<SessionMeta[]> {
const infos = await SessionManager.list(this.cwd)
// 空会话(一条消息都没发过)不上列表:第一句发出之后才在侧栏可见
const metas = infos
.filter((info) => info.messageCount > 0)
.sort((a, b) => b.modified.getTime() - a.modified.getTime())
.map((info) => toSessionMeta(info))
// 新会话在首条消息写入前没有 .jsonl 文件,SessionManager.list 扫不到;
// 把当前活跃会话兜底合并进列表,避免侧栏“新建后不出现”
// 未落盘的活跃会话兜底合并进列表(同样要求已有消息)
const session = this.runtime?.session
if (session && !metas.some((m) => samePath(m.path, session.sessionFile ?? ''))) {
if (session && session.messages.length > 0 && !metas.some((m) => samePath(m.path, session.sessionFile ?? ''))) {
const now = new Date().toISOString()
metas.unshift({
id: session.sessionId,
@@ -876,6 +878,12 @@ export class AgentService {
}
}
/** 主题色偏好(纯外观,仅持久化;渲染层负责应用) */
setThemeColor(id: string): void {
this.settings = { ...this.settings, themeColor: id }
this.saveSettings()
}
private async rebuildRuntime(): Promise<void> {
const old = this.runtime
this.runtime = null
+1
View File
@@ -63,6 +63,7 @@ const api = {
logoutProvider: (providerId: string): Promise<void> => ipcRenderer.invoke('auth:logout', providerId),
getSettings: (): Promise<AppSettings> => ipcRenderer.invoke('settings:get'),
setSystemPrompt: (text: string): Promise<void> => ipcRenderer.invoke('settings:setPrompt', text),
setThemeColor: (id: string): Promise<void> => ipcRenderer.invoke('settings:setTheme', id),
resolveApproval: (id: string, approved: boolean): Promise<void> =>
ipcRenderer.invoke('approval:resolve', id, approved),
onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => {
+11
View File
@@ -8,6 +8,7 @@ import { BottomSlot } from './components/BottomSlot'
import { Composer } from './components/Composer'
import { SettingsModal } from './components/SettingsModal'
import { Search } from './components/Search'
import { applyThemeColor } from './lib/theme'
export default function App(): JSX.Element {
const {
@@ -31,6 +32,14 @@ export default function App(): JSX.Element {
const [settingsOpen, setSettingsOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
// 启动时应用持久化的主题色
useEffect(() => {
void window.api
.getSettings()
.then((s) => applyThemeColor(s.themeColor))
.catch(() => undefined)
}, [])
// 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Esc 关闭弹窗
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
@@ -110,6 +119,8 @@ export default function App(): JSX.Element {
disabled={!state.ready}
model={state.sessionState?.model}
permissionMode={state.sessionState?.permissionMode ?? 'confirm'}
sessionKey={state.sessionState?.sessionId}
sessionMessageCount={state.sessionState?.messageCount}
onSend={send}
onStop={stop}
onNewSession={newSession}
@@ -222,7 +222,7 @@ export function ApprovalCard({
</div>
<div className="-mr-0.5 flex items-center gap-1.5">
<Button variant="ghost" size="sm" onClick={() => settle(false, '已拒绝')}>
<Button variant="quiet" size="sm" onClick={() => settle(false, '已拒绝')}>
跳过
</Button>
<Button
@@ -11,26 +11,19 @@ const SUGGESTIONS = [
'查询最近一次上架任务的执行结果'
]
/** 空状态:大标题 + 建议词(beautifului Streaming Text 的 follow-ups 行样式) */
/** 空状态:一行小标题 + 三条建议(纯文本行,点击填入输入框) */
function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
return (
<div className="flex h-full flex-col items-center justify-center px-8 text-center">
<h2 className="mb-2 text-[32px] font-semibold leading-normal text-ink">今天想做点什么?</h2>
<p className="mb-8 max-w-md text-base leading-normal text-ink-3">
描述你的目标,Agent 会按 SOP 调用工具完成;也可以随时切为手动逐步执行。
</p>
<div className="grid w-full max-w-md flex-col">
<div className="flex h-full flex-col items-center justify-center gap-4 px-8 text-center">
<h2 className="text-[19px] font-medium text-accent-ink">今天想做点什么?</h2>
<div className="flex flex-col items-stretch">
{SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => onPick(s)}
className="-mx-1.5 flex items-center gap-2 rounded-[7px] border-b border-line px-1.5 py-1.5 text-left text-[12.5px] text-ink transition-colors duration-100 hover:bg-hover-2"
className="rounded-control px-2 py-1 text-[13px] text-ink-3 transition-colors duration-100 hover:bg-hover-2 hover:text-ink-2"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M9 10l-5 5 5 5" />
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
</svg>
{s}
</button>
))}
@@ -23,6 +23,24 @@ interface LocalCommand {
/** 图片附件大小上限(与主进程 pickAttachments 一致) */
const MAX_IMAGE_BYTES = 8 * 1024 * 1024
// ---- 输入草稿:未发送的输入实时保存;新建的空会话自动复用,发送后清除 ----
const DRAFT_KEY = 'aso-composer-draft'
function loadDraft(): string {
try {
return localStorage.getItem(DRAFT_KEY) ?? ''
} catch {
return ''
}
}
function saveDraft(text: string): void {
try {
if (text) localStorage.setItem(DRAFT_KEY, text)
else localStorage.removeItem(DRAFT_KEY)
} catch {
// 存储不可用时静默放弃(草稿是尽力而为)
}
}
/** 输入框锚定菜单:portal 到 body 固定定位(BottomSlot 的 overflow-hidden 会裁剪普通弹层) */
function ComposerMenu({
anchor,
@@ -64,6 +82,8 @@ export function Composer({
disabled,
model,
permissionMode = 'confirm',
sessionKey,
sessionMessageCount,
onSend,
onStop,
onNewSession,
@@ -78,6 +98,10 @@ export function Composer({
disabled: boolean
model?: ModelInfo
permissionMode?: PermissionMode
/** 当前会话 id(切换时触发草稿复用) */
sessionKey?: string
/** 当前会话消息数(0 = 新建未发言的空会话) */
sessionMessageCount?: number
onSend: (text: string, images?: { mimeType: string; data: string }[]) => void
onStop: () => void
onNewSession: () => void
@@ -358,6 +382,24 @@ export function Composer({
}
}, [prefillRef])
// 草稿实时保存(发送后输入框清空,草稿随之清除)
useEffect(() => {
saveDraft(value)
}, [value])
// 切换到空(新建未发言)会话且输入为空时,复用上次未发送的草稿
const prevSessionRef = useRef(sessionKey)
useEffect(() => {
const prev = prevSessionRef.current
prevSessionRef.current = sessionKey
if (prev === sessionKey) return
if ((sessionMessageCount ?? 0) !== 0) return
if (value.trim()) return
const draft = loadDraft()
if (draft) setValue(draft)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionKey, sessionMessageCount])
// 自适应高度
useEffect(() => {
const el = textareaRef.current
@@ -16,15 +16,18 @@ import {
LuSettings2,
LuSparkles,
LuTerminal,
LuTextQuote,
LuWebhook,
LuX
} from 'react-icons/lu'
import type { IconType } from 'react-icons'
import type { CustomProviderConfig, ModelCatalog, ModelMeta, ProviderCatalogEntry, ResourceDetails, SessionState } from '@shared/types'
import { THEME_PRESETS, applyThemeColor } from '../lib/theme'
type Tab =
| 'general'
| 'model'
| 'prompt'
| 'memory'
| 'subagent'
| 'plugins'
@@ -39,7 +42,8 @@ const NAV: Array<{ group: string; items: Array<{ id: Tab; label: string; icon: I
group: '基础设置',
items: [
{ id: 'general', label: '常规', icon: LuSettings2 },
{ id: 'model', label: '模型设置', icon: LuCpu }
{ id: 'model', label: '模型设置', icon: LuCpu },
{ id: 'prompt', label: '系统提示词', icon: LuTextQuote }
]
},
{
@@ -65,8 +69,9 @@ const PLACEHOLDER: Record<string, string> = {
}
const TAB_META: Record<Tab, { title: string; desc: string }> = {
general: { title: '常规', desc: '管理 Agent 的系统提示词与基础行为。' },
general: { title: '常规', desc: '主题色等外观偏好。' },
model: { title: '模型设置', desc: '' },
prompt: { title: '系统提示词', desc: '覆盖 Agent 的系统提示词,保存后重建运行时。' },
memory: { title: '记忆', desc: PLACEHOLDER.memory },
subagent: { title: '子智能体', desc: PLACEHOLDER.subagent },
plugins: { title: '插件', desc: PLACEHOLDER.plugins },
@@ -618,6 +623,7 @@ export function SettingsModal({
const [keyValue, setKeyValue] = useState('')
const [prompt, setPrompt] = useState('')
const [promptDirty, setPromptDirty] = useState(false)
const [theme, setTheme] = useState('default')
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null)
const [resources, setResources] = useState<ResourceDetails | null>(null)
@@ -701,6 +707,7 @@ export function SettingsModal({
.then((s) => {
setPrompt(s.systemPrompt)
setPromptDirty(false)
setTheme(s.themeColor ?? 'default')
})
.catch(() => undefined)
setNotice(null)
@@ -782,6 +789,36 @@ export function SettingsModal({
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-6 pb-4">
{tab === 'general' && (
<div className="flex flex-col gap-6">
<div>
<div className="text-[12.5px] font-medium text-zinc-700">主题色</div>
<div className="mt-1 text-[11.5px] text-zinc-400">影响链接、选中态、焦点环等强调色,点击即时生效。</div>
<div className="mt-3 flex flex-wrap items-center gap-3">
{THEME_PRESETS.map((p) => (
<button
key={p.id}
type="button"
title={p.label}
aria-label={`主题色:${p.label}`}
onClick={() => {
setTheme(p.id)
applyThemeColor(p.id)
void window.api.setThemeColor(p.id).catch(() => undefined)
}}
className={`flex size-8 items-center justify-center rounded-full transition-transform hover:scale-105 ${
theme === p.id ? 'ring-2 ring-zinc-900 ring-offset-2' : 'ring-1 ring-zinc-200'
}`}
style={{ background: p.accent }}
>
{theme === p.id && <LuCheck size={14} className="text-white drop-shadow" />}
</button>
))}
</div>
</div>
</div>
)}
{tab === 'prompt' && (
<div className="flex h-full min-h-0 flex-col">
<textarea
value={prompt}
@@ -905,7 +942,7 @@ export function SettingsModal({
/>
)}
{tab !== 'general' && tab !== 'model' && tab !== 'skills' && tab !== 'commands' && (
{tab !== 'general' && tab !== 'model' && tab !== 'prompt' && tab !== 'skills' && tab !== 'commands' && (
<ComingSoon icon={NAV[1].items.find((i) => i.id === tab)?.icon ?? LuPuzzle} label={meta.title} />
)}
</div>
@@ -311,7 +311,9 @@ export function useAgent(): UseAgent {
dispatch({ type: 'event', event })
// 会话集合可能在后台变化(新建/删除/重命名),切换后刷新列表;
// 每个回合结束(stats)也刷新一次,让新会话首条消息落盘后的元数据及时更新
if (event.type === 'session_switched' || event.type === 'stats') {
const firstUserLanded =
event.type === 'message_end' && readRole(event.message) === 'user'
if (event.type === 'session_switched' || event.type === 'stats' || firstUserLanded) {
void window.api
.listSessions()
.then(setSessions)
+1
View File
@@ -56,6 +56,7 @@ declare global {
logoutProvider: (providerId: string) => Promise<void>
getSettings: () => Promise<AppSettings>
setSystemPrompt: (text: string) => Promise<void>
setThemeColor: (id: string) => Promise<void>
resolveApproval: (id: string, approved: boolean) => Promise<void>
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
}
@@ -0,0 +1,39 @@
/**
* 主题色(accent):运行时覆盖 :root 的 --accent / --accent-ink / --accent-tint 三个变量,
* Tailwind 的 bg-accent / text-accent-ink / bg-accent-tint 等全部随之生效。
* accent-ink 为加深版(保证链接/可交互文本 AA 对比度),tint 为低透明度选中底。
*/
export interface ThemePreset {
id: string
label: string
accent: string
ink: string
tint: string
}
export const THEME_PRESETS: ThemePreset[] = [
{ id: 'default', label: '默认灰紫', accent: '#8e8ea0', ink: '#707084', tint: 'rgba(142, 142, 160, 0.12)' },
{ id: 'blue', label: '蓝', accent: '#5b8def', ink: '#3a6cd4', tint: 'rgba(91, 141, 239, 0.12)' },
{ id: 'green', label: '绿', accent: '#48a878', ink: '#2f7d57', tint: 'rgba(72, 168, 120, 0.13)' },
{ id: 'orange', label: '橙', accent: '#e08a3c', ink: '#b05f1a', tint: 'rgba(224, 138, 60, 0.14)' },
{ id: 'red', label: '红', accent: '#d95c5c', ink: '#ad3636', tint: 'rgba(217, 92, 92, 0.12)' },
{ id: 'purple', label: '紫', accent: '#8e7cd8', ink: '#6a56b8', tint: 'rgba(142, 124, 216, 0.13)' },
{ id: 'cyan', label: '青', accent: '#38a3a8', ink: '#257b80', tint: 'rgba(56, 163, 168, 0.12)' },
{ id: 'black', label: '墨黑', accent: '#3a3a3f', ink: '#17171a', tint: 'rgba(23, 23, 26, 0.08)' }
]
/** 应用主题预设(undefined/未知 id = 恢复默认,移除内联覆盖) */
export function applyThemeColor(id: string | undefined): void {
const preset = THEME_PRESETS.find((p) => p.id === id)
const root = document.documentElement
if (!preset || preset.id === 'default') {
root.style.removeProperty('--accent')
root.style.removeProperty('--accent-ink')
root.style.removeProperty('--accent-tint')
return
}
root.style.setProperty('--accent', preset.accent)
root.style.setProperty('--accent-ink', preset.ink)
root.style.setProperty('--accent-tint', preset.tint)
}
+2
View File
@@ -92,6 +92,8 @@ export interface AppSettings {
customWorkspaces?: string[]
/** 权限模式(缺省 confirm) */
permissionMode?: PermissionMode
/** 主题色预设 id(缺省 default,见 renderer lib/theme.ts) */
themeColor?: string
}
/** 权限模式:confirm 变更前确认 / full 完全访问 */