Compare commits

...

3 Commits

Author SHA1 Message Date
cd70b50180 feat: 添加 opencode 模块和相关服务 2026-03-13 18:39:58 +08:00
96390df254 chore: 添加保存调试日志、修复 time-tracking 类型、简化首页 2026-03-13 18:39:08 +08:00
371d4ce327 fix: 修复 markdown 编辑保存后内容丢失的问题
- 在 saveContent 中缓存 unsavedContent,避免 async 期间的竞态条件
- 在 useMarkdownLogic 中添加 lastContentRef 跟踪内容变化,防止不必要的编辑器更新
2026-03-13 18:38:38 +08:00
12 changed files with 261 additions and 65 deletions

View File

@@ -239,9 +239,18 @@ router.post(
validateBody(saveFileSchema),
asyncHandler(async (req: Request, res: Response) => {
const { path: relPath, content } = req.body
console.log('[save] relPath:', relPath)
console.log('[save] content length:', content?.length)
console.log('[save] content preview:', content?.slice(0, 100))
const { fullPath } = resolveNotebookPath(relPath)
console.log('[save] fullPath:', fullPath)
await fs.mkdir(path.dirname(fullPath), { recursive: true })
console.log('[save] before writeFile')
await fs.writeFile(fullPath, content, 'utf-8')
console.log('[save] after writeFile, checking file...')
const writtenContent = await fs.readFile(fullPath, 'utf-8')
console.log('[save] written content length:', writtenContent?.length)
console.log('[save] written content preview:', writtenContent?.slice(0, 100))
successResponse(res, null)
}),

View File

@@ -183,7 +183,7 @@ class SessionPersistenceService implements SessionPersistence {
try {
const content = await fs.readFile(filePath, 'utf-8')
const data = JSON.parse(content)
data.activeDays = Object.values(data.days).filter(d => d.totalDuration > 0).length
data.activeDays = Object.values(data.days).filter((d: any) => d.totalDuration > 0).length
return data
} catch (err) {
return createEmptyMonthData(year, month)
@@ -195,7 +195,7 @@ class SessionPersistenceService implements SessionPersistence {
try {
const content = await fs.readFile(filePath, 'utf-8')
const data = JSON.parse(content)
data.totalActiveDays = Object.values(data.months).filter(m => m.totalDuration > 0).length
data.totalActiveDays = Object.values(data.months).filter((m: any) => m.totalDuration > 0).length
return data
} catch (err) {
return createEmptyYearData(year)

View File

@@ -389,7 +389,7 @@ class TimeTrackerService {
const yearData = await this.persistence.getYearData(targetYear)
totalDuration = yearData.yearlyTotal
activeDays = Object.values(yearData.months).reduce((sum, m) => {
return sum + Object.entries(m).filter(([_, d]) => (d as { totalDuration: number }).totalDuration > 0).length
return sum + Object.entries(m).filter(([_, d]) => (d as any).totalDuration > 0).length
}, 0)
for (const [month, summary] of Object.entries(yearData.months)) {

View File

@@ -0,0 +1,99 @@
# XCOpenCodeWeb
XCOpenCodeWeb 是一个基于 Web 的 AI 编程助手界面,用于与 OpenCode 服务器交互。
## 快速开始
### 使用单文件 exe推荐
直接下载 `XCOpenCodeWeb.exe`,双击运行:
```bash
# 默认端口 3000
XCOpenCodeWeb.exe
# 指定端口
XCOpenCodeWeb.exe --port 8080
# 查看帮助
XCOpenCodeWeb.exe --help
```
启动后访问 http://localhost:3000
### 从源码运行
需要安装 [Bun](https://bun.sh) 或 Node.js 20+
```bash
# 安装依赖
bun install
# 构建前端
bun run build
# 启动服务器
bun server/index.js --port 3000
```
## 构建单文件 exe
```bash
cd web
bun run build:exe
```
输出:`web/XCOpenCodeWeb.exe`(约 320MB
详细文档:[docs/single-file-exe-build.md](docs/single-file-exe-build.md)
## 项目结构
```
├── ui/ # 前端组件库
├── web/
│ ├── src/ # 前端源码
│ ├── server/ # 后端服务器
│ ├── bin/ # CLI 工具
│ └── dist/ # 构建输出
├── docs/ # 文档
└── AGENTS.md # AI Agent 参考文档
```
## 常用命令
```bash
# 开发模式
bun run dev # 前端热更新
bun run dev:server # 启动开发服务器
# 构建
bun run build # 构建前端
bun run build:exe # 构建单文件 exe
# 代码检查
bun run type-check:web # TypeScript 类型检查
bun run lint:web # ESLint 检查
```
## 依赖
- [Bun](https://bun.sh) - 运行时和打包工具
- [React](https://react.dev) - 前端框架
- [Express](https://expressjs.com) - 后端服务器
- [Tailwind CSS](https://tailwindcss.com) - 样式框架
## 配置
### 环境变量
| 变量 | 说明 |
|------|------|
| `XCOpenCodeWeb_PORT` | 服务器端口(默认 3000 |
| `OPENCODE_HOST` | 外部 OpenCode 服务器地址 |
| `OPENCODE_PORT` | 外部 OpenCode 端口 |
| `OPENCODE_SKIP_START` | 跳过启动 OpenCode |
## 许可证
MIT

View File

@@ -0,0 +1,99 @@
# XCOpenCodeWeb
XCOpenCodeWeb 是一个基于 Web 的 AI 编程助手界面,用于与 OpenCode 服务器交互。
## 快速开始
### 使用单文件 exe推荐
直接下载 `XCOpenCodeWeb.exe`,双击运行:
```bash
# 默认端口 3000
XCOpenCodeWeb.exe
# 指定端口
XCOpenCodeWeb.exe --port 8080
# 查看帮助
XCOpenCodeWeb.exe --help
```
启动后访问 http://localhost:3000
### 从源码运行
需要安装 [Bun](https://bun.sh) 或 Node.js 20+
```bash
# 安装依赖
bun install
# 构建前端
bun run build
# 启动服务器
bun server/index.js --port 3000
```
## 构建单文件 exe
```bash
cd web
bun run build:exe
```
输出:`web/XCOpenCodeWeb.exe`(约 320MB
详细文档:[docs/single-file-exe-build.md](docs/single-file-exe-build.md)
## 项目结构
```
├── ui/ # 前端组件库
├── web/
│ ├── src/ # 前端源码
│ ├── server/ # 后端服务器
│ ├── bin/ # CLI 工具
│ └── dist/ # 构建输出
├── docs/ # 文档
└── AGENTS.md # AI Agent 参考文档
```
## 常用命令
```bash
# 开发模式
bun run dev # 前端热更新
bun run dev:server # 启动开发服务器
# 构建
bun run build # 构建前端
bun run build:exe # 构建单文件 exe
# 代码检查
bun run type-check:web # TypeScript 类型检查
bun run lint:web # ESLint 检查
```
## 依赖
- [Bun](https://bun.sh) - 运行时和打包工具
- [React](https://react.dev) - 前端框架
- [Express](https://expressjs.com) - 后端服务器
- [Tailwind CSS](https://tailwindcss.com) - 样式框架
## 配置
### 环境变量
| 变量 | 说明 |
|------|------|
| `XCOpenCodeWeb_PORT` | 服务器端口(默认 3000 |
| `OPENCODE_HOST` | 外部 OpenCode 服务器地址 |
| `OPENCODE_PORT` | 外部 OpenCode 端口 |
| `OPENCODE_SKIP_START` | 跳过启动 OpenCode |
## 许可证
MIT

Binary file not shown.

View File

@@ -0,0 +1,12 @@
import { defineModule } from '../types.js'
export const OPENCODE_MODULE = defineModule({
id: 'opencode',
name: 'OpenCode',
basePath: '/opencode',
order: 15,
version: '1.0.0',
backend: {
enabled: false,
},
})

View File

@@ -85,6 +85,8 @@ export const useMarkdownLogic = ({
const readOnlyRef = useRef(readOnly)
const ctxRef = useRef<Ctx | null>(null)
const lastContentRef = useRef<string>('')
useEffect(() => {
onChangeRef.current = onChange
}, [])
@@ -117,23 +119,28 @@ export const useMarkdownLogic = ({
}
}, [readOnly])
// 在只读模式下动态更新内容
// 在只读模式下动态更新内容(仅当 content 真正变化时)
useEffect(() => {
if (!ctxRef.current || !readOnly) return
// 只有当 content 真正变化时才更新编辑器
if (content === lastContentRef.current) return
lastContentRef.current = content
try {
const view = ctxRef.current.get(editorViewCtx)
const parser = ctxRef.current.get(parserCtx)
const doc = parser(content)
if (!doc) return
const state = view.state
view.dispatch(state.tr.replaceWith(0, state.doc.content.size, doc))
view.dispatch(view.state.tr.replaceWith(0, view.state.doc.content.size, doc))
} catch {
// 编辑器可能尚未就绪
}
}, [content, readOnly])
return useEditor((root) => {
lastContentRef.current = content
return Editor.make()
.config((ctx) => {
ctxRef.current = ctx

View File

@@ -1,62 +1,7 @@
import { useRef, useEffect, useState } from 'react'
import React from 'react'
export const HomePage = () => {
const webviewRef = useRef<HTMLWebViewElement>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const startOpencodeServer = async () => {
try {
const result = await window.electronAPI?.opencodeStartServer()
if (!result?.success) {
setError(result?.error || 'Failed to start opencode server')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
}
}
startOpencodeServer()
return () => {
window.electronAPI?.opencodeStopServer()
}
}, [])
useEffect(() => {
const webview = webviewRef.current
if (!webview) return
webview.addEventListener('did-fail-load', (e) => {
console.error('[HomePage] Failed to load:', e)
setIsLoading(false)
})
webview.addEventListener('did-finish-load', () => {
setIsLoading(false)
})
}, [])
return (
<div className="h-full w-full relative">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white dark:bg-gray-900 z-10">
<div className="text-gray-500 dark:text-gray-400"> opencode ...</div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-white dark:bg-gray-900 z-10">
<div className="text-red-500">: {error}</div>
</div>
)}
<webview
ref={webviewRef}
src="http://127.0.0.1:4096"
className="w-full h-full"
nodeintegration={false}
webpreferences="contextIsolation=yes"
/>
</div>
<div className="h-full w-full" />
)
}

View File

@@ -0,0 +1,11 @@
import React from 'react'
export const OpenCodePage: React.FC = () => {
return (
<div className="max-w-4xl mx-auto w-full px-14 py-12 pb-40">
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-8 flex items-center gap-2">
OpenCode
</h1>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { Code } from 'lucide-react'
import { OpenCodePage } from './OpenCodePage'
import { OPENCODE_MODULE } from '@shared/modules/opencode'
import { createFrontendModule } from '@/lib/module-registry'
export default createFrontendModule(OPENCODE_MODULE, {
icon: Code,
component: OpenCodePage,
})
export { OpenCodePage }

View File

@@ -331,7 +331,9 @@ export const useTabStore = create<TabStore>((set, get) => {
const isAsyncImportProcessing = isAsyncImportProcessingContent(tab.content)
if (isAsyncImportProcessing) return
await saveFileContent(filePath, tab.unsavedContent)
const contentToSave = tab.unsavedContent
await saveFileContent(filePath, contentToSave)
set((state) => {
const newTabs = new Map(state.tabs)
@@ -339,7 +341,8 @@ export const useTabStore = create<TabStore>((set, get) => {
if (currentTab) {
newTabs.set(filePath, {
...currentTab,
content: currentTab.unsavedContent,
content: contentToSave,
unsavedContent: contentToSave,
isEditing: false,
})
}