chcode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
chcode/session.py ADDED
@@ -0,0 +1,149 @@
1
+ """
2
+ 会话管理 — thread_id, checkpointer DB, 历史会话列表/加载/删除/重命名
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import json
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from rich.console import Console
14
+
15
+ if TYPE_CHECKING:
16
+ from langgraph.graph.state import CompiledStateGraph
17
+ from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
18
+
19
+ from langchain_core.messages import HumanMessage
20
+
21
+ console = Console()
22
+
23
+ _SUMMARY_MAX_LEN = 40
24
+
25
+
26
+ class SessionManager:
27
+ def __init__(self, workplace_path: Path):
28
+ self.workplace_path = workplace_path
29
+ self.sessions_dir = workplace_path / ".chat" / "sessions"
30
+ self.sessions_dir.mkdir(parents=True, exist_ok=True)
31
+ self.thread_id = self._new_thread_id()
32
+ self._names_path = self.sessions_dir / "names.json"
33
+
34
+ # ─── names.json 读写 ─────────────────────────────────
35
+
36
+ def _load_names(self) -> dict[str, str]:
37
+ if self._names_path.exists():
38
+ try:
39
+ return json.loads(self._names_path.read_text("utf-8"))
40
+ except Exception:
41
+ return {}
42
+ return {}
43
+
44
+ def _save_names(self, names: dict[str, str]) -> None:
45
+ self._names_path.write_text(
46
+ json.dumps(names, ensure_ascii=False, indent=2), "utf-8"
47
+ )
48
+
49
+ # ─── 基础 ────────────────────────────────────────────
50
+
51
+ def _new_thread_id(self) -> str:
52
+ return f"thread_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}"
53
+
54
+ @property
55
+ def config(self) -> dict:
56
+ return {"configurable": {"thread_id": self.thread_id}}
57
+
58
+ def new_session(self) -> None:
59
+ self.thread_id = self._new_thread_id()
60
+
61
+ def set_thread(self, thread_id: str) -> None:
62
+ self.thread_id = thread_id
63
+
64
+ # ─── 重命名 ──────────────────────────────────────────
65
+
66
+ def rename_session(self, thread_id: str, new_name: str) -> None:
67
+ names = self._load_names()
68
+ if new_name:
69
+ names[thread_id] = new_name
70
+ else:
71
+ names.pop(thread_id, None)
72
+ self._save_names(names)
73
+
74
+ # ─── 显示名 ──────────────────────────────────────────
75
+
76
+ async def _get_summary(
77
+ self, agent: CompiledStateGraph, thread_id: str
78
+ ) -> str | None:
79
+ cfg = {"configurable": {"thread_id": thread_id}}
80
+ try:
81
+ from chcode.utils import get_text_content
82
+ state = await agent.aget_state(cfg)
83
+ messages = state.values.get("messages", [])
84
+ for msg in messages:
85
+ if isinstance(msg, HumanMessage):
86
+ if not isinstance(msg.content, (str, list)):
87
+ continue
88
+ text = get_text_content(msg.content)
89
+ text = text.strip().replace("\n", " ")
90
+ if text:
91
+ return text[:_SUMMARY_MAX_LEN] + (
92
+ "…" if len(text) > _SUMMARY_MAX_LEN else ""
93
+ )
94
+ except Exception:
95
+ pass
96
+ return None
97
+
98
+ async def get_display_names(
99
+ self,
100
+ thread_ids: list[str],
101
+ agent: CompiledStateGraph,
102
+ ) -> dict[str, str]:
103
+ names = self._load_names()
104
+ result: dict[str, str] = {}
105
+ need_summary: list[str] = []
106
+
107
+ for tid in thread_ids:
108
+ if tid in names:
109
+ result[tid] = names[tid]
110
+ else:
111
+ need_summary.append(tid)
112
+
113
+ if need_summary:
114
+ summaries = await asyncio.gather(
115
+ *[self._get_summary(agent, tid) for tid in need_summary]
116
+ )
117
+ for tid, summary in zip(need_summary, summaries):
118
+ result[tid] = summary or tid
119
+
120
+ return result
121
+
122
+ # ─── 列表 / 删除 ─────────────────────────────────────
123
+
124
+ async def list_sessions(self, checkpointer: AsyncSqliteSaver) -> list[str]:
125
+ """从 checkpointer 获取所有历史 thread_id"""
126
+ try:
127
+ await checkpointer.setup()
128
+ async with checkpointer.lock:
129
+ rows = await checkpointer.conn.execute_fetchall(
130
+ "SELECT DISTINCT thread_id FROM checkpoints"
131
+ )
132
+ return [row[0] for row in rows if row[0]]
133
+ except Exception:
134
+ return []
135
+
136
+ async def delete_session(
137
+ self, thread_id: str, checkpointer: AsyncSqliteSaver
138
+ ) -> bool:
139
+ """删除指定会话的所有数据"""
140
+ try:
141
+ await checkpointer.adelete_thread(thread_id)
142
+ names = self._load_names()
143
+ if thread_id in names:
144
+ del names[thread_id]
145
+ self._save_names(names)
146
+ return True
147
+ except Exception as e:
148
+ console.print(f"[red]删除会话失败: {e}[/red]")
149
+ return False
@@ -0,0 +1,165 @@
1
+ """
2
+ 技能管理 — 扫描/列表/查看详情/安装/删除,全部用下拉列表交互
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.markdown import Markdown
13
+ from rich.table import Table
14
+
15
+ from chcode.prompts import select, confirm, text
16
+ from chcode.utils.skill_loader import (
17
+ scan_all_skills,
18
+ validate_skill_package,
19
+ install_skill,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from chcode.session import SessionManager
24
+
25
+ console = Console()
26
+
27
+
28
+ async def manage_skills(session: SessionManager) -> None:
29
+ """技能管理主菜单"""
30
+ while True:
31
+ action = await select(
32
+ "技能管理:",
33
+ ["查看已安装技能", "安装新技能", "返回"],
34
+ )
35
+ if action is None or action == "返回":
36
+ return
37
+
38
+ if action == "查看已安装技能":
39
+ await _list_skills(session)
40
+ elif action == "安装新技能":
41
+ await _install_skill(session)
42
+
43
+
44
+ async def _list_skills(session: SessionManager) -> None:
45
+ """列出所有已安装技能,支持下拉选择操作"""
46
+ skills = scan_all_skills(session.workplace_path)
47
+ if not skills:
48
+ console.print("[yellow]没有发现已安装的技能[/yellow]")
49
+ return
50
+
51
+ # 构建表格
52
+ table = Table(title="已安装技能")
53
+ table.add_column("名称", style="cyan")
54
+ table.add_column("类型", style="green")
55
+ table.add_column("描述", style="white")
56
+ table.add_column("路径", style="dim")
57
+ for s in skills:
58
+ desc = s["description"]
59
+ if len(desc) > 60:
60
+ desc = desc[:57] + "..."
61
+ table.add_row(s["name"], s["type"], desc, str(s["path"]))
62
+ console.print(table)
63
+
64
+ # 选择操作
65
+ names = [f"{s['name']} ({s['type']})" for s in skills]
66
+ action = await select(
67
+ "选择技能进行操作:",
68
+ names + ["返回"],
69
+ )
70
+ if action is None or action == "返回":
71
+ return
72
+
73
+ # 找到选中的技能
74
+ selected_name = action.split(" (")[0]
75
+ skill = next((s for s in skills if s["name"] == selected_name), None)
76
+ if not skill:
77
+ return
78
+
79
+ op = await select(
80
+ f"对技能 '{skill['name']}' 的操作:",
81
+ ["查看详情", "删除技能", "返回"],
82
+ )
83
+ if op == "查看详情":
84
+ await _show_skill_detail(skill)
85
+ elif op == "删除技能":
86
+ await _delete_skill(skill, session)
87
+ elif op == "返回":
88
+ return
89
+
90
+
91
+ async def _show_skill_detail(skill: dict) -> None:
92
+ """查看技能详情"""
93
+ skill_md = Path(skill["path"]) / "SKILL.md"
94
+ if not skill_md.exists():
95
+ console.print("[red]技能文件不存在[/red]")
96
+ return
97
+
98
+ content = skill_md.read_text(encoding="utf-8")
99
+ console.print(
100
+ Panel(
101
+ Markdown(content),
102
+ title=f"技能: {skill['name']}",
103
+ border_style="cyan",
104
+ padding=(1, 2),
105
+ )
106
+ )
107
+
108
+
109
+ async def _delete_skill(skill: dict, session: SessionManager) -> None:
110
+ """删除技能"""
111
+ ok = await confirm(
112
+ f"确定删除技能 '{skill['name']}'?此操作不可撤销!", default=False
113
+ )
114
+ if not ok:
115
+ return
116
+
117
+ import shutil
118
+
119
+ skill_path = Path(skill["path"])
120
+ try:
121
+ shutil.rmtree(skill_path)
122
+ console.print(f"[green]技能 '{skill['name']}' 已删除[/green]")
123
+ except Exception as e:
124
+ console.print(f"[red]删除失败: {e}[/red]")
125
+
126
+
127
+ async def _install_skill(session: SessionManager) -> None:
128
+ """安装技能"""
129
+ file_path = await text("输入技能压缩包路径 (.zip/.tar.gz/.tgz):")
130
+ if not file_path:
131
+ return
132
+
133
+ path = Path(file_path)
134
+ if not path.exists():
135
+ console.print("[red]文件不存在[/red]")
136
+ return
137
+
138
+ # 验证
139
+ console.print("[yellow]验证技能包...[/yellow]")
140
+ skill_info = validate_skill_package(str(path))
141
+ if not skill_info:
142
+ console.print("[red]无效的技能包,必须包含 SKILL.md[/red]")
143
+ return
144
+
145
+ # 选择安装位置
146
+ location = await select(
147
+ "选择安装位置:",
148
+ ["项目级 (当前工作目录)", "全局级 (用户目录)"],
149
+ )
150
+ if location is None:
151
+ return
152
+
153
+ if "项目级" in location:
154
+ install_path = session.workplace_path / ".chat" / "skills"
155
+ else:
156
+ install_path = Path.home() / ".chat" / "skills"
157
+
158
+ install_path.mkdir(parents=True, exist_ok=True)
159
+
160
+ console.print("[yellow]安装中...[/yellow]")
161
+ if install_skill(str(path), install_path):
162
+ name = skill_info["name"]
163
+ console.print(f"[green]技能 '{name}' 安装成功![/green]")
164
+ else:
165
+ console.print("[red]安装失败[/red]")
@@ -0,0 +1,3 @@
1
+ from chcode.utils.text_utils import get_text_content
2
+
3
+ __all__ = ["get_text_content"]
@@ -0,0 +1,368 @@
1
+ """
2
+ Enhanced ChatOpenAI with support for third-party model reasoning content.
3
+
4
+ This module extends langchain_openai's ChatOpenAI to support reasoning/thinking
5
+ content from third-party models like Qwen, GLM, DeepSeek, etc.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, ClassVar
11
+
12
+ from langchain_core.messages import AIMessage
13
+ from langchain_core.outputs import ChatResult
14
+ from langchain_openai import ChatOpenAI
15
+
16
+
17
+ class EnhancedChatOpenAI(ChatOpenAI):
18
+ """Enhanced ChatOpenAI with third-party model reasoning support.
19
+
20
+ This class extends ChatOpenAI to support reasoning/thinking content
21
+ from third-party models (Qwen, GLM, DeepSeek, etc.) that use different
22
+ field names for reasoning output.
23
+
24
+ Supported reasoning fields:
25
+ - reasoning_content (Qwen)
26
+ - thinking (Generic)
27
+ - reasoning (DeepSeek)
28
+ - thought (GLM)
29
+ - thought_process (Custom)
30
+
31
+ Example:
32
+ ```python
33
+ from enhanced_chat_openai import EnhancedChatOpenAI
34
+
35
+ # For Qwen models
36
+ model = EnhancedChatOpenAI(
37
+ model="qwen-plus",
38
+ base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
39
+ api_key="your-api-key",
40
+ reasoning_field="reasoning_content" # Qwen uses this field
41
+ )
42
+
43
+ response = model.invoke("What is 2+2?")
44
+
45
+ # Access reasoning content
46
+ reasoning = response.additional_kwargs.get("reasoning")
47
+ print(f"Reasoning: {reasoning}")
48
+
49
+ Note on streaming:
50
+ When using streaming mode, the reasoning content is accumulated across
51
+ all chunks and stored in the final message's additional_kwargs.
52
+ You can access it after the stream completes:
53
+
54
+ ```python
55
+ reasoning_parts = []
56
+ for chunk in model.stream(messages):
57
+ # Access reasoning from each chunk if needed
58
+ if "reasoning" in chunk.additional_kwargs:
59
+ reasoning_parts.append(chunk.additional_kwargs["reasoning"])
60
+
61
+ full_reasoning = "".join(reasoning_parts)
62
+ ```
63
+ print(f"Answer: {response.content}")
64
+ ```
65
+
66
+ Args:
67
+ reasoning_field: Field name for reasoning content in API response.
68
+ Options: "reasoning_content", "thinking", "reasoning", "thought",
69
+ "thought_process", or "auto" (auto-detect, default)
70
+ include_reasoning_in_content: Whether to prepend reasoning to content.
71
+ Default: False (reasoning stored only in additional_kwargs)
72
+ reasoning_separator: Separator between reasoning and content when included.
73
+ Default: "\n\n---\n\n"
74
+ """
75
+
76
+ reasoning_field: str = "auto"
77
+ include_reasoning_in_content: bool = False
78
+ reasoning_separator: str = "\n\n---\n\n"
79
+
80
+ # Known reasoning field mappings for different providers
81
+ REASONING_FIELDS: ClassVar[list[str]] = [
82
+ "reasoning_content", # Qwen / Alibaba
83
+ "thinking", # Generic / Anthropic-style
84
+ "reasoning", # DeepSeek / OpenAI o1
85
+ "thought", # GLM / Zhipu
86
+ "thought_process", # Custom
87
+ "reasoning_text", # Alternative
88
+ "thought_content", # Alternative
89
+ ]
90
+
91
+ def _extract_reasoning(self, data: dict[str, Any]) -> str | None:
92
+ """Extract reasoning content from API response data.
93
+
94
+ Args:
95
+ data: Message dictionary from API response
96
+
97
+ Returns:
98
+ Reasoning content string or None if not found
99
+ """
100
+ # If specific field configured, try that first
101
+ if self.reasoning_field != "auto":
102
+ if self.reasoning_field in data:
103
+ return data[self.reasoning_field]
104
+ return None
105
+
106
+ # Auto-detect: try all known fields
107
+ for field in self.REASONING_FIELDS:
108
+ if field in data and data[field]:
109
+ return data[field]
110
+
111
+ return None
112
+
113
+ def _process_message_with_reasoning(
114
+ self, message: dict[str, Any]
115
+ ) -> dict[str, Any]:
116
+ """Process message to extract and format reasoning content.
117
+
118
+ Args:
119
+ message: Raw message dict from API
120
+
121
+ Returns:
122
+ Processed message dict
123
+ """
124
+ # Extract reasoning
125
+ reasoning = self._extract_reasoning(message)
126
+
127
+ if reasoning:
128
+ # Store in additional_kwargs
129
+ if "additional_kwargs" not in message:
130
+ message["additional_kwargs"] = {}
131
+ message["additional_kwargs"]["reasoning"] = reasoning
132
+
133
+ # Optionally include in content
134
+ if self.include_reasoning_in_content and message.get("content"):
135
+ message["content"] = (
136
+ f"{reasoning}{self.reasoning_separator}{message['content']}"
137
+ )
138
+
139
+ # Remove original reasoning field from message
140
+ for field in self.REASONING_FIELDS:
141
+ if field in message:
142
+ del message[field]
143
+
144
+ return message
145
+
146
+ def _extract_reasoning_from_message(self, message: Any) -> str | None:
147
+ """Extract reasoning content from message object.
148
+
149
+ Checks multiple sources:
150
+ 1. reasoning_content field (Qwen style)
151
+ 2. content_blocks with type='thinking'
152
+ 3. reasoning field (DeepSeek style)
153
+ """
154
+ if message is None:
155
+ return None
156
+
157
+ reasoning_parts = []
158
+
159
+ # Method 1: Direct reasoning_content field
160
+ if hasattr(message, "reasoning_content") and message.reasoning_content:
161
+ if isinstance(message.reasoning_content, str):
162
+ reasoning_parts.append(message.reasoning_content)
163
+
164
+ # Method 2: content_blocks with thinking type
165
+ if hasattr(message, "content_blocks") and message.content_blocks:
166
+ for block in message.content_blocks:
167
+ # Handle both object and dict formats
168
+ block_type = None
169
+ thinking_content = None
170
+
171
+ if hasattr(block, "type"):
172
+ block_type = block.type
173
+ elif isinstance(block, dict):
174
+ block_type = block.get("type")
175
+
176
+ if block_type == "thinking":
177
+ if hasattr(block, "thinking"):
178
+ thinking_content = block.thinking
179
+ elif isinstance(block, dict):
180
+ thinking_content = block.get("thinking")
181
+
182
+ if thinking_content and isinstance(thinking_content, str):
183
+ reasoning_parts.append(thinking_content)
184
+
185
+ # Method 3: Check model_dump/dict for reasoning_content
186
+ if not reasoning_parts:
187
+ try:
188
+ if hasattr(message, "model_dump"):
189
+ data = message.model_dump()
190
+ if data.get("reasoning_content"):
191
+ reasoning_parts.append(data["reasoning_content"])
192
+ elif hasattr(message, "dict"):
193
+ data = message.dict()
194
+ if data.get("reasoning_content"):
195
+ reasoning_parts.append(data["reasoning_content"])
196
+ except Exception:
197
+ pass
198
+
199
+ # Combine all reasoning parts
200
+ if reasoning_parts:
201
+ return "\n".join(reasoning_parts)
202
+
203
+ return None
204
+
205
+ def _create_chat_result(
206
+ self, response: Any, generation_info: dict[str, Any] | None = None
207
+ ) -> ChatResult:
208
+ """Override to process reasoning content in response."""
209
+ # Extract reasoning from raw response before parent processing
210
+ reasoning_content = None
211
+ if hasattr(response, "choices") and response.choices:
212
+ try:
213
+ message = response.choices[0].message
214
+ reasoning_content = self._extract_reasoning_from_message(message)
215
+ except (AttributeError, IndexError):
216
+ pass
217
+
218
+ # Get result from parent
219
+ result = super()._create_chat_result(response, generation_info)
220
+
221
+ # Add reasoning to the message's additional_kwargs and content_blocks
222
+ if result.generations:
223
+ for generation in result.generations:
224
+ if isinstance(generation.message, AIMessage):
225
+ # Add to additional_kwargs
226
+ if reasoning_content:
227
+ if "reasoning" not in generation.message.additional_kwargs:
228
+ generation.message.additional_kwargs["reasoning"] = (
229
+ reasoning_content
230
+ )
231
+
232
+ # Add to content_blocks if reasoning exists
233
+ if reasoning_content:
234
+ content_blocks = []
235
+
236
+ # Add thinking block
237
+ thinking_block = {
238
+ "type": "thinking",
239
+ "thinking": reasoning_content,
240
+ }
241
+ content_blocks.append(thinking_block)
242
+
243
+ # Add text block with actual content
244
+ if generation.message.content:
245
+ text_block = {
246
+ "type": "text",
247
+ "text": generation.message.content,
248
+ }
249
+ content_blocks.append(text_block)
250
+
251
+ # Store content_blocks in additional_kwargs
252
+ generation.message.additional_kwargs["content_blocks"] = (
253
+ content_blocks
254
+ )
255
+
256
+ break
257
+
258
+ return result
259
+
260
+ def _make_status_error_from_response(
261
+ self,
262
+ response: Any,
263
+ message: str,
264
+ *,
265
+ body: Any = None,
266
+ ) -> Exception:
267
+ """Override to handle reasoning in error responses."""
268
+ # Some providers include reasoning even in errors
269
+ if body and isinstance(body, dict):
270
+ if "choices" in body and body["choices"]:
271
+ for choice in body["choices"]:
272
+ if "message" in choice:
273
+ choice["message"] = self._process_message_with_reasoning(
274
+ choice["message"]
275
+ )
276
+
277
+ return super()._make_status_error_from_response(response, message, body=body)
278
+
279
+ def _convert_dict_to_message(self, _dict: dict[str, Any]) -> Any:
280
+ """Override to extract reasoning before conversion."""
281
+ # Process reasoning first
282
+ _dict = self._process_message_with_reasoning(_dict)
283
+
284
+ # Convert using parent method
285
+ return super()._convert_dict_to_message(_dict)
286
+
287
+ def _convert_chunk_to_generation_chunk(
288
+ self,
289
+ chunk: dict,
290
+ default_chunk_class: type,
291
+ base_generation_info: dict | None,
292
+ ):
293
+ """Override to extract reasoning_content from streaming chunks.
294
+
295
+ This is the correct method to override for streaming support.
296
+ The chunk here is already a dict from model_dump().
297
+ """
298
+ # First, let parent process the chunk
299
+ generation_chunk = super()._convert_chunk_to_generation_chunk(
300
+ chunk, default_chunk_class, base_generation_info
301
+ )
302
+
303
+ # Now extract reasoning_content if present
304
+ if generation_chunk is not None:
305
+ choices = chunk.get("choices", [])
306
+ if choices:
307
+ delta = choices[0].get("delta", {})
308
+ if delta and isinstance(delta, dict):
309
+ reasoning_content = delta.get("reasoning_content")
310
+ content = delta.get("content")
311
+
312
+ # Ensure additional_kwargs exists
313
+ if not hasattr(generation_chunk.message, "additional_kwargs"):
314
+ generation_chunk.message.additional_kwargs = {}
315
+ if generation_chunk.message.additional_kwargs is None:
316
+ generation_chunk.message.additional_kwargs = {}
317
+
318
+ # Accumulate reasoning
319
+ if reasoning_content and isinstance(reasoning_content, str):
320
+ if (
321
+ "reasoning"
322
+ not in generation_chunk.message.additional_kwargs
323
+ ):
324
+ generation_chunk.message.additional_kwargs["reasoning"] = ""
325
+ generation_chunk.message.additional_kwargs["reasoning"] += (
326
+ reasoning_content
327
+ )
328
+
329
+ # Build content_blocks for this chunk
330
+ content_blocks = []
331
+
332
+ # Add thinking block if we have reasoning in this chunk
333
+ if reasoning_content and isinstance(reasoning_content, str):
334
+ content_blocks.append(
335
+ {
336
+ "type": "thinking",
337
+ "thinking": reasoning_content,
338
+ }
339
+ )
340
+
341
+ # Add text block if we have content in this chunk
342
+ if content and isinstance(content, str):
343
+ content_blocks.append(
344
+ {
345
+ "type": "text",
346
+ "text": content,
347
+ }
348
+ )
349
+
350
+ # Store content_blocks if we have any
351
+ if content_blocks:
352
+ if (
353
+ "content_blocks"
354
+ not in generation_chunk.message.additional_kwargs
355
+ ):
356
+ generation_chunk.message.additional_kwargs[
357
+ "content_blocks"
358
+ ] = []
359
+ generation_chunk.message.additional_kwargs[
360
+ "content_blocks"
361
+ ].extend(content_blocks)
362
+
363
+ return generation_chunk
364
+
365
+
366
+ __all__ = [
367
+ "EnhancedChatOpenAI",
368
+ ]