Skip to content

Commit 08e8e50

Browse files
authored
Merge pull request #32 from minorcell/dev
feat: 支持切换模型,修复历史聊天记录切换问题
2 parents bd199b4 + 4c0b3c2 commit 08e8e50

29 files changed

Lines changed: 1532 additions & 131 deletions

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ current_provider = "deepseek"
5555
max_steps = 100
5656
stream_output = true
5757

58-
[[providers]]
58+
[[providers.deepseek]]
5959
name = "deepseek"
6060
env_api_key = "DEEPSEEK_API_KEY" # 仅存环境变量名
6161
model = "deepseek-chat"

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,13 +175,15 @@ current_provider = "deepseek"
175175
max_steps = 100
176176
stream_output = false
177177
178-
[[providers]]
178+
[[providers.deepseek]]
179179
name = "deepseek"
180180
env_api_key = "DEEPSEEK_API_KEY"
181181
model = "deepseek-chat"
182182
base_url = "https://api.deepseek.com"
183183
```
184184

185+
可通过多个 `[[providers.<name>]]` 段落配置多个 Provider。
186+
185187
MCP 服务器示例:
186188

187189
```toml

‎bun.lock‎

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@dqbd/tiktoken": "^1.0.22",
2424
"@modelcontextprotocol/sdk": "^1.24.3",
2525
"openai": "^6.10.0",
26+
"react-devtools-core": "^7.0.1",
2627
"toml": "^3.0.0"
2728
}
2829
}

‎packages/core/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"module": "src/index.ts",
55
"private": true,
66
"dependencies": {
7+
"ignore": "^7.0.5",
78
"zod": "^4.1.13"
89
}
910
}

‎packages/core/src/config/config.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ describe('mcp config serialization', () => {
122122
})
123123

124124
const text = await Bun.file(configPath).text()
125+
expect(text).toContain('[[providers.deepseek]]')
125126
expect(text).toContain('[mcp_servers.remote]')
126127
expect(text).toContain('type = "streamable_http"')
127128
expect(text).toContain('url = "https://example.com/mcp"')
@@ -141,7 +142,7 @@ current_provider = "deepseek"
141142
stream_output = false
142143
max_steps = 42
143144
144-
[[providers]]
145+
[[providers.deepseek]]
145146
name = "deepseek"
146147
env_api_key = "DEEPSEEK_API_KEY"
147148
model = "deepseek-chat"
@@ -171,4 +172,23 @@ url = "https://legacy.example.com/mcp"
171172
expectSseServer(legacy)
172173
expect(legacy.url).toBe('https://legacy.example.com/mcp')
173174
})
175+
176+
test('loadMemoConfig ignores legacy providers array', async () => {
177+
const home = join(tempBase, 'memo-home-legacy')
178+
process.env.MEMO_HOME = home
179+
await mkdir(home, { recursive: true })
180+
const configText = `
181+
current_provider = "legacy"
182+
183+
[[providers]]
184+
name = "legacy"
185+
env_api_key = "LEGACY_API_KEY"
186+
model = "legacy-model"
187+
`
188+
await Bun.write(join(home, 'config.toml'), configText)
189+
190+
const loaded = await loadMemoConfig()
191+
expect(loaded.needsSetup).toBe(true)
192+
expect(loaded.config.current_provider).not.toBe('legacy')
193+
})
174194
})

‎packages/core/src/config/config.ts‎

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export type MemoConfig = {
4545
providers: ProviderConfig[]
4646
}
4747

48+
type ParsedMemoConfig = Omit<Partial<MemoConfig>, 'providers'> & { providers?: unknown }
49+
4850
const DEFAULT_MEMO_HOME = join(homedir(), '.memo')
4951
const DEFAULT_SESSIONS_DIR = 'sessions'
5052
const DEFAULT_MEMORY_FILE = 'memo.md'
@@ -64,6 +66,29 @@ const DEFAULT_CONFIG: MemoConfig = {
6466
mcp_servers: {},
6567
}
6668

