diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 0e0a19e..443fa4a 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, ipcMain, Menu, shell } from 'electron' +import { app, BrowserWindow, ipcMain, Menu, nativeTheme, shell } from 'electron' import { existsSync } from 'node:fs' import { join } from 'node:path' import { registerIpc } from './ipc' @@ -32,13 +32,16 @@ function registerWindowControls(): void { } async function createWindow(): Promise { + // 窗口底色跟随深浅色(加载/resize 时不闪白);构造函数里已按持久化偏好设置过 + // nativeTheme.themeSource,这里用实际生效值 + const dark = nativeTheme.shouldUseDarkColors const win = new BrowserWindow({ width: 1280, height: 840, minWidth: 960, minHeight: 640, show: false, - backgroundColor: '#ffffff', + backgroundColor: dark ? '#212121' : '#ffffff', title: 'Inkreach Agent', // 窗口/任务栏图标(Windows/Linux;macOS 打包时由 build 配置提供 icns) ...(existsSync(APP_ICON) ? { icon: APP_ICON } : {}), @@ -52,6 +55,11 @@ async function createWindow(): Promise { } }) + // 深浅色切换时同步窗口底色(避免暗色下 resize/加载闪白块) + nativeTheme.on('updated', () => { + win.setBackgroundColor(nativeTheme.shouldUseDarkColors ? '#212121' : '#ffffff') + }) + // 最大化状态同步给渲染层(切换「最大化/还原」字形) const sendWinState = (): void => win.webContents.send('win:state', win.isMaximized()) win.on('maximize', sendWinState) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 91e9df5..98f7df1 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -178,19 +178,25 @@ export function registerIpc(): AgentService { ) }) + // 会话末尾分叉为新会话(pi fork,原会话保留) + ipcMain.handle('session:fork', () => pass(() => service.forkSession())) + // ---- 应用设置 ---- ipcMain.handle('settings:get', () => service.getSettings()) - ipcMain.handle('settings:setPrompt', (_event, text: unknown) => { - if (typeof text !== 'string') throw new Error('invalid prompt') - return pass(() => service.setSystemPrompt(text)) - }) + // 当前生效的系统提示词(pi 原生 + 技能注入,只读展示) + ipcMain.handle('settings:getSystemPrompt', () => service.getSystemPrompt()) 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('settings:setThemeMode', (_event, mode: unknown) => { + if (mode !== 'light' && mode !== 'dark' && mode !== 'system') throw new Error('invalid theme mode') + service.setThemeMode(mode) + }) + // ---- 工作区 ---- ipcMain.handle('workspaces:list', () => service.listWorkspaces().catch((err: unknown) => { diff --git a/apps/desktop/src/main/pi/agent-service.ts b/apps/desktop/src/main/pi/agent-service.ts index c70a48a..3b206fe 100644 --- a/apps/desktop/src/main/pi/agent-service.ts +++ b/apps/desktop/src/main/pi/agent-service.ts @@ -1,4 +1,5 @@ import { app, dialog, shell } from 'electron' +import { nativeTheme } from 'electron' import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { @@ -39,6 +40,7 @@ import type { SessionStatsInfo, SkillDetail, ThinkingLevel, + ThemeMode, WorkspaceFile, WorkspaceMeta } from '@shared/types' @@ -63,7 +65,7 @@ export class AgentService { private flushTimer: NodeJS.Timeout | null = null private attached: AgentSession | null = null private unsubscribeAttached: (() => void) | null = null - private settings: AppSettings = { systemPrompt: '' } + private settings: AppSettings = {} /** 当前工作区目录(agent cwd),会话按目录隔离 */ private workspaceDir = '' private approvalsRegistered = false @@ -73,6 +75,13 @@ export class AgentService { private skillsEstimate: { cwd: string; value: number } | null = null readonly approvals = new ApprovalService() + constructor() { + // 设置同步读入 + 应用持久化的深浅色偏好(nativeTheme 会影响渲染层 + // prefers-color-scheme 与原生控件配色;窗口创建前生效可避免首帧闪错色) + this.loadSettings() + nativeTheme.themeSource = this.settings.themeMode ?? 'system' + } + /** 工作区根目录:文档/Inkreach,每个子目录 = 一个工作区 */ get workspacesRoot(): string { return join(app.getPath('documents'), 'Inkreach') @@ -94,7 +103,6 @@ export class AgentService { try { const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial this.settings = { - systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : '', workspace: typeof raw.workspace === 'string' ? raw.workspace : undefined, customWorkspaces: Array.isArray(raw.customWorkspaces) ? raw.customWorkspaces.filter((p): p is string => typeof p === 'string') @@ -102,10 +110,11 @@ export class AgentService { // 历史版本存过 autoEdit/plan,统一收敛回 confirm permissionMode: raw.permissionMode === 'full' ? 'full' : 'confirm', - themeColor: typeof raw.themeColor === 'string' ? raw.themeColor : undefined + themeColor: typeof raw.themeColor === 'string' ? raw.themeColor : undefined, + themeMode: raw.themeMode === 'light' || raw.themeMode === 'dark' ? raw.themeMode : 'system' } } catch { - this.settings = { systemPrompt: '', permissionMode: this.permissionMode } + this.settings = { permissionMode: this.permissionMode, themeMode: 'system' } } this.permissionMode = this.settings.permissionMode ?? 'confirm' this.initWorkspaces() @@ -258,7 +267,6 @@ export class AgentService { cwd: targetCwd, agentDir: targetAgentDir, resourceLoaderOptions: { - ...(this.settings.systemPrompt.trim() ? { systemPrompt: this.settings.systemPrompt } : {}), extensionFactories: [ createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode) ] @@ -1054,17 +1062,45 @@ export class AgentService { this.emitSwitched(session) } + /** 在会话末尾分叉出新会话(pi fork:复制到新会话文件,原会话保留不动)。 + * fork 后 runtime 重挂新会话,rebind 回调负责发 session_switched。 */ + async forkSession(): Promise { + const runtime = await this.getRuntime() + const session = runtime.session + if (session.isStreaming) { + await session.abort() + } + const leafId = session.sessionManager.getLeafId() + if (!leafId) { + throw new Error('会话还没有可分叉的内容') + } + const result = await runtime.fork(leafId, { position: 'at' }) + if (result.cancelled) return + } + // ---- 应用设置 ---- getSettings(): AppSettings { return { ...this.settings } } - /** 更新系统提示词后重建运行时(continueRecent 重新挂载当前最近会话,历史保留) */ - async setSystemPrompt(text: string): Promise { - this.settings = { ...this.settings, systemPrompt: text } + /** 当前生效的系统提示词(pi 原生提示词 + 技能注入,只读展示用) */ + async getSystemPrompt(): Promise { + const runtime = await this.getRuntime() + return runtime.session.systemPrompt + } + + /** 深浅色模式:写入 Electron nativeTheme(渲染层 prefers-color-scheme 随之变化)并持久化 */ + setThemeMode(mode: ThemeMode): void { + this.settings = { ...this.settings, themeMode: mode } + this.saveSettings() + nativeTheme.themeSource = mode + } + + /** 主题色偏好(纯外观,仅持久化;渲染层负责应用) */ + setThemeColor(id: string): void { + this.settings = { ...this.settings, themeColor: id } this.saveSettings() - await this.rebuildRuntime() } /** 切换权限模式:即时生效(审批扩展每笔工具调用实时读取),并广播给渲染层 */ @@ -1078,12 +1114,6 @@ export class AgentService { } } - /** 主题色偏好(纯外观,仅持久化;渲染层负责应用) */ - setThemeColor(id: string): void { - this.settings = { ...this.settings, themeColor: id } - this.saveSettings() - } - private async rebuildRuntime(): Promise { const old = this.runtime this.runtime = null diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 12f048f..8b2fb05 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -18,6 +18,7 @@ import type { SessionMeta, SessionState, ThinkingLevel, + ThemeMode, WorkspaceFile, WorkspaceMeta } from '@shared/types' @@ -76,9 +77,11 @@ const api = { listForkPoints: (): Promise => ipcRenderer.invoke('session:forkPoints'), navigateTree: (entryId: string, options?: NavigateTreeOptions): Promise => ipcRenderer.invoke('session:navigate', entryId, options), + forkSession: (): Promise => ipcRenderer.invoke('session:fork'), getSettings: (): Promise => ipcRenderer.invoke('settings:get'), - setSystemPrompt: (text: string): Promise => ipcRenderer.invoke('settings:setPrompt', text), + getSystemPrompt: (): Promise => ipcRenderer.invoke('settings:getSystemPrompt'), setThemeColor: (id: string): Promise => ipcRenderer.invoke('settings:setTheme', id), + setThemeMode: (mode: ThemeMode): Promise => ipcRenderer.invoke('settings:setThemeMode', mode), resolveApproval: (id: string, approved: boolean): Promise => ipcRenderer.invoke('approval:resolve', id, approved), onEvent: (callback: (event: AgentUiEvent) => void): (() => void) => { diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 06b7ae8..5925804 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -12,7 +12,7 @@ import { SettingsModal } from './components/SettingsModal' import { Search } from './components/Search' import { BrowserPanel } from './components/browser/BrowserPanel' import { SidePanel, type PanelTabIcon } from './components/SidePanel' -import { applyThemeColor } from './lib/theme' +import { applyThemeColor, watchColorScheme } from './lib/theme' // ---- 侧边面板(TabPanel):开合 / 标签 / 宽度,跨启动持久化 ---- @@ -63,6 +63,7 @@ export default function App(): JSX.Element { workspaces, forkPoints, navigating, + forking, send, stop, clearError, @@ -80,7 +81,8 @@ export default function App(): JSX.Element { abortCompaction, setAutoCompaction, dismissCompactionNotice, - navigateTree + navigateTree, + forkSession } = useAgent() const prefillRef = useRef(null) const [settingsOpen, setSettingsOpen] = useState(false) @@ -133,12 +135,15 @@ export default function App(): JSX.Element { })) }, []) - // 启动时应用持久化的主题色 + // 启动时应用持久化的主题色;深浅色由主进程 nativeTheme 决定 prefers-color-scheme, + // 这里监听并切换 .dark 类(设置里改模式 → nativeTheme 变化 → 媒体查询事件 → 生效) useEffect(() => { + const stopWatching = watchColorScheme() void window.api .getSettings() .then((s) => applyThemeColor(s.themeColor)) .catch(() => undefined) + return stopWatching }, []) // 全局快捷键:Ctrl+N 新会话 / Ctrl+K 搜索 / Ctrl+, 设置 / Esc 关闭弹窗 @@ -249,11 +254,13 @@ export default function App(): JSX.Element { compacting={state.compacting} compactionNotice={state.compactionNotice} navigating={navigating} + forking={forking} forkPoints={forkPoints} onDismissError={clearError} onDismissCompactionNotice={dismissCompactionNotice} onAbortCompaction={abortCompaction} onRewind={handleRewind} + onFork={forkSession} onPickSuggestion={handlePickSuggestion} onApplySelection={handleApplySelection} /> diff --git a/apps/desktop/src/renderer/src/components/ChatView.tsx b/apps/desktop/src/renderer/src/components/ChatView.tsx index dfd6207..e05cd2f 100644 --- a/apps/desktop/src/renderer/src/components/ChatView.tsx +++ b/apps/desktop/src/renderer/src/components/ChatView.tsx @@ -1,6 +1,6 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' -import { LuFoldVertical, LuLoaderCircle, LuRotateCw, LuTriangleAlert, LuX } from 'react-icons/lu' +import { LuFoldVertical, LuGitFork, LuLoaderCircle, LuRotateCw, LuTriangleAlert, LuX } from 'react-icons/lu' import type { ApprovalRequest, ForkPoint } from '@shared/types' import type { CompactionNotice, ToolState, UiMessage } from '../hooks/useAgent' import { formatTokens } from '../lib/utils' @@ -147,11 +147,13 @@ export function ChatView({ compacting, compactionNotice, navigating, + forking, forkPoints, onDismissError, onDismissCompactionNotice, onAbortCompaction, onRewind, + onFork, onPickSuggestion, onApplySelection }: { @@ -169,11 +171,15 @@ export function ChatView({ compactionNotice: CompactionNotice | null /** 分支回溯/摘要生成进行中 */ navigating: boolean + /** 会话末尾分叉(pi fork)进行中 */ + forking: boolean forkPoints: ForkPoint[] onDismissError: () => void onDismissCompactionNotice: () => void onAbortCompaction: () => void onRewind: (entryId: string, summarize: boolean) => void + /** 在会话末尾分叉出新会话(原会话保留) */ + onFork: () => void onPickSuggestion: (text: string) => void onApplySelection: (instruction: string, selection: string) => void }): ReactNode { @@ -374,6 +380,25 @@ export function ChatView({ countUserMessages(messages, 0, hidden), hidden === 0 )} + {/* 会话结尾:从此处分叉为新会话(pi fork,原会话保留)。回合进行中不打断,收起入口 */} + {hasContent && !turnActive && !streaming && !compacting && !navigating && ( +
+ +
+ )} ) : (
diff --git a/apps/desktop/src/renderer/src/components/Markdown.tsx b/apps/desktop/src/renderer/src/components/Markdown.tsx index 9272275..3471c96 100644 --- a/apps/desktop/src/renderer/src/components/Markdown.tsx +++ b/apps/desktop/src/renderer/src/components/Markdown.tsx @@ -23,6 +23,7 @@ import { createOnigurumaEngine } from 'shiki/engine/oniguruma' import { bundledLanguages, type BundledLanguage } from 'shiki/langs' import { bundledThemes } from 'shiki/themes' import { CodeBlock, type ShikiLine } from './CodeBlock' +import { useDarkTheme } from '../lib/theme' /** * Markdown 渲染:基于 llm-ui(github.com/richardgill/llm-ui)做块级解析 —— @@ -32,7 +33,8 @@ import { CodeBlock, type ShikiLine } from './CodeBlock' * 代码块用 Shiki 做真实语法高亮(按需加载语言),保留本项目 CodeBlock 的行号/复制/diff 视图。 */ -/** Shiki 高亮器:github-light 主题 + 常用语言(grammar 在运行时按需动态加载) */ +/** Shiki 高亮器:双主题注册(浅色/深色随 .dark 切换,切换时按块重新取词元), + * grammar 在运行时按需动态加载 */ const HIGHLIGHT_LANGS = [ 'typescript', 'javascript', @@ -63,7 +65,7 @@ const HIGHLIGHT_LANGS = [ ] const highlighterPromise: Promise = createHighlighterCore({ - themes: [bundledThemes['github-light']], + themes: [bundledThemes['github-light'], bundledThemes['github-dark']], langs: HIGHLIGHT_LANGS.map((name) => bundledLanguages[name as BundledLanguage]), engine: createOnigurumaEngine(import('shiki/wasm')) }) @@ -220,6 +222,9 @@ const CodeBlockComponent: LLMOutputComponent = ({ blockMatch }) => { const source = code ?? '' const rawLang = (language ?? '').toLowerCase() const shikiLang = LANG_ALIAS[rawLang] ?? (HIGHLIGHT_LANGS.includes(rawLang) ? rawLang : '') + // 主题随深浅色切换:切换时 effect 重跑,可见代码块重新取词元 + const dark = useDarkTheme() + const shikiTheme = dark ? 'github-dark' : 'github-light' const [tokens, setTokens] = useState(null) useEffect(() => { @@ -232,7 +237,7 @@ const CodeBlockComponent: LLMOutputComponent = ({ blockMatch }) => { .then((highlighter) => { if (!alive) return try { - const result = highlighter.codeToTokens(source, { lang: shikiLang, theme: 'github-light' }) + const result = highlighter.codeToTokens(source, { lang: shikiLang, theme: shikiTheme }) setTokens(result.tokens) } catch { setTokens(null) @@ -242,7 +247,7 @@ const CodeBlockComponent: LLMOutputComponent = ({ blockMatch }) => { return () => { alive = false } - }, [source, shikiLang]) + }, [source, shikiLang, shikiTheme]) return (
diff --git a/apps/desktop/src/renderer/src/components/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/SettingsModal.tsx index 0c6cdcb..5d6a6f8 100644 --- a/apps/desktop/src/renderer/src/components/SettingsModal.tsx +++ b/apps/desktop/src/renderer/src/components/SettingsModal.tsx @@ -21,8 +21,8 @@ import { 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' +import type { CustomProviderConfig, ModelCatalog, ModelMeta, ProviderCatalogEntry, ResourceDetails, SessionState, ThemeMode } from '@shared/types' +import { THEME_MODES, THEME_PRESETS, applyThemeColor } from '../lib/theme' type Tab = | 'general' @@ -71,7 +71,7 @@ const PLACEHOLDER: Record = { const TAB_META: Record = { general: { title: '常规', desc: '主题色等外观偏好。' }, model: { title: '模型设置', desc: '' }, - prompt: { title: '系统提示词', desc: '覆盖 Agent 的系统提示词,保存后重建运行时。' }, + prompt: { title: '系统提示词', desc: '当前生效的 pi 原生系统提示词(含技能清单注入),只读。' }, memory: { title: '记忆', desc: PLACEHOLDER.memory }, subagent: { title: '子智能体', desc: PLACEHOLDER.subagent }, plugins: { title: '插件', desc: PLACEHOLDER.plugins }, @@ -88,7 +88,7 @@ function ProviderBadge({ provider }: { provider: ProviderCatalogEntry }): JSX.El if (provider.isOAuth) { return OAuth } - return 未配置 + return 未配置 } function formatContext(n: number): string { @@ -99,12 +99,12 @@ function formatContext(n: number): string { function ComingSoon({ icon: Icon, label }: { icon: IconType; label: string }): ReactNode { return ( -
- +
+ -
{label}
-
该能力正在建设中,敬请期待
+
{label}
+
该能力正在建设中,敬请期待
) } @@ -144,14 +144,14 @@ function ResourcePanel({ return (
-
+
{(['user', 'project'] as const).map((s) => (
{!loading && scoped.length === 0 ? ( -
-
{emptyTitle}
-
{hints[scope]}
+
+
{emptyTitle}
+
{hints[scope]}
) : (
@@ -184,25 +184,25 @@ function ResourcePanel({ type="button" title={`${item.filePath}(点击打开)`} onClick={() => onOpen(item.filePath)} - className="block w-full rounded-card border border-zinc-200 px-3 py-2.5 text-left transition-colors hover:border-zinc-300 hover:bg-zinc-50" + className="block w-full rounded-card border border-line px-3 py-2.5 text-left transition-colors hover:border-line-strong hover:bg-inset" >
- {item.title} + {item.title} {(item.badges ?? []).map((b) => ( {b.text} ))} - + {item.hint || item.filePath}
- {item.desc &&
{item.desc}
} + {item.desc &&
{item.desc}
} ))}
@@ -235,7 +235,7 @@ const CUSTOM_API_OPTIONS: Array<{ value: string; label: string }> = [ ] const customInputCls = - 'w-full rounded-control border border-zinc-300 bg-white px-2.5 py-1.5 text-[12px] text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500' + 'w-full rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 focus:border-ink-3' /** 模型设置:搜索/过滤 + @tanstack/react-virtual 虚拟列表(供应商多、模型上千时滚动不卡顿)。 * 卡片视觉按行拼装:provider 头行圆角上盖、模型行接左右边框、末行圆角下盖,行尾留组间距。 @@ -346,17 +346,17 @@ function ModelSettings({ switch (row.kind) { case 'provider': return ( -
+
- {row.provider.name} + {row.provider.name} {customProviders.some((c) => c.id === row.provider.id) && ( - 自定义 + 自定义 )} @@ -419,25 +419,25 @@ function ModelSettings({ case 'model': { const selected = row.provider.id === currentProvider && row.model.id === currentModel return ( -
+