Skip to content
Merged
52 changes: 52 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# AGENTS.md

## Purpose

NovAI is intended to evolve toward an agentic novel-writing tool, closer to the interaction model of Claude Code / Vibe Coding tools than to a traditional chat app.

The core loop should be:

1. The user expresses intent in natural language.
2. The AI maintains task context.
3. The AI uses tools to read and write project files.
4. Story artifacts are saved into the local project filesystem.
5. The conversation acts as the collaboration interface, not the primary storage for story content.

## Product Direction

When making implementation decisions, prefer this framing:

- The chat UI is an agent control surface.
- The AI should operate on files, not mainly emit long final text into the chat stream.
- Chapters, prompts, and elements belong in files.
- Conversation history is for collaboration, clarification, planning, and action summaries.
- Generated story content should be previewed in file/content panels and written back to the project.

This means NovAI should gradually move away from a simple "single prompt -> single response" flow and toward a tool-using agent workflow for story creation and revision.

## Reference Repository

For implementation reference and comparative study, keep this external repository available next to the NovAI repo:

- `/Users/honlnk/project/claude-code-sound`

This repository is intentionally cloned outside the NovAI git repository so that:

- it does not affect NovAI git status,
- it is not accidentally committed,
- it can still be read and compared during development.

When useful, study that repository for patterns such as:

- agent loop design,
- conversation state management,
- tool invocation structure,
- streaming interaction flow,
- file-oriented execution behavior.

## Working Rule

When documentation and code appear to conflict, prefer the clarified product intent above:

- NovAI is not just a workspace with a chat box.
- NovAI should become a conversation-driven AI agent for writing stories through tools and files.
35 changes: 26 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,29 @@ NovAI 的思路是换一条路:

## 当前状态

项目目前处于早期开发阶段,正在验证 MVP 的最小可用创作闭环
项目目前处于 MVP 早期实现阶段,正在优先验证 AI 最小可用创作闭环

当前仓库已经完成的内容主要包括:

- Vue 3 + TypeScript + Vite 前端工程初始化
- 首页、工作区、设置页的基础路由和页面骨架
- 本地小说项目的创建与打开流程
- 不合法项目目录的检测与修复流程
- 标准项目目录初始化
- 文件树扫描与基础文件预览链路
- `novel.config.json` 读写
- LLM / Embedding 配置测试连接
- LLM 流式生成链路
- `prompts/system.md` 读取与保存
- 章节文件写入 `chapters/`
- 测试页中的项目文档分组与原文预览
- 项目规划、需求说明、UI 设计、技术架构等文档整理

尚未完整落地的核心能力包括:

- LLM / Embedding 真实接入
- 章节流式生成
- 要素抽取
- Embedding 向量化与 RAG 检索
- AI 精筛选与更完整的创作工作流
- 近期章节上下文拼装
- Rerank 精排与更完整的创作工作流
- 正式工作台 UI

## MVP 目标

Expand Down Expand Up @@ -91,15 +96,27 @@ NovAI 采用“一个文件夹就是一个小说项目”的思路。当前默
- Vite
- Pinia
- Vue Router
- Sass
- Tailwind CSS v4
- File System Access API

规划中的核心能力还包括:

- File System Access API
- Orama
- isomorphic-git

## 当前实现方式

为了优先验证 AI 主链路,当前版本暂不继续推进正式工作台界面,而是采用一个极简测试页 `/test` 作为开发入口。

当前测试页已经可以完成:

- 创建 / 打开 / 修复小说项目
- 编辑并保存项目配置
- 测试 LLM / Embedding 连通性
- 发起流式生成
- 保存 SYSTEM Prompt
- 保存生成章节
- 浏览项目中的 Markdown / JSON / 文本文档原文

## 本地开发

### 环境要求
Expand Down
6 changes: 6 additions & 0 deletions src/app/router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'

import SessionTestView from '../views/SessionTestView.vue'
import TestLabView from '../views/TestLabView.vue'

export const router = createRouter({
Expand All @@ -14,5 +15,10 @@ export const router = createRouter({
name: 'test',
component: TestLabView,
},
{
path: '/session-test',
name: 'session-test',
component: SessionTestView,
},
],
})
189 changes: 189 additions & 0 deletions src/core/ai/rerank-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { createJsonHeaders, extractErrorMessage, normalizeBaseUrl, readJsonResponse, resolveApiUrl } from '../ai/shared'