69+
function formatTomlKey(key: string) {
70+
return /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key)
71+
}
72+
73+
function normalizeProviders(input: unknown): ProviderConfig[] {
74+
if (!input || typeof input !== 'object' || Array.isArray(input)) return []
75+
76+
const providers: ProviderConfig[] = []
77+
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
78+
if (!value) continue
79+
const entries = Array.isArray(value) ? value : [value]
80+
for (const entry of entries) {
81+
if (!entry || typeof entry !== 'object') continue
82+
const provider = { ...(entry as ProviderConfig) }
83+
if ((typeof provider.name !== 'string' || provider.name.length === 0) && key) {
84+
provider.name = key
85+
}
86+
providers.push(provider)
87+
}
88+
}
89+
return providers
90+
}
91+
6792
function expandHome(path: string) {
6893
if (path.startsWith('~')) {
6994
return join(homedir(), path.slice(1))
@@ -74,13 +99,23 @@ function expandHome(path: string) {
7499
function serializeConfig(config: MemoConfig) {
75100
const providers = config.providers
76101
.map(
77-
(p) =>
78-
`[[providers]]
79-
name = "${p.name}"
80-
env_api_key = "${p.env_api_key}"
81-
model = "${p.model}"
82-
${p.base_url ? `base_url = "${p.base_url}"\n` : ''}`,
102+
(p) => {
103+
const name = typeof p?.name === 'string' ? p.name : ''
104+
if (!name) return ''
105+
const key = formatTomlKey(name)
106+
const lines = [
107+
`[[providers.${key}]]`,
108+
`name = ${JSON.stringify(name)}`,
109+
`env_api_key = ${JSON.stringify(String(p.env_api_key ?? ''))}`,
110+
`model = ${JSON.stringify(String(p.model ?? ''))}`,
111+
]
112+
if (p.base_url) {
113+
lines.push(`base_url = ${JSON.stringify(String(p.base_url))}`)
114+
}
115+
return lines.join('\n')
116+
},
83117
)
118+
.filter(Boolean)
84119
.join('\n\n')
85120

86121
let mcpSection = ''
@@ -139,12 +174,13 @@ export async function loadMemoConfig(): Promise<LoadedConfig> {
139174
return { config: DEFAULT_CONFIG, home, configPath, needsSetup: true }
140175
}
141176
const text = await file.text()
142-
const parsed = parse(text) as Partial<MemoConfig>
177+
const parsed = parse(text) as ParsedMemoConfig
178+
const providers = normalizeProviders(parsed.providers)
143179
const merged: MemoConfig = {
144180
current_provider: parsed.current_provider ?? DEFAULT_CONFIG.current_provider,
145181
max_steps: parsed.max_steps ?? DEFAULT_CONFIG.max_steps,
146182
stream_output: parsed.stream_output ?? DEFAULT_CONFIG.stream_output,
147-
providers: parsed.providers ?? [],
183+
providers,
148184
mcp_servers: parsed.mcp_servers ?? {},
149185
}
150186
const needsSetup = !merged.providers.length
@@ -198,14 +234,22 @@ export function buildSessionPath(baseDir: string, sessionId: string) {
198234
const HH = String(now.getHours()).padStart(2, '0')
199235
const MM = String(now.getMinutes()).padStart(2, '0')
200236
const SS = String(now.getSeconds()).padStart(2, '0')
201-
const cwd = process.cwd()
202-
const safeParts = cwd.split(/[/\\]+/).map((p) => sanitizePathComponent(p))
203-
const truncatedParts = truncatePath(safeParts, 180)
204-
const dirName = truncatedParts.join('-')
237+
const dirName = buildSessionDirName(process.cwd())
205238
const fileName = `${yyyy}-${mm}-${dd}_${HH}${MM}${SS}_${sessionId}.jsonl`
206239
return join(baseDir, dirName, fileName)
207240
}
208241

242+
function buildSessionDirName(cwd: string) {
243+
const safeParts = cwd.split(/[/\\]+/).map((p) => sanitizePathComponent(p))
244+
const truncatedParts = truncatePath(safeParts, 180)
245+
return truncatedParts.join('-') || 'root'
246+
}
247+
248+
/** 获取某个 cwd 对应的 session 日志目录。 */
249+
export function getSessionLogDir(baseDir: string, cwd: string) {
250+
return join(baseDir, buildSessionDirName(cwd))
251+
}
252+
209253
/** 提供一个新的 sessionId,便于外部复用。 */
210254
export function createSessionId() {
211255
return randomUUID()

‎packages/core/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export * from '@memo/core/config/config'
77
export * from '@memo/core/utils/utils'
88
export * from '@memo/core/utils/tokenizer'
99
export * from '@memo/core/runtime/session'
10+
export * from '@memo/core/runtime/suggestions'

‎packages/core/src/runtime/defaults.ts‎

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export async function withDefaultDeps(
3737
tokenCounter: TokenCounter
3838
maxSteps: number
3939
dispose: () => Promise<void>
40+
historyFilePath?: string
4041
}> {
4142
const loaded = await loadMemoConfig()
4243
const config = loaded.config
@@ -83,6 +84,10 @@ export async function withDefaultDeps(
8384
return basePrompt
8485
}
8586
const streamOutput = options.stream ?? config.stream_output ?? false
87+
const sessionsDir = getSessionsDir(loaded, options)
88+
const historyFilePath = buildSessionPath(sessionsDir, sessionId)
89+
const defaultHistorySink = new JsonlHistorySink(historyFilePath)
90+
8691
return {
8792
tools: combinedTools,
8893
dispose: async () => {
@@ -91,7 +96,7 @@ export async function withDefaultDeps(
9196
},
9297
callLLM:
9398
deps.callLLM ??
94-
(async (messages, onChunk) => {
99+
(async (messages, onChunk, callOptions) => {
95100
const provider = selectProvider(config, options.providerName)
96101
const apiKey =
97102
process.env[provider.env_api_key] ??
@@ -107,12 +112,15 @@ export async function withDefaultDeps(
107112
baseURL: provider.base_url,
108113
})
109114
if (streamOutput) {
110-
const stream = await client.chat.completions.create({
111-
model: provider.model,
112-
messages,
113-
temperature: 0.35,
114-
stream: true,
115-
})
115+
const stream = await client.chat.completions.create(
116+
{
117+
model: provider.model,
118+
messages,
119+
temperature: 0.35,
120+
stream: true,
121+
},
122+
{ signal: callOptions?.signal },
123+
)
116124
let content = ''
117125
for await (const part of stream) {
118126
const delta = part.choices?.[0]?.delta?.content
@@ -123,11 +131,14 @@ export async function withDefaultDeps(
123131
}
124132
return { content, streamed: true }
125133
} else {
126-
const data = await client.chat.completions.create({
127-
model: provider.model,
128-
messages,
129-
temperature: 0.35,
130-
})
134+
const data = await client.chat.completions.create(
135+
{
136+
model: provider.model,
137+
messages,
138+
temperature: 0.35,
139+
},
140+
{ signal: callOptions?.signal },
141+
)
131142
const content = data.choices?.[0]?.message?.content
132143
if (typeof content !== 'string') {
133144
throw new Error('OpenAI-compatible API returned empty content')
@@ -143,10 +154,9 @@ export async function withDefaultDeps(
143154
}
144155
}),
145156
loadPrompt,
146-
historySinks: deps.historySinks ?? [
147-
new JsonlHistorySink(buildSessionPath(getSessionsDir(loaded, options), sessionId)),
148-
],
157+
historySinks: deps.historySinks ?? [defaultHistorySink],
149158
tokenCounter: deps.tokenCounter ?? createTokenCounter(options.tokenizerModel),
150159
maxSteps: options.maxSteps ?? config.max_steps ?? 100,
160+
historyFilePath: historyFilePath,
151161
}
152162
}

‎packages/core/src/runtime/prompt.md‎

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
你是 **MemoAgent**,由 mCell 设计开发,并在 BunJS Runtime 中运行的高级编码代理。你的目标是精确、安全且有帮助地解决用户的编码任务。
44

5-
你的核心工作方式是 **ReAct 循环**。
6-
75
# 核心特质 (Personality)
86

97
- **简洁明了**:沟通高效,避免废话。
@@ -17,6 +15,7 @@
1715
- **普通回复**:直接输出文本。
1816
- **使用工具**:当需要执行操作时,只输出一个 **Markdown JSON 代码块**,不要输出任何额外文字。
1917
- **最终回答**:当任务完成或需要回复用户时,直接输出您的回答(支持 Markdown),**不要**再调用工具。
18+
- **硬性格式约束**:调用工具的消息必须是“只含 JSON 代码块、无其它内容”的单独消息;禁止在同一条消息里出现 JSON 工具块与任何文本、提示、思考或解释(包括前后空行/注释/Markdown)。
2019

2120
## 工具调用示例
2221

@@ -108,11 +107,3 @@
108107
- Add: `{"type": "add", "todos": [{"content": "string", "status": "pending"}]}`
109108
- Update: `{"type": "update", "todos": [{"id": "string", "content": "string", "status": "completed"}]}`
110109
- Remove: `{"type": "remove", "ids": ["string"]}`
111-
112-
# 启动确认
113-
114-
现在,等待用户输入。一旦收到任务:
115-
116-
1. 分析需求。
117-
2. (如果任务复杂) 使用 `todo` 建立计划。
118-
3. 开始工作。

0 commit comments

Comments
 (0)