wip: 主题系统与设置面板改动 checkpoint
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -92,6 +92,8 @@ export interface AppSettings {
|
||||
customWorkspaces?: string[]
|
||||
/** 权限模式(缺省 confirm) */
|
||||
permissionMode?: PermissionMode
|
||||
/** 主题色预设 id(缺省 default,见 renderer lib/theme.ts) */
|
||||
themeColor?: string
|
||||
}
|
||||
|
||||
/** 权限模式:confirm 变更前确认 / full 完全访问 */
|
||||
|
||||
Reference in New Issue
Block a user