From 19aba25c8f6b60a96f5dcbefac6042c5e21c0991 Mon Sep 17 00:00:00 2001 From: 7418 <7418@openclaw.ai> Date: Fri, 6 Mar 2026 20:31:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20assistant=20workspace=20=E2=80=94=20per?= =?UTF-8?q?sistent=20AI=20personality=20&=20memory=20via=20Markdown=20file?= =?UTF-8?q?s=20(#165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "Assistant Workspace" feature that lets users designate a directory (e.g. Obsidian vault) containing 4 Markdown files (soul.md, user.md, memory.md, claude.md) to define AI personality, user profile, long-term memory, and execution rules. These files are automatically injected into the system prompt for assistant project sessions. Core library (src/lib/assistant-workspace.ts): - Workspace validation, initialization with template files - File loading with 8000-char truncation, XML prompt assembly (40k limit) - State management via .assistant/state.json - Directory docs generation (README.ai.md + PATH.ai.md) - Case-insensitive file name fallback (soul.md → SOUL.md) System prompt injection (src/app/api/chat/route.ts): - Scoped to sessions where working_directory === workspace path - Workspace prompt prepended before session-level system prompt - Onboarding/check-in instructions injected as - autoTrigger flag skips user message save and title update Chat-based onboarding (10 questions) & daily check-in (3 questions): - AI conducts conversation, outputs structured completion code blocks - Frontend detects onboarding-complete/checkin-complete blocks - Backend calls Claude API to generate file content from answers - Fallback to raw answers if API call fails Auto-trigger pipeline: - Mount trigger (500ms delay) + focus fallback in MessageInput - assistantTriggerFiredRef prevents duplicate triggers per mount - hookTriggeredSessionId in state.json prevents cross-reload duplicates - Daily check-in only triggers in the most recent workspace session - hookTriggeredSessionId cleared via __clear__ sentinel after completion API endpoints: - GET/PUT /api/settings/workspace — path CRUD + file status - POST /api/workspace/session — create (onboarding) or reuse (check-in) - POST /api/workspace/onboarding — AI-generate soul.md + user.md - POST /api/workspace/checkin — AI-update memory.md + user.md - POST /api/workspace/hook-triggered — set/clear trigger guard - GET /api/workspace/latest-session — latest session lookup - POST /api/workspace/docs — refresh directory documentation UI: - Settings "Assistant" tab with workspace path, file status, controls - Violet badge on workspace project group in ChatListPanel - useAssistantWorkspace hook for state queries Also includes: - 102 passing tests (16 new regression tests for workspace logic) - i18n keys for en.ts and zh.ts - Handover documentation and execution plan Co-Authored-By: Claude Opus 4.6 --- .../completed/assistant-workspace.md | 118 ++++++ docs/handover/README.md | 1 + docs/handover/assistant-workspace.md | 87 +++++ .../unit/assistant-workspace.test.ts | 211 +++++++++++ src/app/api/chat/route.ts | 127 +++++-- src/app/api/settings/workspace/route.ts | 66 ++++ src/app/api/workspace/checkin/route.ts | 96 +++++ src/app/api/workspace/docs/route.ts | 24 ++ src/app/api/workspace/hook-triggered/route.ts | 21 ++ src/app/api/workspace/latest-session/route.ts | 11 + src/app/api/workspace/onboarding/route.ts | 106 ++++++ src/app/api/workspace/session/route.ts | 33 ++ src/components/chat/ChatView.tsx | 125 ++++++- src/components/chat/MessageInput.tsx | 13 + src/components/layout/ChatListPanel.tsx | 9 +- .../settings/AssistantWorkspaceSection.tsx | 338 ++++++++++++++++++ src/components/settings/SettingsLayout.tsx | 7 +- src/hooks/useAssistantWorkspace.ts | 93 +++++ src/i18n/en.ts | 44 +++ src/i18n/zh.ts | 44 +++ src/lib/assistant-workspace.ts | 233 ++++++++++++ src/lib/db.ts | 7 + src/lib/stream-session-manager.ts | 3 + src/types/index.ts | 19 + 24 files changed, 1812 insertions(+), 24 deletions(-) create mode 100644 docs/exec-plans/completed/assistant-workspace.md create mode 100644 docs/handover/assistant-workspace.md create mode 100644 src/__tests__/unit/assistant-workspace.test.ts create mode 100644 src/app/api/settings/workspace/route.ts create mode 100644 src/app/api/workspace/checkin/route.ts create mode 100644 src/app/api/workspace/docs/route.ts create mode 100644 src/app/api/workspace/hook-triggered/route.ts create mode 100644 src/app/api/workspace/latest-session/route.ts create mode 100644 src/app/api/workspace/onboarding/route.ts create mode 100644 src/app/api/workspace/session/route.ts create mode 100644 src/components/settings/AssistantWorkspaceSection.tsx create mode 100644 src/hooks/useAssistantWorkspace.ts create mode 100644 src/lib/assistant-workspace.ts diff --git a/docs/exec-plans/completed/assistant-workspace.md b/docs/exec-plans/completed/assistant-workspace.md new file mode 100644 index 0000000..49758e0 --- /dev/null +++ b/docs/exec-plans/completed/assistant-workspace.md @@ -0,0 +1,118 @@ +# 助理工作区(Assistant Workspace) + +> 创建时间:2026-03-06 +> 最后更新:2026-03-06 + +## 状态 + +| Phase | 内容 | 状态 | +|-------|------|------| +| 1 | 类型定义 + 核心工作区库 | ✅ 已完成 | +| 2 | 系统提示词注入 | ✅ 已完成 | +| 3 | API 端点(workspace / onboarding / checkin / session / hook-triggered) | ✅ 已完成 | +| 4 | i18n(~48 个翻译键) | ✅ 已完成 | +| 5 | Settings UI — 助理标签页 | ✅ 已完成 | +| 6 | 对话式引导 + 自动触发 + 兜底触发 | ✅ 已完成 | +| 7 | 目录文档生成(README.ai.md + PATH.ai.md) | ✅ 已完成 | +| 8 | 回归测试(16 case) | ✅ 已完成 | + +## 功能概述 + +用户指定一个目录(如 Obsidian vault)作为助理工作区,存放 4 份 Markdown 文件定义 AI 人格和记忆: + +| 文件 | 用途 | +|------|------| +| `claude.md` | 执行规则 | +| `soul.md` | 人格风格 | +| `user.md` | 用户画像 | +| `memory.md` | 长期记忆 | + +状态持久化在 `.assistant/state.json`(与目录一起可移植)。 + +## 架构决策 + +### 对话式引导(非 Dialog UI) + +最初实现用 Dialog 弹窗完成引导问卷,后改为通过自然对话完成: +- 点击"开始引导"→ 创建助理项目会话 → 自动发送触发消息 → AI 逐题提问 +- 输入框 focus 作为兜底触发器(防止 mount 时因各种原因未触发) +- `hookTriggeredSessionId` 状态位防止同一会话重复触发 + +### 确定性落盘 + +AI 在对话中收集完答案后输出结构化完成信号(`onboarding-complete` / `checkin-complete` 代码块),前端检测到后调用后端 API 确定性写入文件,不依赖模型自行操作文件系统。 + +### Prompt 作用域隔离 + +工作区 prompt(soul/user/memory/claude.md)仅注入 `working_directory === assistant_workspace_path` 的会话,普通项目不受影响。 + +### 引导与问询互斥 + +- `needsDailyCheckIn()` 在 `onboardingComplete === false` 时返回 false +- onboarding 完成时同时设置 `lastCheckInDate = today`,当天不再触发每日问询 + +### 每日问询复用会话 + +每日问询优先打开该工作区最后一条会话继续提问(`getLatestSessionByWorkingDirectory`),不新建会话。仅引导问卷新建会话。 + +## 文件清单 + +### 新增文件 + +| 文件 | 说明 | +|------|------| +| `src/lib/assistant-workspace.ts` | 核心库:文件加载/截断/拼接/状态管理/目录文档生成 | +| `src/app/api/settings/workspace/route.ts` | 工作区路径 CRUD | +| `src/app/api/workspace/onboarding/route.ts` | 引导问卷 API | +| `src/app/api/workspace/checkin/route.ts` | 每日问询 API | +| `src/app/api/workspace/session/route.ts` | 会话创建/复用(onboarding 新建,checkin 复用) | +| `src/app/api/workspace/hook-triggered/route.ts` | 防重复触发状态 API | +| `src/app/api/workspace/docs/route.ts` | 目录文档生成 API | +| `src/components/settings/AssistantWorkspaceSection.tsx` | 设置面板主组件 | +| `src/hooks/useAssistantWorkspace.ts` | 工作区状态 Hook | +| `src/__tests__/unit/assistant-workspace.test.ts` | 16 个回归测试 | + +### 修改文件 + +| 文件 | 改动 | +|------|------| +| `src/types/index.ts` | `AssistantWorkspaceState`、`AssistantWorkspaceFiles` 接口,`SETTING_KEYS` | +| `src/app/api/chat/route.ts` | 工作区 prompt 加载(限助理项目)+ 引导/问询指令注入 | +| `src/lib/db.ts` | `getLatestSessionByWorkingDirectory()` | +| `src/components/chat/ChatView.tsx` | 自动触发 + 完成信号检测 | +| `src/components/chat/MessageInput.tsx` | focus 兜底触发 | +| `src/components/settings/SettingsLayout.tsx` | 助理标签 | +| `src/components/layout/ChatListPanel.tsx` | 助理项目徽标 | +| `src/i18n/en.ts` / `src/i18n/zh.ts` | ~48 个翻译键 | + +## 数据流 + +``` +用户点击"开始引导" + → POST /api/workspace/session { mode: 'onboarding' } + → 创建新会话,跳转 /chat/{id} + → ChatView mount → checkAssistantTrigger() + → 检测 workingDirectory === workspacePath && !onboardingComplete + → POST /api/workspace/hook-triggered { sessionId } + → sendMessage("请开始助理引导设置。") + → chat/route.ts 检测 isAssistantProject + !state.onboardingComplete + → 注入 onboarding 系统提示 + → AI 逐题提问,用户逐题回答 + → AI 输出 ```onboarding-complete {...}``` + → ChatView.detectAssistantCompletion() 解析 JSON + → POST /api/workspace/onboarding { answers } + → 后端 AI 生成 soul.md + user.md → 写入文件 + → state.onboardingComplete = true, lastCheckInDate = today +``` + +## 测试覆盖 + +| 测试 | 验证行为 | +|------|---------| +| initializeWorkspace creates state.json | 初始化写入 state.json + 4 个模板文件 | +| onboarding auto-trigger detection | 新工作区检测到需要引导 | +| hookTriggeredSessionId prevents repeat | 同一会话不重复触发 | +| daily check-in respects onboarding state | 未完成引导不触发问询;完成当天不触发 | +| workspace prompt scoping | 有内容时生成 prompt,空目录返回空 | +| session reuse for daily check-in | `getLatestSessionByWorkingDirectory` 正确查询 | +| generateDirectoryDocs | 同时生成 README.ai.md 和 PATH.ai.md | diff --git a/docs/handover/README.md b/docs/handover/README.md index d774d8d..37c268e 100644 --- a/docs/handover/README.md +++ b/docs/handover/README.md @@ -10,3 +10,4 @@ |------|------| | agent-tooling-todo-bridge.md | SDK → SSE → DB 事件流、TodoWrite 字段映射、去重策略 | | bridge-system.md | 多 IM 远程桥接系统架构(目录结构、数据流、设计决策) | +| assistant-workspace.md | 助理工作区:人格/记忆文件、对话式引导、自动触发、确定性落盘 | diff --git a/docs/handover/assistant-workspace.md b/docs/handover/assistant-workspace.md new file mode 100644 index 0000000..88ad90b --- /dev/null +++ b/docs/handover/assistant-workspace.md @@ -0,0 +1,87 @@ +# 助理工作区 — 交接文档 + +## 概述 + +助理工作区让用户指定一个目录存放 AI 人格/记忆文件(soul.md、user.md、memory.md、claude.md),在助理项目会话中自动注入系统提示词。附带对话式引导问卷(10 题)和每日轻量问询(3 题)。 + +## 目录结构 + +``` +workspace/ +├── claude.md # 执行规则 +├── soul.md # 人格风格 +├── user.md # 用户画像 +├── memory.md # 长期记忆 +└── .assistant/ + └── state.json # 状态持久化 +``` + +## 核心模块 + +### `src/lib/assistant-workspace.ts` + +| 函数 | 职责 | +|------|------| +| `validateWorkspace(dir)` | 检查目录和文件状态 | +| `initializeWorkspace(dir)` | 补建缺失模板 + state.json | +| `loadWorkspaceFiles(dir)` | 读 4 文件,每文件上限 8000 chars | +| `assembleWorkspacePrompt(files)` | XML 标签拼接,总上限 40000 chars | +| `loadState(dir)` / `saveState(dir, state)` | 读写 `.assistant/state.json` | +| `needsDailyCheckIn(state)` | 判断是否需要每日问询(前置检查 onboarding 完成) | +| `generateDirectoryDocs(dir)` | 扫描子目录生成 README.ai.md + PATH.ai.md | + +文件兼容映射:lowercase canonical → uppercase fallback(如 `soul.md` → `Soul.md` → `SOUL.md`)。 + +### Prompt 注入(`src/app/api/chat/route.ts`) + +仅当 `session.working_directory === getSetting('assistant_workspace_path')` 时注入: +1. 工作区文件内容(`` XML) +2. 引导/问询指令(``) + +普通项目聊天完全不受影响。 + +### 自动触发链路 + +1. **Mount 触发**:`ChatView` mount 时 500ms 延迟检测,使用 `startStream()` 直接发送(非 `sendMessage`) +2. **Focus 兜底**:`MessageInput` onFocus 首次触发,防 mount 未生效 +3. **防重复**:`assistantTriggerFiredRef`(组件级 ref)防止同一 mount 周期内重复触发;`state.hookTriggeredSessionId` 按 session 粒度防止跨页面重复 +4. **autoTrigger 标志**:触发时携带 `autoTrigger: true`,后端跳过保存用户消息和标题更新,实现"AI 先说话"体验 +5. **hookTriggeredSessionId 清理**:引导/问询完成后前端调用 `POST /api/workspace/hook-triggered` 发送 `{ sessionId: '__clear__' }` 清除标记,确保下次进入可再次触发 +6. **最新会话校验**:每日问询仅在该工作区最新会话中触发,旧会话打开不会劫持问询(通过 `GET /api/workspace/latest-session` 校验) + +### 确定性落盘 + +AI 在对话中输出 `onboarding-complete` / `checkin-complete` 代码块 → 前端 `detectAssistantCompletion()` 解析 → 调 POST `/api/workspace/onboarding` 或 `/api/workspace/checkin` → 后端 AI 生成文件内容(失败回退原始答案)→ 清除 `hookTriggeredSessionId`。 + +## API 端点 + +| 路由 | 方法 | 职责 | +|------|------|------| +| `/api/settings/workspace` | GET | 返回路径 + 文件状态 + state | +| `/api/settings/workspace` | PUT | 保存路径,可选初始化 | +| `/api/workspace/session` | POST | 创建或复用助理会话(onboarding 新建,checkin 复用) | +| `/api/workspace/onboarding` | POST | 接收答案 → AI 生成 soul.md + user.md | +| `/api/workspace/checkin` | POST | 接收答案 → AI 更新 memory.md + user.md | +| `/api/workspace/hook-triggered` | POST | 设置/清除 hookTriggeredSessionId(`__clear__` 哨兵值清除) | +| `/api/workspace/latest-session` | GET | 返回指定 workingDirectory 的最新会话 ID | +| `/api/workspace/docs` | POST | 刷新目录文档 | + +## 状态字段(`.assistant/state.json`) + +```typescript +interface AssistantWorkspaceState { + onboardingComplete: boolean; + lastCheckInDate: string | null; // "YYYY-MM-DD" + schemaVersion: number; // 当前 1 + hookTriggeredSessionId?: string; // 防重复触发 +} +``` + +## 关键约束 + +- 引导与问询互斥:onboarding 完成当天不触发 daily check-in +- 每日问询复用最后一条会话,不新建 +- `hookTriggeredSessionId` 按 session 粒度防重复,换 session 可重新触发;完成后自动清除 +- `autoTrigger` 贯穿 ChatView → stream-session-manager → chat/route.ts,跳过用户消息保存和标题更新 +- 每日问询不受 `messages.length > 0` 限制,可在有历史消息的复用会话中触发 +- 文件截断策略:每文件 8000 chars(head 6000 + truncated marker + tail 1800) diff --git a/src/__tests__/unit/assistant-workspace.test.ts b/src/__tests__/unit/assistant-workspace.test.ts new file mode 100644 index 0000000..62b6b01 --- /dev/null +++ b/src/__tests__/unit/assistant-workspace.test.ts @@ -0,0 +1,211 @@ +/** + * Unit tests for assistant workspace. + * + * Run with: npx tsx --test src/__tests__/unit/assistant-workspace.test.ts + * + * Tests verify: + * 1. Auto-trigger: onboarding detects correctly for new workspace + * 2. Input focus fallback: hookTriggeredSessionId prevents repeat + * 3. Daily check-in: needsDailyCheckIn respects onboarding state + * 4. Workspace prompt scoping: only assistant project sessions get prompts + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; + +// Set a temp data dir before importing db module +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codepilot-workspace-test-')); +process.env.CLAUDE_GUI_DATA_DIR = tmpDir; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const { + initializeWorkspace, + loadState, + saveState, + needsDailyCheckIn, + loadWorkspaceFiles, + assembleWorkspacePrompt, + generateDirectoryDocs, +} = require('../../lib/assistant-workspace') as typeof import('../../lib/assistant-workspace'); + +const { createSession, getLatestSessionByWorkingDirectory, closeDb } = require('../../lib/db') as typeof import('../../lib/db'); + +describe('Assistant Workspace', () => { + let workDir: string; + + beforeEach(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'assistant-ws-')); + }); + + afterEach(() => { + fs.rmSync(workDir, { recursive: true, force: true }); + }); + + describe('initializeWorkspace creates state.json', () => { + it('should create .assistant/state.json on init', () => { + initializeWorkspace(workDir); + const statePath = path.join(workDir, '.assistant', 'state.json'); + assert.ok(fs.existsSync(statePath), 'state.json should exist after init'); + + const state = JSON.parse(fs.readFileSync(statePath, 'utf-8')); + assert.equal(state.onboardingComplete, false); + assert.equal(state.lastCheckInDate, null); + assert.equal(state.schemaVersion, 1); + }); + + it('should create all 4 template files', () => { + initializeWorkspace(workDir); + assert.ok(fs.existsSync(path.join(workDir, 'claude.md'))); + assert.ok(fs.existsSync(path.join(workDir, 'soul.md'))); + assert.ok(fs.existsSync(path.join(workDir, 'user.md'))); + assert.ok(fs.existsSync(path.join(workDir, 'memory.md'))); + }); + }); + + describe('onboarding auto-trigger detection', () => { + it('should detect onboarding needed for fresh workspace', () => { + initializeWorkspace(workDir); + const state = loadState(workDir); + assert.equal(state.onboardingComplete, false); + // needsDailyCheckIn should return false when onboarding not done + assert.equal(needsDailyCheckIn(state), false); + }); + + it('should not need onboarding after completion', () => { + initializeWorkspace(workDir); + const state = loadState(workDir); + state.onboardingComplete = true; + state.lastCheckInDate = new Date().toISOString().slice(0, 10); + saveState(workDir, state); + + const reloaded = loadState(workDir); + assert.equal(reloaded.onboardingComplete, true); + }); + }); + + describe('hookTriggeredSessionId prevents repeat', () => { + it('should persist hookTriggeredSessionId', () => { + initializeWorkspace(workDir); + const state = loadState(workDir); + state.hookTriggeredSessionId = 'session-123'; + saveState(workDir, state); + + const reloaded = loadState(workDir); + assert.equal(reloaded.hookTriggeredSessionId, 'session-123'); + }); + + it('should allow different session to trigger', () => { + initializeWorkspace(workDir); + const state = loadState(workDir); + state.hookTriggeredSessionId = 'session-123'; + saveState(workDir, state); + + const reloaded = loadState(workDir); + // A different session ID should not match + assert.notEqual(reloaded.hookTriggeredSessionId, 'session-456'); + }); + }); + + describe('daily check-in respects onboarding state', () => { + it('should not trigger check-in if onboarding not complete', () => { + const state = { onboardingComplete: false, lastCheckInDate: null, schemaVersion: 1 }; + assert.equal(needsDailyCheckIn(state), false); + }); + + it('should trigger check-in if onboarding done and no check-in today', () => { + const state = { onboardingComplete: true, lastCheckInDate: '2020-01-01', schemaVersion: 1 }; + assert.equal(needsDailyCheckIn(state), true); + }); + + it('should not trigger check-in if already done today', () => { + const today = new Date().toISOString().slice(0, 10); + const state = { onboardingComplete: true, lastCheckInDate: today, schemaVersion: 1 }; + assert.equal(needsDailyCheckIn(state), false); + }); + + it('onboarding day should skip daily check-in (lastCheckInDate set)', () => { + // Simulates what happens after onboarding completes: + // onboardingComplete=true, lastCheckInDate=today + const today = new Date().toISOString().slice(0, 10); + const state = { onboardingComplete: true, lastCheckInDate: today, schemaVersion: 1 }; + assert.equal(needsDailyCheckIn(state), false); + }); + }); + + describe('workspace prompt scoping', () => { + it('should generate prompt for workspace files', () => { + initializeWorkspace(workDir); + // Write some content + fs.writeFileSync(path.join(workDir, 'soul.md'), '# Soul\nI am helpful.', 'utf-8'); + + const files = loadWorkspaceFiles(workDir); + const prompt = assembleWorkspacePrompt(files); + + assert.ok(prompt.includes('')); + assert.ok(prompt.includes('I am helpful')); + }); + + it('should return empty prompt for empty workspace', () => { + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-ws-')); + const files = loadWorkspaceFiles(emptyDir); + const prompt = assembleWorkspacePrompt(files); + assert.equal(prompt, ''); + fs.rmSync(emptyDir, { recursive: true, force: true }); + }); + }); + + describe('session reuse for daily check-in', () => { + it('should find latest session by working directory', () => { + // Create a session for the directory + const s1 = createSession('First', '', undefined, workDir); + + const latest = getLatestSessionByWorkingDirectory(workDir); + assert.ok(latest, 'Should find a session'); + assert.equal(latest!.id, s1.id, 'Should return the session for this directory'); + assert.equal(latest!.working_directory, workDir); + }); + + it('should return undefined for directory with no sessions', () => { + const result = getLatestSessionByWorkingDirectory('/nonexistent/dir'); + assert.equal(result, undefined); + }); + }); + + describe('generateDirectoryDocs produces README.ai.md and PATH.ai.md', () => { + it('should generate both files for subdirectories', () => { + // Create a subdirectory with some files + const subDir = path.join(workDir, 'notes'); + fs.mkdirSync(subDir); + fs.writeFileSync(path.join(subDir, 'todo.txt'), 'buy milk', 'utf-8'); + fs.writeFileSync(path.join(subDir, 'ideas.md'), '# Ideas', 'utf-8'); + + const generated = generateDirectoryDocs(workDir); + + assert.ok(generated.length >= 2, 'Should generate at least 2 files'); + + const readmePath = path.join(subDir, 'README.ai.md'); + const pathFilePath = path.join(subDir, 'PATH.ai.md'); + + assert.ok(fs.existsSync(readmePath), 'README.ai.md should exist'); + assert.ok(fs.existsSync(pathFilePath), 'PATH.ai.md should exist'); + + const readmeContent = fs.readFileSync(readmePath, 'utf-8'); + assert.ok(readmeContent.includes('')); + assert.ok(readmeContent.includes('ideas.md')); + + const pathContent = fs.readFileSync(pathFilePath, 'utf-8'); + assert.ok(pathContent.includes('')); + assert.ok(pathContent.includes('Path Index')); + }); + }); +}); + +// Clean up DB +describe('cleanup', () => { + it('close db', () => { + closeDb(); + }); +}); diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index ce38af6..acc67b4 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -15,8 +15,8 @@ export async function POST(request: NextRequest) { let activeLockId: string | undefined; try { - const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string } = await request.json(); - const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend } = body; + const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean } = await request.json(); + const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger } = body; console.log('[chat API] content length:', content.length, 'first 200 chars:', content.slice(0, 200)); console.log('[chat API] systemPromptAppend:', systemPromptAppend ? `${systemPromptAppend.length} chars` : 'none'); @@ -58,29 +58,32 @@ export async function POST(request: NextRequest) { notifySessionStart(telegramNotifyOpts).catch(() => {}); // Save user message — persist file metadata so attachments survive page reload + // Skip saving for autoTrigger messages (invisible system triggers for assistant hooks) let savedContent = content; let fileMeta: Array<{ id: string; name: string; type: string; size: number; filePath: string }> | undefined; - if (files && files.length > 0) { - const workDir = session.working_directory; - const uploadDir = path.join(workDir, '.codepilot-uploads'); - if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }); + if (!autoTrigger) { + if (files && files.length > 0) { + const workDir = session.working_directory; + const uploadDir = path.join(workDir, '.codepilot-uploads'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + fileMeta = files.map((f) => { + const safeName = path.basename(f.name).replace(/[^a-zA-Z0-9._-]/g, '_'); + const filePath = path.join(uploadDir, `${Date.now()}-${safeName}`); + const buffer = Buffer.from(f.data, 'base64'); + fs.writeFileSync(filePath, buffer); + return { id: f.id, name: f.name, type: f.type, size: buffer.length, filePath }; + }); + savedContent = `${content}`; } - fileMeta = files.map((f) => { - const safeName = path.basename(f.name).replace(/[^a-zA-Z0-9._-]/g, '_'); - const filePath = path.join(uploadDir, `${Date.now()}-${safeName}`); - const buffer = Buffer.from(f.data, 'base64'); - fs.writeFileSync(filePath, buffer); - return { id: f.id, name: f.name, type: f.type, size: buffer.length, filePath }; - }); - savedContent = `${content}`; - } - addMessage(session_id, 'user', savedContent); + addMessage(session_id, 'user', savedContent); - // Auto-generate title from first message if still default - if (session.title === 'New Chat') { - const title = content.slice(0, 50) + (content.length > 50 ? '...' : ''); - updateSessionTitle(session_id, title); + // Auto-generate title from first message if still default + if (session.title === 'New Chat') { + const title = content.slice(0, 50) + (content.length > 50 ? '...' : ''); + updateSessionTitle(session_id, title); + } } // Determine model: request override > session model > default setting @@ -164,12 +167,94 @@ export async function POST(request: NextRequest) { }) : undefined; + // Load assistant workspace prompt if configured + let workspacePrompt = ''; + let assistantProjectInstructions = ''; + try { + const workspacePath = getSetting('assistant_workspace_path'); + if (workspacePath) { + const { loadWorkspaceFiles, assembleWorkspacePrompt, loadState, needsDailyCheckIn } = await import('@/lib/assistant-workspace'); + + // Only inject workspace files for assistant project sessions + const sessionWd = session.working_directory || ''; + const isAssistantProject = sessionWd === workspacePath; + + if (isAssistantProject) { + const files = loadWorkspaceFiles(workspacePath); + workspacePrompt = assembleWorkspacePrompt(files); + + const state = loadState(workspacePath); + + if (!state.onboardingComplete) { + // First-time onboarding: instruct AI to ask onboarding questions + assistantProjectInstructions = ` +You are now in the assistant workspace onboarding session. Your task is to interview the user to build their profile. + +Ask the following 10 questions ONE AT A TIME. Wait for the user's answer before asking the next question. Be conversational and friendly. + +1. How should I address you? +2. What name should I use for myself? +3. Do you prefer "concise and direct" or "detailed explanations"? +4. Do you prefer "minimal interruptions" or "proactive suggestions"? +5. What are your three hard boundaries? +6. What are your three most important current goals? +7. Do you prefer output as "lists", "reports", or "conversation summaries"? +8. What information may be written to long-term memory? +9. What information must never be written to long-term memory? +10. What three things should I do first when entering a project? + +After all questions are answered, output a summary of the collected answers in exactly this format — this is critical for the system to process your answers: + +\`\`\`onboarding-complete +{"q1":"answer1","q2":"answer2","q3":"answer3","q4":"answer4","q5":"answer5","q6":"answer6","q7":"answer7","q8":"answer8","q9":"answer9","q10":"answer10"} +\`\`\` + +Do NOT try to write files yourself. The system will automatically generate soul.md and user.md from your collected answers. + +Start by greeting the user and asking the first question. +`; + } else if (needsDailyCheckIn(state)) { + // Daily check-in: instruct AI to ask 3 quick questions + assistantProjectInstructions = ` +You are now in the assistant workspace daily check-in session. Ask the user these 3 questions ONE AT A TIME: + +1. What did you work on or accomplish today? +2. Any changes to your current priorities or goals? +3. Anything you'd like me to remember going forward? + +After collecting all 3 answers, output a summary in exactly this format: + +\`\`\`checkin-complete +{"q1":"answer1","q2":"answer2","q3":"answer3"} +\`\`\` + +Do NOT try to write files yourself. The system will automatically update memory.md and user.md from your collected answers. + +Start by greeting the user and asking the first question. +`; + } + } + } + } catch (e) { + console.warn('[chat API] Failed to load assistant workspace:', e); + } + // Append per-request system prompt (e.g. skill injection for image generation) let finalSystemPrompt = systemPromptOverride || session.system_prompt || undefined; if (systemPromptAppend) { finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + systemPromptAppend; } + // Workspace prompt goes first (base personality), session prompt after (task override) + if (workspacePrompt) { + finalSystemPrompt = workspacePrompt + '\n\n' + (finalSystemPrompt || ''); + } + + // Assistant project instructions go after workspace prompt + if (assistantProjectInstructions) { + finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + assistantProjectInstructions; + } + // Load recent conversation history from DB as fallback context. // This is used when SDK session resume is unavailable or fails, // so the model still has conversation context. diff --git a/src/app/api/settings/workspace/route.ts b/src/app/api/settings/workspace/route.ts new file mode 100644 index 0000000..42c1d35 --- /dev/null +++ b/src/app/api/settings/workspace/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs'; +import { getSetting, setSetting } from '@/lib/db'; +import { validateWorkspace, initializeWorkspace, loadState } from '@/lib/assistant-workspace'; + +export async function GET() { + try { + const workspacePath = getSetting('assistant_workspace_path'); + if (!workspacePath) { + return NextResponse.json({ path: null, files: {}, state: null }); + } + + const validation = validateWorkspace(workspacePath); + const state = loadState(workspacePath); + + // Build file status with preview + const fileStatus: Record = {}; + for (const [key, info] of Object.entries(validation.files)) { + let preview = ''; + if (info.exists && info.path) { + try { + const content = fs.readFileSync(info.path, 'utf-8'); + preview = content.split('\n').slice(0, 3).join('\n'); + } catch { /* ignore */ } + } + fileStatus[key] = { + exists: info.exists, + chars: info.size, + preview, + }; + } + + return NextResponse.json({ + path: workspacePath, + exists: validation.exists, + files: fileStatus, + state, + }); + } catch (e) { + console.error('[settings/workspace] GET failed:', e); + return NextResponse.json({ error: 'Failed to load workspace info' }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + try { + const body = await request.json(); + const { path: workspacePath, initialize } = body as { path: string; initialize?: boolean }; + + if (!workspacePath || typeof workspacePath !== 'string') { + return NextResponse.json({ error: 'Invalid workspace path' }, { status: 400 }); + } + + setSetting('assistant_workspace_path', workspacePath); + + let createdFiles: string[] = []; + if (initialize) { + createdFiles = initializeWorkspace(workspacePath); + } + + return NextResponse.json({ success: true, createdFiles }); + } catch (e) { + console.error('[settings/workspace] PUT failed:', e); + return NextResponse.json({ error: 'Failed to save workspace settings' }, { status: 500 }); + } +} diff --git a/src/app/api/workspace/checkin/route.ts b/src/app/api/workspace/checkin/route.ts new file mode 100644 index 0000000..0146a83 --- /dev/null +++ b/src/app/api/workspace/checkin/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import { getSetting, getDefaultProviderId } from '@/lib/db'; +import { loadState, saveState } from '@/lib/assistant-workspace'; +import { generateTextFromProvider } from '@/lib/text-generator'; + +const CHECK_IN_QUESTIONS = [ + 'assistant.checkInQ1', + 'assistant.checkInQ2', + 'assistant.checkInQ3', +]; + +const CHECK_IN_LABELS = [ + 'What did you work on or accomplish today?', + 'Any changes to your current priorities or goals?', + 'Anything you\'d like me to remember going forward?', +]; + +export async function GET() { + return NextResponse.json({ + questions: CHECK_IN_QUESTIONS.map((key, i) => ({ + key, + label: CHECK_IN_LABELS[i], + index: i + 1, + })), + }); +} + +export async function POST(request: NextRequest) { + try { + const workspacePath = getSetting('assistant_workspace_path'); + if (!workspacePath) { + return NextResponse.json({ error: 'No workspace path configured' }, { status: 400 }); + } + + const body = await request.json(); + const { answers } = body as { answers: Record }; + + if (!answers || typeof answers !== 'object') { + return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 }); + } + + const qaText = CHECK_IN_LABELS.map((q, i) => { + const key = `q${i + 1}`; + return `Q: ${q}\nA: ${answers[key] || '(skipped)'}`; + }).join('\n\n'); + + const today = new Date().toISOString().slice(0, 10); + + // Read existing files for context + const memoryPath = path.join(workspacePath, 'memory.md'); + const userPath = path.join(workspacePath, 'user.md'); + let existingMemory = ''; + let existingUser = ''; + try { existingMemory = fs.readFileSync(memoryPath, 'utf-8'); } catch { /* new file */ } + try { existingUser = fs.readFileSync(userPath, 'utf-8'); } catch { /* new file */ } + + try { + const providerId = getDefaultProviderId() || ''; + const model = getSetting('default_model') || 'claude-sonnet-4-20250514'; + + const memoryPrompt = `You maintain a memory.md file for an AI assistant. Given the user's daily check-in answers and the existing memory file, generate an UPDATED memory.md. Add new facts, update changed information, remove outdated items. Keep it organized with markdown headers. Keep under 4000 characters.\n\nExisting memory.md:\n${existingMemory || '(empty)'}\n\nToday's check-in (${today}):\n${qaText}`; + + const userPrompt = `You maintain a user.md profile for an AI assistant. Given the user's daily check-in answers and the existing profile, generate an UPDATED user.md. Only update sections affected by today's answers. Keep it organized with markdown headers. Keep under 2000 characters.\n\nExisting user.md:\n${existingUser || '(empty)'}\n\nToday's check-in (${today}):\n${qaText}`; + + const [newMemory, newUser] = await Promise.all([ + generateTextFromProvider({ providerId, model, system: 'You maintain knowledge files for AI assistants. Output only the file content, no explanations.', prompt: memoryPrompt }), + generateTextFromProvider({ providerId, model, system: 'You maintain user profile documents. Output only the file content, no explanations.', prompt: userPrompt }), + ]); + + if (!newMemory.trim()) { + throw new Error('AI returned empty memory content'); + } + fs.writeFileSync(memoryPath, newMemory, 'utf-8'); + if (existingUser && newUser.trim()) { + fs.writeFileSync(userPath, newUser, 'utf-8'); + } + } catch (e) { + console.warn('[workspace/checkin] AI generation failed, appending raw answers:', e); + // Fallback: append raw answers to memory.md + const appendText = `\n\n## Check-in ${today}\n${qaText}\n`; + fs.appendFileSync(memoryPath, appendText, 'utf-8'); + } + + // Update state + const state = loadState(workspacePath); + state.lastCheckInDate = today; + saveState(workspacePath, state); + + return NextResponse.json({ success: true }); + } catch (e) { + console.error('[workspace/checkin] POST failed:', e); + return NextResponse.json({ error: 'Check-in failed' }, { status: 500 }); + } +} diff --git a/src/app/api/workspace/docs/route.ts b/src/app/api/workspace/docs/route.ts new file mode 100644 index 0000000..c015aa4 --- /dev/null +++ b/src/app/api/workspace/docs/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; +import { getSetting } from '@/lib/db'; +import { generateDirectoryDocs } from '@/lib/assistant-workspace'; + +export async function POST() { + try { + const workspacePath = getSetting('assistant_workspace_path'); + if (!workspacePath) { + return NextResponse.json( + { error: 'No assistant workspace path configured' }, + { status: 400 }, + ); + } + + const files = generateDirectoryDocs(workspacePath); + return NextResponse.json({ files }); + } catch (e) { + console.error('[workspace/docs] Failed to generate directory docs:', e); + return NextResponse.json( + { error: 'Failed to generate directory docs' }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/workspace/hook-triggered/route.ts b/src/app/api/workspace/hook-triggered/route.ts new file mode 100644 index 0000000..412a80e --- /dev/null +++ b/src/app/api/workspace/hook-triggered/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSetting } from '@/lib/db'; +import { loadState, saveState } from '@/lib/assistant-workspace'; + +export async function POST(request: NextRequest) { + try { + const workspacePath = getSetting('assistant_workspace_path'); + if (!workspacePath) { + return NextResponse.json({ error: 'No workspace path configured' }, { status: 400 }); + } + const { sessionId } = await request.json(); + const state = loadState(workspacePath); + // '__clear__' sentinel clears the field (used after onboarding/checkin completes) + state.hookTriggeredSessionId = sessionId === '__clear__' ? undefined : sessionId; + saveState(workspacePath, state); + return NextResponse.json({ success: true }); + } catch (e) { + console.error('[workspace/hook-triggered] POST failed:', e); + return NextResponse.json({ error: 'Failed' }, { status: 500 }); + } +} diff --git a/src/app/api/workspace/latest-session/route.ts b/src/app/api/workspace/latest-session/route.ts new file mode 100644 index 0000000..4a3e733 --- /dev/null +++ b/src/app/api/workspace/latest-session/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getLatestSessionByWorkingDirectory } from '@/lib/db'; + +export async function GET(request: NextRequest) { + const workingDirectory = request.nextUrl.searchParams.get('workingDirectory'); + if (!workingDirectory) { + return NextResponse.json({ error: 'Missing workingDirectory' }, { status: 400 }); + } + const session = getLatestSessionByWorkingDirectory(workingDirectory); + return NextResponse.json({ sessionId: session?.id ?? null }); +} diff --git a/src/app/api/workspace/onboarding/route.ts b/src/app/api/workspace/onboarding/route.ts new file mode 100644 index 0000000..f16257e --- /dev/null +++ b/src/app/api/workspace/onboarding/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import { getSetting, getDefaultProviderId } from '@/lib/db'; +import { loadState, saveState } from '@/lib/assistant-workspace'; +import { generateTextFromProvider } from '@/lib/text-generator'; + +const QUESTIONS = [ + 'assistant.onboardingQ1', + 'assistant.onboardingQ2', + 'assistant.onboardingQ3', + 'assistant.onboardingQ4', + 'assistant.onboardingQ5', + 'assistant.onboardingQ6', + 'assistant.onboardingQ7', + 'assistant.onboardingQ8', + 'assistant.onboardingQ9', + 'assistant.onboardingQ10', +]; + +const QUESTION_LABELS = [ + 'How should I address you?', + 'What name should I use for myself?', + 'Do you prefer "concise and direct" or "detailed explanations"?', + 'Do you prefer "minimal interruptions" or "proactive suggestions"?', + 'What are your three hard boundaries?', + 'What are your three most important current goals?', + 'Do you prefer output as "lists", "reports", or "conversation summaries"?', + 'What information may be written to long-term memory?', + 'What information must never be written to long-term memory?', + 'What three things should I do first when entering a project?', +]; + +export async function GET() { + return NextResponse.json({ + questions: QUESTIONS.map((key, i) => ({ + key, + label: QUESTION_LABELS[i], + index: i + 1, + })), + }); +} + +export async function POST(request: NextRequest) { + try { + const workspacePath = getSetting('assistant_workspace_path'); + if (!workspacePath) { + return NextResponse.json({ error: 'No workspace path configured' }, { status: 400 }); + } + + const body = await request.json(); + const { answers } = body as { answers: Record }; + + if (!answers || typeof answers !== 'object') { + return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 }); + } + + // Build Q&A text for the prompt + const qaText = QUESTION_LABELS.map((q, i) => { + const key = `q${i + 1}`; + return `Q: ${q}\nA: ${answers[key] || '(skipped)'}`; + }).join('\n\n'); + + let soulContent: string; + let userContent: string; + + try { + const providerId = getDefaultProviderId() || ''; + const model = getSetting('default_model') || 'claude-sonnet-4-20250514'; + + const soulPrompt = `Based on the following user onboarding answers, generate a concise "soul.md" file that defines an AI assistant's personality, communication style, and behavioral rules. Write in second person ("You are..."). Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`; + + const userPrompt = `Based on the following user onboarding answers, generate a concise "user.md" profile that captures the user's preferences, goals, and boundaries. Write in third person. Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`; + + [soulContent, userContent] = await Promise.all([ + generateTextFromProvider({ providerId, model, system: 'You generate configuration files for AI assistants. Output only the file content, no explanations.', prompt: soulPrompt }), + generateTextFromProvider({ providerId, model, system: 'You generate user profile documents. Output only the file content, no explanations.', prompt: userPrompt }), + ]); + // Fallback if AI returned empty + if (!soulContent.trim() || !userContent.trim()) { + throw new Error('AI returned empty content'); + } + } catch (e) { + console.warn('[workspace/onboarding] AI generation failed, using raw answers:', e); + // Fallback: write raw answers + soulContent = `# Soul\n\n## Communication Style\n- Address user as: ${answers.q1 || 'not specified'}\n- Assistant name: ${answers.q2 || 'not specified'}\n- Style: ${answers.q3 || 'not specified'}\n- Approach: ${answers.q4 || 'not specified'}\n`; + userContent = `# User Profile\n\n## Preferences\n- Boundaries: ${answers.q5 || 'not specified'}\n- Goals: ${answers.q6 || 'not specified'}\n- Output format: ${answers.q7 || 'not specified'}\n- Memory allowed: ${answers.q8 || 'not specified'}\n- Memory forbidden: ${answers.q9 || 'not specified'}\n- Project entry: ${answers.q10 || 'not specified'}\n`; + } + + // Write files + fs.writeFileSync(path.join(workspacePath, 'soul.md'), soulContent, 'utf-8'); + fs.writeFileSync(path.join(workspacePath, 'user.md'), userContent, 'utf-8'); + + // Update state + const today = new Date().toISOString().slice(0, 10); + const state = loadState(workspacePath); + state.onboardingComplete = true; + state.lastCheckInDate = today; // Skip daily check-in on the day of onboarding + saveState(workspacePath, state); + + return NextResponse.json({ success: true }); + } catch (e) { + console.error('[workspace/onboarding] POST failed:', e); + return NextResponse.json({ error: 'Onboarding failed' }, { status: 500 }); + } +} diff --git a/src/app/api/workspace/session/route.ts b/src/app/api/workspace/session/route.ts new file mode 100644 index 0000000..7e06ab0 --- /dev/null +++ b/src/app/api/workspace/session/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSetting } from '@/lib/db'; +import { getLatestSessionByWorkingDirectory, createSession } from '@/lib/db'; + +export async function POST(request: NextRequest) { + try { + const workspacePath = getSetting('assistant_workspace_path'); + if (!workspacePath) { + return NextResponse.json({ error: 'No workspace path configured' }, { status: 400 }); + } + + const body = await request.json().catch(() => ({})); + const { mode } = body as { mode?: 'onboarding' | 'checkin' }; + + // For onboarding: always create new session + // For checkin: reuse latest session if exists + let session; + if (mode === 'checkin') { + session = getLatestSessionByWorkingDirectory(workspacePath); + } + + if (!session) { + const model = typeof body.model === 'string' ? body.model : ''; + const provider_id = typeof body.provider_id === 'string' ? body.provider_id : ''; + session = createSession(undefined, model, undefined, workspacePath, 'code', provider_id); + } + + return NextResponse.json({ session, isNew: !session.sdk_session_id }); + } catch (e) { + console.error('[workspace/session] POST failed:', e); + return NextResponse.json({ error: 'Failed to create/find session' }, { status: 500 }); + } +} diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 8357255..6b9779e 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import type { Message, MessagesResponse, FileAttachment, SessionStreamSnapshot } from '@/types'; import { MessageList } from './MessageList'; import { MessageInput } from './MessageInput'; @@ -136,6 +136,9 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal // Append the final assistant message to the messages list const finalContent = event.snapshot.finalMessageContent; if (finalContent) { + // Check for assistant project completion signals + detectAssistantCompletion(finalContent); + const assistantMessage: Message = { id: 'temp-assistant-' + Date.now(), session_id: sessionId, @@ -180,6 +183,82 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal setHasMore(initialHasMore); }, [initialHasMore]); + // Auto-trigger assistant project hooks (onboarding/check-in) + // Uses autoTrigger flag so the backend skips saving user message and title update. + // Works for both fresh sessions (onboarding) and reused sessions (check-in). + const assistantTriggerFiredRef = useRef(false); + const checkAssistantTrigger = useCallback(async () => { + // Don't trigger if already streaming or already triggered in this mount + if (isStreaming || assistantTriggerFiredRef.current) return; + + try { + const res = await fetch('/api/settings/workspace'); + if (!res.ok) return; + const data = await res.json(); + if (!data.path) return; + + // Check if this session's working directory matches workspace path + if (workingDirectory !== data.path) return; + + const state = data.state; + if (!state) return; + + const today = new Date().toISOString().slice(0, 10); + const needsOnboarding = !state.onboardingComplete; + const needsCheckIn = state.onboardingComplete && state.lastCheckInDate !== today; + + if (!needsOnboarding && !needsCheckIn) return; + + // For daily check-in, only trigger in the most recent session for this workspace. + // This prevents older sessions from hijacking the check-in when reopened. + if (needsCheckIn) { + const latestRes = await fetch(`/api/workspace/latest-session?workingDirectory=${encodeURIComponent(data.path)}`); + if (latestRes.ok) { + const { sessionId: latestSessionId } = await latestRes.json(); + if (latestSessionId && latestSessionId !== sessionId) return; + } + } + + // Mark fired so we don't re-trigger on focus/re-render + assistantTriggerFiredRef.current = true; + + // Mark in persistent state to prevent duplicate triggers across page reloads + await fetch('/api/workspace/hook-triggered', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId }), + }); + + // Use autoTrigger: the message is invisible (no user bubble, no title update) + const triggerMsg = needsOnboarding + ? '请开始助理引导设置。' + : '请开始每日问询。'; + startStream({ + sessionId, + content: triggerMsg, + mode, + model: currentModel, + providerId: currentProviderId, + autoTrigger: true, + onModeChanged: (sdkMode) => { + const uiMode = sdkMode === 'plan' ? 'plan' : 'code'; + handleModeChange(uiMode); + }, + sendMessageFn: (retryContent: string, retryFiles?: FileAttachment[]) => { + sendMessageRef.current?.(retryContent, retryFiles); + }, + }); + } catch (e) { + console.error('[ChatView] Assistant auto-trigger failed:', e); + } + }, [sessionId, workingDirectory, isStreaming, mode, currentModel, currentProviderId, handleModeChange]); + + useEffect(() => { + // Small delay to let the session fully initialize + const timer = setTimeout(checkAssistantTrigger, 500); + return () => clearTimeout(timer); + }, [checkAssistantTrigger]); + const loadEarlierMessages = useCallback(async () => { // Use ref as atomic lock to prevent double-fetch from rapid clicks if (loadingMoreRef.current || !hasMore || messages.length === 0) return; @@ -217,6 +296,49 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal [sessionId, setPendingApprovalSessionId] ); + // Detect assistant project completion signals in final message content + const detectAssistantCompletion = useCallback(async (content: string) => { + const clearHookTriggered = () => + fetch('/api/workspace/hook-triggered', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: '__clear__' }), + }).catch(() => {}); + + // Check for onboarding completion + const onboardingMatch = content.match(/```onboarding-complete\n([\s\S]*?)\n```/); + if (onboardingMatch) { + try { + const answers = JSON.parse(onboardingMatch[1]); + await fetch('/api/workspace/onboarding', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ answers }), + }); + await clearHookTriggered(); + } catch (e) { + console.error('[ChatView] Onboarding completion failed:', e); + } + return; + } + + // Check for check-in completion + const checkinMatch = content.match(/```checkin-complete\n([\s\S]*?)\n```/); + if (checkinMatch) { + try { + const answers = JSON.parse(checkinMatch[1]); + await fetch('/api/workspace/checkin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ answers }), + }); + await clearHookTriggered(); + } catch (e) { + console.error('[ChatView] Check-in completion failed:', e); + } + } + }, []); + // Send message — delegates stream management to the manager const sendMessage = useCallback( async (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => { @@ -420,6 +542,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal workingDirectory={workingDirectory} mode={mode} onModeChange={handleModeChange} + onAssistantTrigger={checkAssistantTrigger} /> ); diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index b303923..4a763ab 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -97,6 +97,7 @@ interface MessageInputProps { workingDirectory?: string; mode?: string; onModeChange?: (mode: string) => void; + onAssistantTrigger?: () => void; } interface PopoverItem { @@ -373,6 +374,7 @@ export function MessageInput({ workingDirectory, mode = 'code', onModeChange, + onAssistantTrigger, }: MessageInputProps) { const { t } = useTranslation(); const imageGen = useImageGen(); @@ -396,6 +398,16 @@ export function MessageInput({ const aiSearchAbortRef = useRef(null); const aiSearchTimerRef = useRef | null>(null); + // Assistant trigger on first focus + const assistantTriggerFired = useRef(false); + + const handleAssistantFocus = useCallback(() => { + if (!assistantTriggerFired.current && onAssistantTrigger) { + assistantTriggerFired.current = true; + onAssistantTrigger(); + } + }, [onAssistantTrigger]); + // Fetch provider groups from API const fetchProviderModels = useCallback(() => { fetch('/api/providers/models') @@ -1098,6 +1110,7 @@ export function MessageInput({ value={inputValue} onChange={(e) => handleInputChange(e.currentTarget.value)} onKeyDown={handleKeyDown} + onFocus={handleAssistantFocus} disabled={disabled} className="min-h-10" /> diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index aaab10f..c389a22 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -14,6 +14,7 @@ import { ArrowRight01Icon, PlusSignIcon, FolderOpenIcon, + AiUserIcon, } from "@hugeicons/core-free-icons"; import { Columns2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -32,6 +33,7 @@ import { useNativeFolderPicker } from "@/hooks/useNativeFolderPicker"; import { ConnectionStatus } from "./ConnectionStatus"; import { ImportSessionDialog } from "./ImportSessionDialog"; import { FolderPicker } from "@/components/chat/FolderPicker"; +import { useAssistantWorkspace } from "@/hooks/useAssistantWorkspace"; import type { ChatSession } from "@/types"; interface ChatListPanelProps { @@ -130,6 +132,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { ); const [hoveredFolder, setHoveredFolder] = useState(null); const [creatingChat, setCreatingChat] = useState(false); + const { workspacePath } = useAssistantWorkspace(); /** Read current model + provider_id from localStorage for new session creation */ const getCurrentModelAndProvider = useCallback(() => { @@ -376,7 +379,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { style={{ width: width ?? 240 }} > {/* Header - extra top padding for macOS traffic lights */} -
+
@@ -563,6 +566,9 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { {group.displayName} + {workspacePath && group.workingDirectory === workspacePath && ( + + )} {/* New chat in project button (on hover) */} {group.workingDirectory !== "" && ( @@ -735,6 +741,7 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { onOpenChange={setFolderPickerOpen} onSelect={handleFolderSelect} /> + ); } diff --git a/src/components/settings/AssistantWorkspaceSection.tsx b/src/components/settings/AssistantWorkspaceSection.tsx new file mode 100644 index 0000000..3acced5 --- /dev/null +++ b/src/components/settings/AssistantWorkspaceSection.tsx @@ -0,0 +1,338 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Loading02Icon } from "@hugeicons/core-free-icons"; +import { useTranslation } from "@/hooks/useTranslation"; + +interface FileStatus { + exists: boolean; + chars: number; + preview: string; +} + +interface WorkspaceState { + onboardingComplete: boolean; + lastCheckInDate: string | null; + schemaVersion: number; +} + +interface WorkspaceInfo { + path: string | null; + exists?: boolean; + files: Record; + state: WorkspaceState | null; +} + +const FILE_LABELS: Record = { + claude: "claude.md", + soul: "soul.md", + user: "user.md", + memory: "memory.md", +}; + +export function AssistantWorkspaceSection() { + const { t } = useTranslation(); + const router = useRouter(); + const [workspace, setWorkspace] = useState(null); + const [loading, setLoading] = useState(true); + const [initializing, setInitializing] = useState(false); + const [refreshingDocs, setRefreshingDocs] = useState(false); + const [pathInput, setPathInput] = useState(""); + const [creatingSession, setCreatingSession] = useState(false); + + const fetchWorkspace = useCallback(async () => { + try { + const res = await fetch("/api/settings/workspace"); + if (res.ok) { + const data = await res.json(); + setWorkspace(data); + if (data.path) setPathInput(data.path); + } + } catch (e) { + console.error("Failed to fetch workspace:", e); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchWorkspace(); + }, [fetchWorkspace]); + + const handleSavePath = useCallback(async (initialize: boolean) => { + if (!pathInput.trim()) return; + if (initialize) setInitializing(true); + try { + const res = await fetch("/api/settings/workspace", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: pathInput.trim(), initialize }), + }); + if (res.ok) { + await fetchWorkspace(); + } + } catch (e) { + console.error("Failed to save workspace path:", e); + } finally { + setInitializing(false); + } + }, [pathInput, fetchWorkspace]); + + const handleSelectFolder = useCallback(async () => { + try { + const w = window as unknown as { electronAPI?: { selectFolder?: () => Promise } }; + if (w.electronAPI?.selectFolder) { + const selected = await w.electronAPI.selectFolder(); + if (selected) { + setPathInput(selected); + } + } else { + const input = prompt("Enter workspace directory path:"); + if (input) setPathInput(input); + } + } catch (e) { + console.error("Failed to select folder:", e); + } + }, []); + + const handleRefreshDocs = useCallback(async () => { + setRefreshingDocs(true); + try { + await fetch("/api/workspace/docs", { method: "POST" }); + } catch (e) { + console.error("Failed to refresh docs:", e); + } finally { + setRefreshingDocs(false); + } + }, []); + + /** Create a new onboarding session in the assistant project folder and navigate to it */ + const handleStartOnboarding = useCallback(async () => { + if (!workspace?.path) return; + setCreatingSession(true); + try { + const model = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') || '' : ''; + const provider_id = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') || '' : ''; + const res = await fetch("/api/workspace/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: 'onboarding', model, provider_id }), + }); + if (res.ok) { + const data = await res.json(); + window.dispatchEvent(new CustomEvent("session-created")); + router.push(`/chat/${data.session.id}`); + } + } catch (e) { + console.error("Failed to create onboarding session:", e); + } finally { + setCreatingSession(false); + } + }, [workspace?.path, router]); + + /** Create or reuse a check-in session in the assistant project folder and navigate to it */ + const handleStartCheckIn = useCallback(async () => { + if (!workspace?.path) return; + setCreatingSession(true); + try { + const model = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') || '' : ''; + const provider_id = typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') || '' : ''; + const res = await fetch("/api/workspace/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: 'checkin', model, provider_id }), + }); + if (res.ok) { + const data = await res.json(); + window.dispatchEvent(new CustomEvent("session-created")); + router.push(`/chat/${data.session.id}`); + } + } catch (e) { + console.error("Failed to create check-in session:", e); + } finally { + setCreatingSession(false); + } + }, [workspace?.path, router]); + + if (loading) { + return ( +
+ +
+ ); + } + + const today = new Date().toISOString().slice(0, 10); + const checkInDoneToday = workspace?.state?.lastCheckInDate === today; + + return ( +
+ {/* Workspace Path Card */} +
+

{t('assistant.workspacePath')}

+

{t('assistant.workspacePathHint')}

+
+ setPathInput(e.target.value)} + placeholder="/path/to/workspace" + className="flex-1 rounded-md border border-border/50 bg-background px-3 py-1.5 text-sm" + /> + +
+
+ + +
+
+ + {/* File Status Card */} + {workspace?.path && ( +
+

{t('assistant.fileStatus')}

+
+ {Object.entries(FILE_LABELS).map(([key, label]) => { + const file = workspace.files[key]; + return ( +
+ {label} +
+ {file?.exists ? ( + <> + + {t('assistant.fileChars', { count: String(file.chars) })} + + + {t('assistant.fileExists')} + + ) : ( + <> + + {t('assistant.fileMissing')} + + )} +
+
+ ); + })} +
+
+ )} + + {/* Onboarding Status Card */} + {workspace?.path && ( +
+
+
+

{t('assistant.onboardingTitle')}

+

{t('assistant.onboardingDesc')}

+

+ {workspace.state?.onboardingComplete + ? {t('assistant.onboardingComplete')} + : {t('assistant.onboardingNotStarted')} + } +

+
+ +
+
+ )} + + {/* Daily Check-in Card */} + {workspace?.path && ( +
+
+
+

{t('assistant.checkInTitle')}

+

{t('assistant.checkInDesc')}

+

+ {workspace.state?.lastCheckInDate && ( + + {t('assistant.lastCheckIn')}: {workspace.state.lastCheckInDate} + + )} + {" "} + {checkInDoneToday + ? {t('assistant.checkInToday')} + : {t('assistant.checkInNeeded')} + } +

+
+ +
+
+ )} + + {/* Directory Docs Card */} + {workspace?.path && ( +
+
+
+

{t('assistant.refreshDocs')}

+
+ +
+
+ )} +
+ ); +} diff --git a/src/components/settings/SettingsLayout.tsx b/src/components/settings/SettingsLayout.tsx index eb88b66..324c6a5 100644 --- a/src/components/settings/SettingsLayout.tsx +++ b/src/components/settings/SettingsLayout.tsx @@ -6,6 +6,7 @@ import type { IconSvgElement } from "@hugeicons/react"; import { Settings02Icon, CodeIcon, + AiUserIcon, } from "@hugeicons/core-free-icons"; import { Plug01Icon, Analytics02Icon } from "@hugeicons/core-free-icons"; import { cn } from "@/lib/utils"; @@ -13,10 +14,11 @@ import { GeneralSection } from "./GeneralSection"; import { ProviderManager } from "./ProviderManager"; import { CliSettingsSection } from "./CliSettingsSection"; import { UsageStatsSection } from "./UsageStatsSection"; +import { AssistantWorkspaceSection } from "./AssistantWorkspaceSection"; import { useTranslation } from "@/hooks/useTranslation"; import type { TranslationKey } from "@/i18n"; -type Section = "general" | "providers" | "cli" | "usage"; +type Section = "general" | "providers" | "cli" | "usage" | "assistant"; interface SidebarItem { id: Section; @@ -29,6 +31,7 @@ const sidebarItems: SidebarItem[] = [ { id: "providers", label: "Providers", icon: Plug01Icon }, { id: "cli", label: "Claude CLI", icon: CodeIcon }, { id: "usage", label: "Usage", icon: Analytics02Icon }, + { id: "assistant", label: "Assistant", icon: AiUserIcon }, ]; function getSectionFromHash(): Section { @@ -61,6 +64,7 @@ export function SettingsLayout() { 'Providers': 'settings.providers', 'Claude CLI': 'settings.claudeCli', 'Usage': 'settings.usage', + 'Assistant': 'settings.assistant', }; const handleSectionChange = useCallback((section: Section) => { @@ -105,6 +109,7 @@ export function SettingsLayout() { {activeSection === "providers" && } {activeSection === "cli" && } {activeSection === "usage" && } + {activeSection === "assistant" && }
diff --git a/src/hooks/useAssistantWorkspace.ts b/src/hooks/useAssistantWorkspace.ts new file mode 100644 index 0000000..d0b063d --- /dev/null +++ b/src/hooks/useAssistantWorkspace.ts @@ -0,0 +1,93 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; + +interface FileStatus { + exists: boolean; + chars: number; + preview: string; +} + +interface WorkspaceState { + onboardingComplete: boolean; + lastCheckInDate: string | null; + schemaVersion: number; +} + +interface WorkspaceInfo { + path: string | null; + exists?: boolean; + files: Record; + state: WorkspaceState | null; +} + +export function useAssistantWorkspace() { + const [workspace, setWorkspace] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refetch = useCallback(async () => { + try { + setError(null); + const res = await fetch("/api/settings/workspace"); + if (res.ok) { + const data = await res.json(); + setWorkspace(data); + } else { + setError("Failed to fetch workspace info"); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Unknown error"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refetch(); + }, [refetch]); + + const setWorkspacePath = useCallback(async (path: string) => { + try { + const res = await fetch("/api/settings/workspace", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + if (res.ok) await refetch(); + } catch (e) { + console.error("Failed to set workspace path:", e); + } + }, [refetch]); + + const initializeWorkspace = useCallback(async () => { + if (!workspace?.path) return; + try { + const res = await fetch("/api/settings/workspace", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: workspace.path, initialize: true }), + }); + if (res.ok) await refetch(); + } catch (e) { + console.error("Failed to initialize workspace:", e); + } + }, [workspace?.path, refetch]); + + const today = new Date().toISOString().slice(0, 10); + const needsCheckIn = workspace?.path != null + && workspace.state != null + && workspace.state.lastCheckInDate !== today; + + return { + workspacePath: workspace?.path ?? null, + fileStatus: workspace?.files ?? {}, + state: workspace?.state ?? null, + loading, + error, + needsCheckIn, + setWorkspacePath, + initializeWorkspace, + refetch, + }; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 79e5944..77c3547 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -624,6 +624,50 @@ const en = { 'discord.stepUserId': 'User ID: right-click your avatar or username → "Copy User ID"', 'discord.stepChannelId': 'Channel ID: right-click a channel name in the sidebar → "Copy Channel ID"', 'discord.stepGuildId': 'Server ID: right-click the server name at the top-left → "Copy Server ID"', + + // ── Assistant Workspace ────────────────────────────── + 'settings.assistant': 'Assistant', + 'assistant.workspaceTitle': 'Assistant Workspace', + 'assistant.workspaceDesc': 'Configure a directory for persistent AI personality and memory', + 'assistant.workspacePath': 'Workspace Path', + 'assistant.workspacePathHint': 'Select a directory (e.g. your Obsidian vault) to store assistant files', + 'assistant.selectFolder': 'Select Folder', + 'assistant.initialize': 'Initialize', + 'assistant.initializing': 'Initializing...', + 'assistant.initialized': 'Workspace initialized', + 'assistant.pathNotSet': 'No workspace path configured', + 'assistant.fileStatus': 'File Status', + 'assistant.fileExists': 'exists', + 'assistant.fileMissing': 'missing', + 'assistant.fileChars': '{count} chars', + 'assistant.onboardingTitle': 'Onboarding', + 'assistant.onboardingDesc': 'Answer questions to personalize your assistant', + 'assistant.onboardingComplete': 'Onboarding complete', + 'assistant.onboardingNotStarted': 'Not started', + 'assistant.startOnboarding': 'Start Onboarding', + 'assistant.redoOnboarding': 'Redo Onboarding', + 'assistant.onboardingQ1': 'How should I address you?', + 'assistant.onboardingQ2': 'What name should I use for myself?', + 'assistant.onboardingQ3': 'Do you prefer "concise and direct" or "detailed explanations"?', + 'assistant.onboardingQ4': 'Do you prefer "minimal interruptions" or "proactive suggestions"?', + 'assistant.onboardingQ5': 'What are your three hard boundaries?', + 'assistant.onboardingQ6': 'What are your three most important current goals?', + 'assistant.onboardingQ7': 'Do you prefer output as "lists", "reports", or "conversation summaries"?', + 'assistant.onboardingQ8': 'What information may be written to long-term memory?', + 'assistant.onboardingQ9': 'What information must never be written to long-term memory?', + 'assistant.onboardingQ10': 'What three things should I do first when entering a project?', + 'assistant.checkInTitle': 'Daily Check-in', + 'assistant.checkInDesc': 'Quick daily update to keep your assistant current', + 'assistant.lastCheckIn': 'Last check-in', + 'assistant.checkInToday': 'Completed today', + 'assistant.checkInNeeded': 'Check-in available', + 'assistant.startCheckIn': 'Start Check-in', + 'assistant.checkInQ1': 'What did you work on or accomplish today?', + 'assistant.checkInQ2': 'Any changes to your current priorities or goals?', + 'assistant.checkInQ3': 'Anything you would like me to remember going forward?', + 'assistant.refreshDocs': 'Refresh Directory Docs', + 'assistant.refreshingDocs': 'Refreshing...', + 'assistant.docsRefreshed': 'Directory docs refreshed', } as const; export type TranslationKey = keyof typeof en; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 4ad1210..2f7b977 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -621,6 +621,50 @@ const zh: Record = { 'discord.stepUserId': '获取用户 ID:右键点击自己的头像或用户名 →「复制用户 ID」', 'discord.stepChannelId': '获取频道 ID:右键点击左侧频道名称 →「复制频道 ID」', 'discord.stepGuildId': '获取服务器 ID:右键点击左上角服务器名称 →「复制服务器 ID」', + + // ── Assistant Workspace ────────────────────────────── + 'settings.assistant': '助理', + 'assistant.workspaceTitle': '助理工作区', + 'assistant.workspaceDesc': '配置一个目录用于持久化 AI 人格和记忆', + 'assistant.workspacePath': '工作区路径', + 'assistant.workspacePathHint': '选择一个目录(如 Obsidian 仓库)存放助理文件', + 'assistant.selectFolder': '选择文件夹', + 'assistant.initialize': '初始化', + 'assistant.initializing': '初始化中...', + 'assistant.initialized': '工作区已初始化', + 'assistant.pathNotSet': '未配置工作区路径', + 'assistant.fileStatus': '文件状态', + 'assistant.fileExists': '存在', + 'assistant.fileMissing': '缺失', + 'assistant.fileChars': '{count} 字符', + 'assistant.onboardingTitle': '引导设置', + 'assistant.onboardingDesc': '回答问题以个性化您的助理', + 'assistant.onboardingComplete': '引导已完成', + 'assistant.onboardingNotStarted': '未开始', + 'assistant.startOnboarding': '开始引导', + 'assistant.redoOnboarding': '重新引导', + 'assistant.onboardingQ1': '你希望我怎么称呼你?', + 'assistant.onboardingQ2': '你希望我自称什么名字?', + 'assistant.onboardingQ3': '你偏好「简洁直接」还是「详细解释」?', + 'assistant.onboardingQ4': '你希望我「少打扰」还是「主动建议」?', + 'assistant.onboardingQ5': '你的三条硬性禁区是什么?', + 'assistant.onboardingQ6': '你当前最重要的三个目标是什么?', + 'assistant.onboardingQ7': '你更喜欢输出「清单」、「报告」还是「对话总结」?', + 'assistant.onboardingQ8': '哪些信息允许写入长期记忆?', + 'assistant.onboardingQ9': '哪些信息绝对不要写入长期记忆?', + 'assistant.onboardingQ10': '每次进入项目我应先做哪三件事?', + 'assistant.checkInTitle': '每日问询', + 'assistant.checkInDesc': '每日快速更新以保持助理信息同步', + 'assistant.lastCheckIn': '上次问询', + 'assistant.checkInToday': '今日已完成', + 'assistant.checkInNeeded': '可进行问询', + 'assistant.startCheckIn': '开始问询', + 'assistant.checkInQ1': '你今天做了什么或完成了什么?', + 'assistant.checkInQ2': '你当前的优先级或目标有变化吗?', + 'assistant.checkInQ3': '有什么希望我今后记住的吗?', + 'assistant.refreshDocs': '刷新目录文档', + 'assistant.refreshingDocs': '刷新中...', + 'assistant.docsRefreshed': '目录文档已刷新', }; export default zh; diff --git a/src/lib/assistant-workspace.ts b/src/lib/assistant-workspace.ts new file mode 100644 index 0000000..d676d2e --- /dev/null +++ b/src/lib/assistant-workspace.ts @@ -0,0 +1,233 @@ +import fs from 'fs'; +import path from 'path'; +import type { AssistantWorkspaceState, AssistantWorkspaceFiles } from '@/types'; + +const DEFAULT_STATE: AssistantWorkspaceState = { + onboardingComplete: false, + lastCheckInDate: null, + schemaVersion: 1, +}; + +const STATE_DIR = '.assistant'; +const STATE_FILE = 'state.json'; + +// Canonical filenames — lowercase preferred, uppercase fallback +const FILE_MAP: Record = { + claude: ['claude.md', 'Claude.md', 'CLAUDE.md', 'AGENTS.md'], + soul: ['soul.md', 'Soul.md', 'SOUL.md'], + user: ['user.md', 'User.md', 'USER.md', 'PROFILE.md'], + memory: ['memory.md', 'Memory.md', 'MEMORY.md'], +}; + +// Templates for initialization +const FILE_TEMPLATES: Record = { + claude: '# Rules\n\n\n', + soul: '# Soul\n\n\n', + user: '# User Profile\n\n\n', + memory: '# Memory\n\n\n', +}; + +const PER_FILE_LIMIT = 8000; +const HEAD_SIZE = 6000; +const TAIL_SIZE = 1800; +const TOTAL_PROMPT_LIMIT = 40000; + +function resolveFile(dir: string, key: keyof AssistantWorkspaceFiles): { filePath: string; exists: boolean } { + for (const variant of FILE_MAP[key]) { + const filePath = path.join(dir, variant); + if (fs.existsSync(filePath)) { + return { filePath, exists: true }; + } + } + // Return canonical (first/lowercase) path when none exist + return { filePath: path.join(dir, FILE_MAP[key][0]), exists: false }; +} + +export function validateWorkspace(dir: string): { + exists: boolean; + files: Record; +} { + const dirExists = fs.existsSync(dir); + const keys = Object.keys(FILE_MAP) as Array; + const files = {} as Record; + + for (const key of keys) { + if (!dirExists) { + files[key] = { exists: false, path: null, size: 0 }; + continue; + } + const resolved = resolveFile(dir, key); + if (resolved.exists) { + const stat = fs.statSync(resolved.filePath); + files[key] = { exists: true, path: resolved.filePath, size: stat.size }; + } else { + files[key] = { exists: false, path: null, size: 0 }; + } + } + + return { exists: dirExists, files }; +} + +export function initializeWorkspace(dir: string): string[] { + const stateDir = path.join(dir, STATE_DIR); + if (!fs.existsSync(stateDir)) { + fs.mkdirSync(stateDir, { recursive: true }); + } + + const created: string[] = []; + const keys = Object.keys(FILE_MAP) as Array; + + for (const key of keys) { + const resolved = resolveFile(dir, key); + if (!resolved.exists) { + const canonicalPath = path.join(dir, FILE_MAP[key][0]); + fs.writeFileSync(canonicalPath, FILE_TEMPLATES[key], 'utf-8'); + created.push(canonicalPath); + } + } + + const statePath = path.join(stateDir, STATE_FILE); + if (!fs.existsSync(statePath)) { + saveState(dir, { ...DEFAULT_STATE }); + } + + return created; +} + +export function truncateContent(content: string, limit: number): string { + if (content.length <= limit) return content; + return content.slice(0, HEAD_SIZE) + '\n\n[...truncated...]\n\n' + content.slice(-TAIL_SIZE); +} + +export function loadWorkspaceFiles(dir: string): AssistantWorkspaceFiles { + const result: AssistantWorkspaceFiles = {}; + const keys = Object.keys(FILE_MAP) as Array; + + for (const key of keys) { + const resolved = resolveFile(dir, key); + if (resolved.exists) { + const content = fs.readFileSync(resolved.filePath, 'utf-8'); + result[key] = truncateContent(content, PER_FILE_LIMIT); + } + } + + return result; +} + +export function assembleWorkspacePrompt(files: AssistantWorkspaceFiles): string { + const order: Array = ['claude', 'soul', 'user', 'memory']; + const sections: string[] = []; + + for (const key of order) { + const content = files[key]; + if (content) { + sections.push(`<${key}>\n${content}\n`); + } + } + + if (sections.length === 0) return ''; + + let prompt = `\n${sections.join('\n\n')}\n`; + + if (prompt.length > TOTAL_PROMPT_LIMIT) { + prompt = prompt.slice(0, TOTAL_PROMPT_LIMIT); + } + + return prompt; +} + +export function loadState(dir: string): AssistantWorkspaceState { + try { + const statePath = path.join(dir, STATE_DIR, STATE_FILE); + const raw = fs.readFileSync(statePath, 'utf-8'); + return JSON.parse(raw) as AssistantWorkspaceState; + } catch { + return { ...DEFAULT_STATE }; + } +} + +export function saveState(dir: string, state: AssistantWorkspaceState): void { + const stateDir = path.join(dir, STATE_DIR); + if (!fs.existsSync(stateDir)) { + fs.mkdirSync(stateDir, { recursive: true }); + } + const statePath = path.join(stateDir, STATE_FILE); + fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf-8'); +} + +export function needsDailyCheckIn(state: AssistantWorkspaceState): boolean { + if (!state.onboardingComplete) return false; + return state.lastCheckInDate !== new Date().toISOString().slice(0, 10); +} + +export function generateDirectoryDocs(dir: string): string[] { + const generated: string[] = []; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return generated; + } + + const subdirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')); + + for (const subdir of subdirs) { + const subdirPath = path.join(dir, subdir.name); + let subEntries: fs.Dirent[]; + try { + subEntries = fs.readdirSync(subdirPath, { withFileTypes: true }); + } catch { + continue; + } + + const fileList = subEntries + .map(e => `- ${e.name}${e.isDirectory() ? '/' : ''}`) + .sort() + .join('\n'); + + const startMarker = ''; + const endMarker = ''; + const generatedBlock = `${startMarker}\n# ${subdir.name}\n\n${fileList}\n${endMarker}`; + + const readmePath = path.join(subdirPath, 'README.ai.md'); + + if (fs.existsSync(readmePath)) { + const existing = fs.readFileSync(readmePath, 'utf-8'); + const startIdx = existing.indexOf(startMarker); + const endIdx = existing.indexOf(endMarker); + + if (startIdx !== -1 && endIdx !== -1) { + const updated = existing.slice(0, startIdx) + generatedBlock + existing.slice(endIdx + endMarker.length); + fs.writeFileSync(readmePath, updated, 'utf-8'); + } else { + fs.writeFileSync(readmePath, generatedBlock + '\n', 'utf-8'); + } + } else { + fs.writeFileSync(readmePath, generatedBlock + '\n', 'utf-8'); + } + + generated.push(readmePath); + + // Generate PATH.ai.md containing full path index + const pathContent = `${startMarker}\n# ${subdir.name} — Path Index\n\nBase: \`${subdirPath}\`\n\n${subEntries.map(e => `- \`${path.join(subdirPath, e.name)}${e.isDirectory() ? '/' : ''}\``).sort().join('\n')}\n${endMarker}`; + const pathFilePath = path.join(subdirPath, 'PATH.ai.md'); + + if (fs.existsSync(pathFilePath)) { + const existing = fs.readFileSync(pathFilePath, 'utf-8'); + const startIdx = existing.indexOf(startMarker); + const endIdx = existing.indexOf(endMarker); + if (startIdx !== -1 && endIdx !== -1) { + const updated = existing.slice(0, startIdx) + pathContent + existing.slice(endIdx + endMarker.length); + fs.writeFileSync(pathFilePath, updated, 'utf-8'); + } else { + fs.writeFileSync(pathFilePath, pathContent + '\n', 'utf-8'); + } + } else { + fs.writeFileSync(pathFilePath, pathContent + '\n', 'utf-8'); + } + generated.push(pathFilePath); + } + + return generated; +} diff --git a/src/lib/db.ts b/src/lib/db.ts index e98fa1e..2e322c6 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -701,6 +701,13 @@ export function createSession( return getSession(id)!; } +export function getLatestSessionByWorkingDirectory(workingDirectory: string): ChatSession | undefined { + const db = getDb(); + return db.prepare( + 'SELECT * FROM chat_sessions WHERE working_directory = ? ORDER BY updated_at DESC LIMIT 1' + ).get(workingDirectory) as ChatSession | undefined; +} + export function deleteSession(id: string): boolean { const db = getDb(); const result = db.prepare('DELETE FROM chat_sessions WHERE id = ?').run(id); diff --git a/src/lib/stream-session-manager.ts b/src/lib/stream-session-manager.ts index e0548aa..c9e37f8 100644 --- a/src/lib/stream-session-manager.ts +++ b/src/lib/stream-session-manager.ts @@ -54,6 +54,8 @@ export interface StartStreamParams { files?: FileAttachment[]; systemPromptAppend?: string; pendingImageNotices?: string[]; + /** When true, backend skips saving user message and title update (assistant auto-trigger) */ + autoTrigger?: boolean; /** Called when SDK mode changes (e.g. plan → code) */ onModeChanged?: (mode: string) => void; /** Reference to the outer sendMessage so tool-timeout auto-retry works */ @@ -215,6 +217,7 @@ async function runStream(stream: ActiveStream, params: StartStreamParams): Promi provider_id: params.providerId, ...(params.files && params.files.length > 0 ? { files: params.files } : {}), ...(params.systemPromptAppend ? { systemPromptAppend: params.systemPromptAppend } : {}), + ...(params.autoTrigger ? { autoTrigger: true } : {}), }), signal: stream.abortController.signal, }); diff --git a/src/types/index.ts b/src/types/index.ts index 094b60e..1003100 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -439,8 +439,27 @@ export const SETTING_KEYS = { THEME: 'theme', PERMISSION_MODE: 'permission_mode', MAX_THINKING_TOKENS: 'max_thinking_tokens', + ASSISTANT_WORKSPACE_PATH: 'assistant_workspace_path', } as const; +// ========================================== +// Assistant Workspace Types +// ========================================== + +export interface AssistantWorkspaceState { + onboardingComplete: boolean; + lastCheckInDate: string | null; + schemaVersion: number; + hookTriggeredSessionId?: string; +} + +export interface AssistantWorkspaceFiles { + soul?: string; + memory?: string; + user?: string; + claude?: string; +} + // ========================================== // Reference Image Types (for image generation) // ==========================================