Skip to content

Commit 0d36be2

Browse files
committed
feat: 完善 OAuth 认证流程与模型导入系统
新增功能: - 实现完整的 OAuth 认证流程,支持多种 issuer(ZCode、BigModel、ZAI 等) - 添加 OAuth loopback 服务器实现,支持授权码回调 - 扩展模型导入功能,支持预设导入和模型绑定 - 新增 Ollama 模型供应商支持 - 增强会话报告系统,支持 subagent 报告展示 改进项: - 改进模型列表获取逻辑,支持 OAuth 认证的上游模型 - 完善导入对话界面,支持选择导入源 - 增强模型绑定和预设管理功能 - 扩展国际化支持 测试: - 新增 OAuth 流程、registry、loopback 等单元测试 - 扩展模型列表和导入相关测试 - 新增 thinking adapter 测试
1 parent 966622d commit 0d36be2

53 files changed

Lines changed: 5412 additions & 253 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/main/db/repo.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3009,6 +3009,104 @@ export function putImportMapping(mapping: ImportMappingRow): void {
30093009
)
30103010
}
30113011

3012+
/**
3013+
* 扫描缓存的一行。★ 这不是「导入过什么」的记录(那是 `import_mappings`),
3014+
* 而是「上次扫描时从这个文件里读出了什么」—— 没导入的文件也有。
3015+
*/
3016+
export interface ImportScanCacheRow {
3017+
sourceId: string
3018+
sourcePath: string
3019+
changeToken: string
3020+
sessionId: string
3021+
/** '' = 只读了头部,没算过全文哈希。 */
3022+
contentHash: string
3023+
cwd: string
3024+
title: string
3025+
model: string
3026+
modelProvider: string
3027+
/** partial 为真时是下界。 */
3028+
messages: number
3029+
partial: boolean
3030+
sourceUpdatedAt: number
3031+
scannedAt: number
3032+
}
3033+
3034+
function importScanCacheFromRow(row: Record<string, unknown>): ImportScanCacheRow {
3035+
return {
3036+
sourceId: String(row['source_id'] ?? ''),
3037+
sourcePath: String(row['source_path'] ?? ''),
3038+
changeToken: String(row['change_token'] ?? ''),
3039+
sessionId: String(row['session_id'] ?? ''),
3040+
contentHash: String(row['content_hash'] ?? ''),
3041+
cwd: String(row['cwd'] ?? ''),
3042+
title: String(row['title'] ?? ''),
3043+
model: String(row['model'] ?? ''),
3044+
modelProvider: String(row['model_provider'] ?? ''),
3045+
messages: Number(row['messages'] ?? 0),
3046+
partial: Number(row['partial'] ?? 0) === 1,
3047+
sourceUpdatedAt: Number(row['source_updated_at'] ?? 0),
3048+
scannedAt: Number(row['scanned_at'] ?? 0)
3049+
}
3050+
}
3051+
3052+
/** 一个来源的全部缓存行,按 `source_path` 建索引供扫描循环查。 */
3053+
export function listImportScanCache(sourceId: string): Map<string, ImportScanCacheRow> {
3054+
const rows = stmt('SELECT * FROM import_scan_cache WHERE source_id = ?').all(sourceId)
3055+
const map = new Map<string, ImportScanCacheRow>()
3056+
for (const row of rows) {
3057+
const parsed = importScanCacheFromRow(row as Record<string, unknown>)
3058+
map.set(parsed.sourcePath, parsed)
3059+
}
3060+
return map
3061+
}
3062+
3063+
/**
3064+
* 整批写回。★ 一次事务,不是一行一个 —— 一轮扫描上千行,逐行提交在 WAL 上
3065+
* 是上千次写放大,那正是这套缓存要省掉的开销。
3066+
*/
3067+
export function putImportScanCache(rows: readonly ImportScanCacheRow[]): void {
3068+
if (rows.length === 0) return
3069+
tx(() => {
3070+
const write = stmt(
3071+
`INSERT INTO import_scan_cache (
3072+
source_id, source_path, change_token, session_id, content_hash, cwd, title,
3073+
model, model_provider, messages, partial, source_updated_at, scanned_at
3074+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3075+
ON CONFLICT (source_id, source_path) DO UPDATE SET
3076+
change_token = excluded.change_token,
3077+
session_id = excluded.session_id,
3078+
content_hash = excluded.content_hash,
3079+
cwd = excluded.cwd,
3080+
title = excluded.title,
3081+
model = excluded.model,
3082+
model_provider = excluded.model_provider,
3083+
messages = excluded.messages,
3084+
partial = excluded.partial,
3085+
source_updated_at = excluded.source_updated_at,
3086+
scanned_at = excluded.scanned_at`
3087+
)
3088+
for (const row of rows) {
3089+
write.run(
3090+
row.sourceId, row.sourcePath, row.changeToken, row.sessionId, row.contentHash,
3091+
row.cwd, row.title, row.model, row.modelProvider, row.messages,
3092+
row.partial ? 1 : 0, row.sourceUpdatedAt, row.scannedAt
3093+
)
3094+
}
3095+
})
3096+
}
3097+
3098+
/**
3099+
* 清掉本轮没再见到的行 —— 源侧文件删了,缓存不该无限长大。
3100+
*
3101+
* ★ 按 `scanned_at` 判定而不是传一份「见过的路径」清单:一轮扫描上千条路径,
3102+
* 拼进 `NOT IN (...)` 会撞上 SQLite 的变量上限,而时间戳比较没有这个问题。
3103+
*/
3104+
export function pruneImportScanCache(sourceId: string, scannedAt: number): number {
3105+
const result = stmt('DELETE FROM import_scan_cache WHERE source_id = ? AND scanned_at < ?')
3106+
.run(sourceId, scannedAt)
3107+
return Number(result.changes ?? 0)
3108+
}
3109+
30123110
/**
30133111
* 把某个本地目标对应的全部映射切到新状态。
30143112
*

src/main/db/schema.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,48 @@ CREATE TABLE usage_daily (
834834
CREATE INDEX usage_daily_by_day ON usage_daily (day);
835835
`
836836

837+
/**
838+
* 扫描缓存 —— 让「扫过但**没导入**」的转录也能跳过重读。
839+
*
840+
* ★ 存在的理由:changeToken 原先挂在 `import_mappings.meta` 上,而映射只有
841+
* 导入过的会话才有。实测一台机器上 1063 个转录只有 47 条映射 —— 96% 的文件
842+
* 每次扫描都要完整重读(Codex 那 1.6GB 读一遍约 5 秒,全程堵在主进程里)。
843+
* 换句话说那个缓存对绝大多数人是不生效的。
844+
*
845+
* ★ 主键用**文件路径**不用 sessionId:sessionId 要解析完文件才知道
846+
* (Codex 的文件名是 `rollout-*`,与会话 id 无关),而缓存的全部意义
847+
* 就是在读文件之前决定「要不要读」。
848+
*
849+
* ★ `content_hash = ''` 表示这一行是**只读头部**得来的 —— 那种情况下
850+
* `messages` 是下界而不是精确值。判据见 `service.ts` 的 `readChatMeta`:
851+
* 没有映射的条目状态必然是 new,而 new 不需要哈希,所以那一趟全文读是白读的。
852+
*/
853+
const V21_IMPORT_SCAN_CACHE = `
854+
CREATE TABLE import_scan_cache (
855+
source_id TEXT NOT NULL,
856+
-- 绝对路径。OpenCode 这类库内会话用 \`opencode.db#<id>\` 这种合成路径。
857+
source_path TEXT NOT NULL,
858+
-- size:mtime。和源文件一比就知道整行能不能复用。
859+
change_token TEXT NOT NULL,
860+
-- 解析出来的会话 id(去重与映射键都要用它)。
861+
session_id TEXT NOT NULL DEFAULT '',
862+
-- '' = 只读了头部,没算过全文哈希。
863+
content_hash TEXT NOT NULL DEFAULT '',
864+
cwd TEXT NOT NULL DEFAULT '',
865+
title TEXT NOT NULL DEFAULT '',
866+
model TEXT NOT NULL DEFAULT '',
867+
model_provider TEXT NOT NULL DEFAULT '',
868+
-- partial = 1 时这是下界。
869+
messages INTEGER NOT NULL DEFAULT 0,
870+
partial INTEGER NOT NULL DEFAULT 0,
871+
source_updated_at INTEGER NOT NULL DEFAULT 0,
872+
scanned_at INTEGER NOT NULL,
873+
PRIMARY KEY (source_id, source_path)
874+
);
875+
-- 扫完一轮要按来源清掉本轮没再见到的行(源侧文件已删)
876+
CREATE INDEX import_scan_cache_by_source ON import_scan_cache (source_id, scanned_at);
877+
`
878+
837879
export const MIGRATIONS: readonly Migration[] = [
838880
{ version: 1, name: 'core', sql: V1_CORE },
839881
{ version: 2, name: 'connections', sql: V2_CONNECTIONS },
@@ -857,4 +899,5 @@ export const MIGRATIONS: readonly Migration[] = [
857899
{ version: 18, name: 'scheduled-tasks', sql: V18_SCHEDULED_TASKS }
858900
,{ version: 19, name: 'plans-v2', sql: V19_PLANS_V2 }
859901
,{ version: 20, name: 'usage-daily', sql: V20_USAGE_DAILY }
902+
,{ version: 21, name: 'import-scan-cache', sql: V21_IMPORT_SCAN_CACHE }
860903
]
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* 预览只读头部 —— **省下的必须只是时间,不能是结论**。
3+
*
4+
* 这套用例守的是一条很容易被优化顺手破坏的性质:扫描从「全文读」改成
5+
* 「读前 1MiB」之后,一份**前 1MiB 全被单条巨型工具输出占满**的转录会解析出
6+
* 0 条消息。而 0 条消息在两个扫描循环里都是终局判决 ——
7+
* Claude 那边 `continue` 直接把它丢出预览,Codex 那边标成 incompatible。
8+
* 两者都不抛异常、不记诊断,用户唯一能观察到的现象是「我那个会话不见了」。
9+
*
10+
* 实测语料里这种文件真实存在(单个 115MB 的 Codex 转录,前若干 MB 是一条
11+
* 工具输出),所以这不是假想的边界。
12+
*/
13+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
14+
import { tmpdir } from 'node:os'
15+
import { join } from 'node:path'
16+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
17+
import { IMPORT_LIMITS } from '../../../shared/domain/import'
18+
import { closeDatabase, openDatabase } from '../../db/index'
19+
import { store } from '../../state/store'
20+
import * as service from '../service'
21+
22+
vi.mock('../../ipc/attachment', () => ({
23+
uploadAttachment: (req: { mime: string }) => ({
24+
id: 'att', scope: 'session', displayName: 'x', mime: req.mime,
25+
size: 1, checksum: 'c', createdAt: 0, url: 'ncw://attachments/session/x/att.png'
26+
})
27+
}))
28+
29+
let dbDir = ''
30+
let sourceDir = ''
31+
let projectDir = ''
32+
33+
beforeEach(() => {
34+
closeDatabase()
35+
dbDir = mkdtempSync(join(tmpdir(), 'ncw-head-db-'))
36+
openDatabase(dbDir)
37+
sourceDir = mkdtempSync(join(tmpdir(), 'ncw-head-cc-'))
38+
projectDir = mkdtempSync(join(tmpdir(), 'ncw-head-proj-'))
39+
mkdirSync(join(sourceDir, 'projects', 'encoded-proj'), { recursive: true })
40+
})
41+
42+
afterEach(() => {
43+
closeDatabase()
44+
for (const dir of [dbDir, sourceDir, projectDir]) rmSync(dir, { recursive: true, force: true })
45+
})
46+
47+
/**
48+
* 一份「头重脚轻」的转录:第一条就是撑满头部预算的工具结果,
49+
* 真正的对话记录全在它后面。
50+
*/
51+
function headHeavyTranscript(sessionId: string): string {
52+
const filler = 'x'.repeat(IMPORT_LIMITS.scanHeadBytes + 512 * 1024)
53+
return [
54+
{
55+
uuid: 'u0', parentUuid: null, type: 'user', cwd: projectDir, sessionId,
56+
timestamp: '2025-01-01T00:00:00.000Z',
57+
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't0', content: filler, is_error: false }] }
58+
},
59+
{
60+
uuid: 'u1', parentUuid: 'u0', type: 'user', cwd: projectDir, sessionId,
61+
timestamp: '2025-01-01T00:00:01.000Z',
62+
message: { role: 'user', content: '这条在头部预算之外' }
63+
},
64+
{
65+
uuid: 'a1', parentUuid: 'u1', type: 'assistant',
66+
timestamp: '2025-01-01T00:00:02.000Z',
67+
message: { role: 'assistant', model: 'claude-sonnet-4-5', content: [{ type: 'text', text: '收到。' }] }
68+
}
69+
].map((r) => JSON.stringify(r)).join('\n') + '\n'
70+
}
71+
72+
function seed(sessionId: string, body: string): string {
73+
writeFileSync(join(sourceDir, 'projects', 'encoded-proj', `${sessionId}.jsonl`), body)
74+
const now = Date.now()
75+
store.putImportSource({
76+
sourceId: 'src-head', kind: 'claude-code', configDir: sourceDir, origin: 'user-picked',
77+
syncEnabled: false, categories: ['chat', 'project'], projectKeys: [projectDir],
78+
status: 'off', diagnostics: [], createdAt: now, updatedAt: now
79+
})
80+
return 'src-head'
81+
}
82+
83+
async function chatItems(sourceId: string, requestId: string) {
84+
const preview = await service.buildPreview(sourceId, requestId)
85+
return service.previewItems({ previewId: preview.previewId, category: 'chat', offset: 0, limit: 50 }).items
86+
}
87+
88+
describe('预览的头部读取', () => {
89+
it('★★ 前 1MiB 全是一条巨型工具输出时,会话依然出现在预览里', async () => {
90+
const sourceId = seed('sess-head-heavy', headHeavyTranscript('sess-head-heavy'))
91+
const items = await chatItems(sourceId, 'head-heavy')
92+
93+
/*
94+
没有那条回退的话,这里拿到的是空数组 —— 而且整个流程一声不吭。
95+
断言写成「至少一条」而不是精确条数,是因为这里要守的是
96+
「不会被静默丢掉」,不是编码器的具体产出。
97+
*/
98+
expect(items).toHaveLength(1)
99+
expect(items[0]?.count).toBeGreaterThan(0)
100+
})
101+
102+
it('★ 回退读的是全文,所以条数是准的,不标近似', async () => {
103+
const sourceId = seed('sess-exact', headHeavyTranscript('sess-exact'))
104+
const items = await chatItems(sourceId, 'exact')
105+
106+
/*
107+
★ 先断言长度。少了这一句,下面那条在 items 为空时会**真空通过**
108+
(`items[0]?.x` 对空数组同样是 undefined)—— 摘掉回退跑一遍就会发现
109+
它照样是绿的,那种用例什么都守不住。
110+
*/
111+
expect(items).toHaveLength(1)
112+
// 头部没解析出消息 → 回退 parseAll → partial 为假 → 不该打近似标记。
113+
expect(items[0]?.countApproximate).toBeUndefined()
114+
})
115+
116+
it('★ 真正的空会话仍然被挡在预览之外 —— 回退不是「什么都放进来」', async () => {
117+
const empty = JSON.stringify({
118+
uuid: 'm1', parentUuid: null, type: 'summary', cwd: projectDir,
119+
sessionId: 'sess-empty', timestamp: '2025-01-01T00:00:00.000Z', summary: '只有摘要没有对话'
120+
}) + '\n'
121+
const sourceId = seed('sess-empty', empty)
122+
expect(await chatItems(sourceId, 'empty')).toHaveLength(0)
123+
})
124+
})