import type { ModelConnectionResult } from '../../types/ai'
import type { RerankInput, RerankResult } from '../../types/rag'

export type RerankConnectionInput = {
baseUrl: string
apiKey: string
model?: string
}

export async function testRerankConnection(
input: RerankConnectionInput,
): Promise<ModelConnectionResult> {
const baseUrl = normalizeBaseUrl(input.baseUrl)

if (!baseUrl || !input.apiKey.trim()) {
return {
ok: false,
message: '请先填写 Rerank 的 API 地址和 API Key',
}
}

try {
const response = await fetch(`${baseUrl}/models`, {
method: 'GET',
headers: createJsonHeaders(input.apiKey, baseUrl),
})

if (!response.ok) {
const payload = await readJsonResponse(response)
return {
ok: false,
message: extractErrorMessage(payload, 'Rerank 测试连接失败'),
}
}

return {
ok: true,
message: 'Rerank 连接成功',
}
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : 'Rerank 测试连接失败',
}
}
}

export async function rerankCandidates(
input: RerankConnectionInput & RerankInput,
): Promise<RerankResult> {
const baseUrl = normalizeBaseUrl(input.baseUrl)

if (!baseUrl || !input.apiKey.trim() || !input.model?.trim()) {
throw new Error('请先填写 Rerank 的 API 地址、API Key 和模型名称')
}

const request = buildRerankRequest(baseUrl, input)
const response = await fetch(request.url, {
method: 'POST',
headers: request.headers,
body: JSON.stringify(request.body),
})

if (!response.ok) {
const payload = await readJsonResponse(response)
throw new Error(extractErrorMessage(payload, 'Rerank 请求失败'))
}

const payload = await readJsonResponse(response)
return normalizeRerankResult(payload, input)
}

function normalizeRerankResult(payload: unknown, input: RerankInput): RerankResult {
const results = extractRerankResults(payload)

if (results) {
const items = results
.map((item) => {
if (
item &&
typeof item === 'object' &&
'index' in item &&
typeof item.index === 'number'
) {
const candidate = input.candidates[item.index]

if (!candidate) {
return null
}

return {
id: candidate.id,
score:
'relevance_score' in item && typeof item.relevance_score === 'number'
? item.relevance_score
: 0,
}
}

return null
})
.filter((item): item is NonNullable<typeof item> => item !== null)

return {
items,
model:
payload &&
typeof payload === 'object' &&
'model' in payload &&
typeof payload.model === 'string'
? payload.model
: undefined,
}
}

return {
items: input.candidates.slice(0, input.topN).map((candidate, index) => ({
id: candidate.id,
score: input.candidates.length - index,
})),
}
}

function extractRerankResults(payload: unknown) {
if (
payload &&
typeof payload === 'object' &&
'results' in payload &&
Array.isArray(payload.results)
) {
return payload.results
}

if (
payload &&
typeof payload === 'object' &&
'output' in payload &&
payload.output &&
typeof payload.output === 'object' &&
'results' in payload.output &&
Array.isArray(payload.output.results)
) {
return payload.output.results
}

return null
}

function buildRerankRequest(baseUrl: string, input: RerankConnectionInput & RerankInput) {
const model = input.model?.trim() ?? ''

if (isDashScopeBaseUrl(baseUrl)) {
const dashScopeOrigin = new URL(baseUrl).origin
const dashScopePath = '/api/v1/services/rerank/text-rerank/text-rerank'

return {
url: resolveApiUrl(dashScopeOrigin, dashScopePath),
headers: createJsonHeaders(input.apiKey, dashScopeOrigin),
body: {
model,
input: {
query: input.query,
documents: input.candidates.map((candidate) => candidate.retrievalText),
},
parameters: {
top_n: input.topN,
return_documents: false,
},
},
}
}

return {
url: resolveApiUrl(baseUrl, '/rerank'),
headers: createJsonHeaders(input.apiKey, baseUrl),
body: {
model,
query: input.query,
top_n: input.topN,
documents: input.candidates.map((candidate) => candidate.retrievalText),
},
}
}

function isDashScopeBaseUrl(baseUrl: string) {
return /dashscope(-intl)?\.aliyuncs\.com/.test(baseUrl)
}
Loading
Loading