src/main/imports/claude-code.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
* 里那张 projects 表),目录名只当**索引键**用。
2424
*/
2525
import { createReadStream } from 'node:fs'
26-
import { readdir, readFile, realpath, stat } from 'node:fs/promises'
26+
import { open, readdir, readFile, realpath, stat } from 'node:fs/promises'
2727
import { createInterface } from 'node:readline'
2828
import { createHash } from 'node:crypto'
2929
import { homedir } from 'node:os'
@@ -179,6 +179,43 @@ export async function readTranscriptLines(
179179
return { lines, diagnostics }
180180
}
181181

182+
/**
183+
* 只读一份转录的**开头**。
184+
*
185+
* ★ 为什么够用:扫描要的是 `cwd` / `title` / `model` / `sessionId`,它们都在
186+
* 转录的头几条记录里;而全文读一遍只多产出两样东西 —— 精确的消息数和
187+
* `contentHash`。哈希**只在这个会话已经导入过**时才有人看(`statusOfChat`
188+
* 里没有映射直接就是 new),那是少数。实测一台机器上 1063 个转录里
189+
* 只有 47 个导入过,为另外 96% 读满 2GB 是白读。
190+
*
191+
* ★ 末尾那一行**必须丢掉**:按字节截断几乎总会把最后一行切在半中间,
192+
* 而半条 JSON 喂给 `JSON.parse` 会变成一条「文件损坏」诊断 —— 答非所问。
193+
* 整个文件都读完了(`complete`)时它是完整的,那时才留。
194+
*/
195+
export async function readTranscriptHead(
196+
path: string,
197+
maxBytes: number
198+
): Promise<{ lines: string[]; complete: boolean }> {
199+
const info = await stat(path)
200+
const want = Math.min(maxBytes, info.size)
201+
const handle = await open(path, 'r')
202+
try {
203+
const buffer = Buffer.alloc(want)
204+
let filled = 0
205+
while (filled < want) {
206+
const { bytesRead } = await handle.read(buffer, filled, want - filled, filled)
207+
if (bytesRead === 0) break
208+
filled += bytesRead
209+
}
210+
const complete = filled >= info.size
211+
const lines = buffer.subarray(0, filled).toString('utf8').split('\n')
212+
if (!complete) lines.pop()
213+
return { lines: lines.filter((line) => line !== ''), complete }
214+
} finally {
215+
await handle.close()
216+
}
217+
}
218+
182219
// ─── 项目与转录枚举 ───────────────────────────────────────────────────────
183220

184221
export interface TranscriptFile {

0 commit comments

Comments
 (0)