thatgfsj-code 0.3.0 → 0.4.0

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.
Files changed (43) hide show
  1. package/dist/cmd/index.js +1 -1
  2. package/dist/config/index.d.ts +5 -1
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/index.js +22 -4
  5. package/dist/config/index.js.map +1 -1
  6. package/dist/config/providers.d.ts +5 -10
  7. package/dist/config/providers.d.ts.map +1 -1
  8. package/dist/config/providers.js +152 -78
  9. package/dist/config/providers.js.map +1 -1
  10. package/dist/config/types.d.ts +1 -1
  11. package/dist/config/types.d.ts.map +1 -1
  12. package/dist/llm/anthropic.d.ts +34 -3
  13. package/dist/llm/anthropic.d.ts.map +1 -1
  14. package/dist/llm/anthropic.js +83 -35
  15. package/dist/llm/anthropic.js.map +1 -1
  16. package/dist/llm/gemini.d.ts +14 -1
  17. package/dist/llm/gemini.d.ts.map +1 -1
  18. package/dist/llm/gemini.js +56 -23
  19. package/dist/llm/gemini.js.map +1 -1
  20. package/dist/llm/index.d.ts +9 -8
  21. package/dist/llm/index.d.ts.map +1 -1
  22. package/dist/llm/index.js +75 -80
  23. package/dist/llm/index.js.map +1 -1
  24. package/dist/llm/openai.d.ts +30 -3
  25. package/dist/llm/openai.d.ts.map +1 -1
  26. package/dist/llm/openai.js +45 -28
  27. package/dist/llm/openai.js.map +1 -1
  28. package/dist/tui/output.js +1 -1
  29. package/dist/tui/welcome.d.ts +5 -1
  30. package/dist/tui/welcome.d.ts.map +1 -1
  31. package/dist/tui/welcome.js +75 -29
  32. package/dist/tui/welcome.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/cmd/index.ts +1 -1
  35. package/src/config/index.ts +21 -4
  36. package/src/config/providers.ts +153 -79
  37. package/src/config/types.ts +11 -5
  38. package/src/llm/anthropic.ts +90 -39
  39. package/src/llm/gemini.ts +65 -30
  40. package/src/llm/index.ts +77 -87
  41. package/src/llm/openai.ts +46 -34
  42. package/src/tui/output.ts +1 -1
  43. package/src/tui/welcome.ts +86 -32
package/src/llm/gemini.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  /**
2
2
  * Google Gemini Provider
3
+ *
4
+ * API: POST {baseUrl}/models/{model}:streamGenerateContent?key={apiKey}
5
+ * Auth: API key in URL query parameter
6
+ * Streaming: JSON array chunks (not SSE)
7
+ *
8
+ * Key differences:
9
+ * - No "system" role in contents, use "systemInstruction" field
10
+ * - Roles are "user" and "model" (not "assistant")
11
+ * - Content is array of "parts" with "text" field
12
+ * - Auth via URL query param, not header
3
13
  */
4
14
 
5
15
  import type { ChatMessage, ChatResponse, ChatOptions } from '../types.js';
@@ -8,30 +18,32 @@ import type { LLMProvider, ProviderConfig } from './provider.js';
8
18
 
9
19
  export class GeminiProvider implements LLMProvider {
10
20
  readonly name = 'gemini';
11
- private config: ProviderConfig;
21
+ protected config: ProviderConfig;
12
22
 
13
23
  constructor(config: ProviderConfig) {
14
24
  this.config = config;
15
25
  }
16
26
 
17
27
  buildTools(tools: Tool[]): any[] {
18
- return tools.map(tool => ({
19
- name: tool.name,
20
- description: tool.description,
21
- parameters: tool.inputSchema || {
22
- type: 'object',
23
- properties: Object.fromEntries(
24
- tool.parameters.map(p => [p.name, { type: p.type, description: p.description }])
25
- ),
26
- required: tool.parameters.filter(p => p.required).map(p => p.name),
27
- },
28
- }));
28
+ return [{
29
+ functionDeclarations: tools.map(tool => ({
30
+ name: tool.name,
31
+ description: tool.description,
32
+ parameters: tool.inputSchema || {
33
+ type: 'object',
34
+ properties: Object.fromEntries(
35
+ tool.parameters.map(p => [p.name, { type: p.type, description: p.description }])
36
+ ),
37
+ required: tool.parameters.filter(p => p.required).map(p => p.name),
38
+ },
39
+ })),
40
+ }];
29
41
  }
30
42
 
31
43
  async chat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResponse> {
32
44
  const body = this.buildRequest(messages, options);
33
-
34
45
  const url = `${this.config.baseUrl}/models/${this.config.model}:generateContent?key=${this.config.apiKey}`;
46
+
35
47
  const response = await fetch(url, {
36
48
  method: 'POST',
37
49
  headers: { 'Content-Type': 'application/json' },
@@ -45,7 +57,18 @@ export class GeminiProvider implements LLMProvider {
45
57
 
46
58
  const data = await response.json();
47
59
  const candidate = data.candidates?.[0];
48
- const text = candidate?.content?.parts?.map((p: any) => p.text).join('') || '';
60
+ const parts = candidate?.content?.parts || [];
61
+ const text = parts.map((p: any) => p.text).filter(Boolean).join('');
62
+
63
+ // Extract function calls
64
+ const functionCalls = parts.filter((p: any) => p.functionCall).map((p: any) => ({
65
+ id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
66
+ type: 'function' as const,
67
+ function: {
68
+ name: p.functionCall.name,
69
+ arguments: JSON.stringify(p.functionCall.args || {}),
70
+ },
71
+ }));
49
72
 
50
73
  return {
51
74
  content: text,
@@ -55,13 +78,14 @@ export class GeminiProvider implements LLMProvider {
55
78
  completion_tokens: data.usageMetadata.candidatesTokenCount || 0,
56
79
  total_tokens: data.usageMetadata.totalTokenCount || 0,
57
80
  } : undefined,
81
+ tool_calls: functionCalls.length > 0 ? functionCalls : undefined,
58
82
  };
59
83
  }
60
84
 
61
85
  async *chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncGenerator<string, ChatResponse> {
62
86
  const body = this.buildRequest(messages, options);
87
+ const url = `${this.config.baseUrl}/models/${this.config.model}:streamGenerateContent?alt=sse&key=${this.config.apiKey}`;
63
88
 
64
- const url = `${this.config.baseUrl}/models/${this.config.model}:streamGenerateContent?key=${this.config.apiKey}`;
65
89
  const response = await fetch(url, {
66
90
  method: 'POST',
67
91
  headers: { 'Content-Type': 'application/json' },
@@ -84,21 +108,25 @@ export class GeminiProvider implements LLMProvider {
84
108
  if (done) break;
85
109
 
86
110
  buffer += decoder.decode(value, { stream: true });
111
+ const lines = buffer.split('\n');
112
+ buffer = lines.pop() || '';
113
+
114
+ for (const line of lines) {
115
+ const trimmed = line.trim();
116
+ if (!trimmed || !trimmed.startsWith('data: ')) continue;
87
117
 
88
- // Gemini streams JSON array chunks
89
- const jsonMatch = buffer.match(/\{"candidates":\[.*?\]\}/);
90
- if (jsonMatch) {
91
118
  try {
92
- const data = JSON.parse(jsonMatch[0]);
93
- const text = data.candidates?.[0]?.content?.parts?.map((p: any) => p.text).join('') || '';
94
- if (text) {
95
- fullContent += text;
96
- yield text;
119
+ const data = JSON.parse(trimmed.slice(6));
120
+ const parts = data.candidates?.[0]?.content?.parts || [];
121
+ for (const part of parts) {
122
+ if (part.text) {
123
+ fullContent += part.text;
124
+ yield part.text;
125
+ }
97
126
  }
98
127
  } catch {
99
128
  // Skip invalid JSON
100
129
  }
101
- buffer = buffer.slice(jsonMatch.index! + jsonMatch[0].length);
102
130
  }
103
131
  }
104
132
  } finally {
@@ -108,16 +136,23 @@ export class GeminiProvider implements LLMProvider {
108
136
  return { content: fullContent, role: 'assistant' };
109
137
  }
110
138
 
139
+ /**
140
+ * Build request body for Gemini API
141
+ */
111
142
  private buildRequest(messages: ChatMessage[], options?: ChatOptions) {
112
- const contents = messages
113
- .filter(m => m.role !== 'system')
114
- .map(m => ({
115
- role: m.role === 'assistant' ? 'model' : 'user',
116
- parts: [{ text: m.content }],
117
- }));
143
+ const systemMsg = messages.find(m => m.role === 'system');
144
+ const nonSystemMsgs = messages.filter(m => m.role !== 'system');
145
+
146
+ const contents = nonSystemMsgs.map(m => ({
147
+ role: m.role === 'assistant' ? 'model' : 'user',
148
+ parts: [{ text: m.content }],
149
+ }));
118
150
 
119
151
  return {
120
152
  contents,
153
+ ...(systemMsg && {
154
+ systemInstruction: { parts: [{ text: systemMsg.content }] },
155
+ }),
121
156
  generationConfig: {
122
157
  temperature: options?.temperature ?? this.config.temperature,
123
158
  maxOutputTokens: options?.maxTokens ?? this.config.maxTokens,
package/src/llm/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * LLM Service - Factory for creating providers
3
- * Replaces the monolithic AIEngine with a clean provider abstraction
3
+ * Supports all providers + custom relay stations (中转站)
4
4
  */
5
5
 
6
6
  import chalk from 'chalk';
@@ -16,9 +16,11 @@ import { GeminiProvider } from './gemini.js';
16
16
  export class LLMService {
17
17
  private provider: LLMProvider;
18
18
  private tools: Map<string, Tool> = new Map();
19
+ private apiKey: string;
19
20
 
20
- constructor(provider: LLMProvider) {
21
+ constructor(provider: LLMProvider, apiKey: string) {
21
22
  this.provider = provider;
23
+ this.apiKey = apiKey;
22
24
  }
23
25
 
24
26
  /**
@@ -50,7 +52,7 @@ export class LLMService {
50
52
  provider = new OpenAIProvider(providerCfg);
51
53
  }
52
54
 
53
- return new LLMService(provider);
55
+ return new LLMService(provider, providerCfg.apiKey);
54
56
  }
55
57
 
56
58
  /**
@@ -69,12 +71,19 @@ export class LLMService {
69
71
  return this.provider.name;
70
72
  }
71
73
 
74
+ /**
75
+ * Check if API key is available
76
+ */
77
+ hasApiKey(): boolean {
78
+ return !!this.apiKey;
79
+ }
80
+
72
81
  /**
73
82
  * Non-streaming chat
74
83
  */
75
84
  async chat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResponse> {
76
85
  if (!this.hasApiKey()) {
77
- return this.getMockResponse(messages);
86
+ throw new Error(this.getNoKeyMessage());
78
87
  }
79
88
  return this.provider.chat(messages, options);
80
89
  }
@@ -87,12 +96,7 @@ export class LLMService {
87
96
  options?: ChatOptions & { maxIterations?: number }
88
97
  ): AsyncGenerator<string, ChatResponse> {
89
98
  if (!this.hasApiKey()) {
90
- const mock = this.getMockResponse(messages);
91
- for (const char of mock.content) {
92
- await new Promise(r => setTimeout(r, 10));
93
- yield char;
94
- }
95
- return mock;
99
+ throw new Error(this.getNoKeyMessage());
96
100
  }
97
101
 
98
102
  const maxIterations = options?.maxIterations ?? 10;
@@ -102,103 +106,89 @@ export class LLMService {
102
106
  while (iterations < maxIterations) {
103
107
  iterations++;
104
108
 
105
- // Get tools in provider format
106
- const toolsArray = [...this.tools.values()];
107
- const providerTools = toolsArray.length > 0 ? this.provider.buildTools(toolsArray) : [];
108
-
109
109
  // Stream the response
110
110
  let fullContent = '';
111
- const streamOptions = { ...options };
112
-
113
- // Add tools to the request if available
114
- if (providerTools.length > 0) {
115
- // We need to pass tools differently depending on provider
116
- // For now, inject into messages context
117
- }
118
-
119
- for await (const chunk of this.provider.chatStream(currentMessages, streamOptions)) {
111
+ for await (const chunk of this.provider.chatStream(currentMessages, options)) {
120
112
  fullContent += chunk;
121
113
  yield chunk;
122
114
  }
123
115
 
124
- // Check for tool calls in the response
125
- const response = await this.provider.chat(currentMessages, { ...streamOptions, stream: false });
126
- const toolCalls = response.tool_calls;
127
-
128
- if (toolCalls && toolCalls.length > 0) {
129
- // Append assistant message with tool calls
130
- currentMessages.push({
131
- role: 'assistant',
132
- content: fullContent,
133
- tool_calls: toolCalls,
134
- });
135
-
136
- // Execute each tool
137
- for (const toolCall of toolCalls) {
138
- const tool = this.tools.get(toolCall.function.name);
139
- if (!tool) {
140
- currentMessages.push({
141
- role: 'tool',
142
- content: `Tool "${toolCall.function.name}" not found`,
143
- tool_call_id: toolCall.id,
144
- name: toolCall.function.name,
145
- });
146
- continue;
147
- }
116
+ // Check for tool calls by making a non-streaming request
117
+ // (streaming tool calls are complex, so we check after stream completes)
118
+ const toolsArray = [...this.tools.values()];
119
+ if (toolsArray.length > 0 && iterations < maxIterations) {
120
+ try {
121
+ const response = await this.provider.chat(currentMessages, options);
122
+ const toolCalls = response.tool_calls;
148
123
 
149
- try {
150
- const params = JSON.parse(toolCall.function.arguments || '{}');
151
- const result = await tool.execute(params);
152
- currentMessages.push({
153
- role: 'tool',
154
- content: result.success ? (result.output || JSON.stringify(result.data)) : (result.error || 'Tool failed'),
155
- tool_call_id: toolCall.id,
156
- name: toolCall.function.name,
157
- });
158
- yield `\n${chalk.gray(`[tool: ${toolCall.function.name}]`)}`;
159
- } catch (error: any) {
124
+ if (toolCalls && toolCalls.length > 0) {
160
125
  currentMessages.push({
161
- role: 'tool',
162
- content: `Tool execution error: ${error.message}`,
163
- tool_call_id: toolCall.id,
164
- name: toolCall.function.name,
126
+ role: 'assistant',
127
+ content: response.content || fullContent,
128
+ tool_calls: toolCalls,
165
129
  });
130
+
131
+ for (const toolCall of toolCalls) {
132
+ const tool = this.tools.get(toolCall.function.name);
133
+ if (!tool) {
134
+ currentMessages.push({
135
+ role: 'tool',
136
+ content: `Tool "${toolCall.function.name}" not found`,
137
+ tool_call_id: toolCall.id,
138
+ name: toolCall.function.name,
139
+ });
140
+ continue;
141
+ }
142
+
143
+ try {
144
+ const params = JSON.parse(toolCall.function.arguments || '{}');
145
+ const result = await tool.execute(params);
146
+ currentMessages.push({
147
+ role: 'tool',
148
+ content: result.success ? (result.output || JSON.stringify(result.data)) : (result.error || 'Tool failed'),
149
+ tool_call_id: toolCall.id,
150
+ name: toolCall.function.name,
151
+ });
152
+ yield `\n${chalk.gray(`[tool: ${toolCall.function.name}]`)}`;
153
+ } catch (error: any) {
154
+ currentMessages.push({
155
+ role: 'tool',
156
+ content: `Tool execution error: ${error.message}`,
157
+ tool_call_id: toolCall.id,
158
+ name: toolCall.function.name,
159
+ });
160
+ }
161
+ }
162
+ continue;
166
163
  }
164
+ } catch {
165
+ // If the non-streaming check fails, just return the streamed content
167
166
  }
168
-
169
- continue; // Loop back for next iteration
170
167
  }
171
168
 
172
- // No tool calls - we're done
173
- return response;
169
+ return { content: fullContent, role: 'assistant' };
174
170
  }
175
171
 
176
172
  return { content: '[Agent loop exceeded maximum iterations]', role: 'assistant' };
177
173
  }
178
174
 
179
175
  /**
180
- * Check if API key is available
176
+ * Error message when no API key is configured
181
177
  */
182
- private hasApiKey(): boolean {
183
- return !!(this.provider as any).config?.apiKey;
184
- }
185
-
186
- /**
187
- * Mock response when no API key is set
188
- */
189
- private getMockResponse(messages: ChatMessage[]): ChatResponse {
190
- const last = messages[messages.length - 1]?.content || '';
191
- let content: string;
192
-
193
- if (/^(hi|hello|hey)/i.test(last)) {
194
- content = 'Hi there! How can I help you today?';
195
- } else if (last.toLowerCase().includes('who are you')) {
196
- content = 'I am Thatgfsj Code, an AI coding assistant.';
197
- } else {
198
- content = `I understand you said: "${last.slice(0, 50)}..."\n\nThis is a demo response. Configure an API key for full AI capabilities.\nRun: gfcode init`;
199
- }
200
-
201
- return { content, role: 'assistant' };
178
+ private getNoKeyMessage(): string {
179
+ return [
180
+ '❌ 未配置 API Key,无法调用 AI。',
181
+ '',
182
+ '请先运行以下命令配置:',
183
+ ' gfcode init',
184
+ '',
185
+ '或手动设置环境变量:',
186
+ ' export SILICONFLOW_API_KEY="sk-..." # 硅基流动',
187
+ ' export OPENAI_API_KEY="sk-..." # OpenAI',
188
+ ' export DEEPSEEK_API_KEY="sk-..." # DeepSeek',
189
+ ' export KIMI_API_KEY="sk-..." # Kimi',
190
+ ' export ANTHROPIC_API_KEY="sk-ant-..." # Claude',
191
+ ].join('\n');
202
192
  }
203
193
  }
204
194
 
package/src/llm/openai.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * OpenAI-compatible Provider
3
- * Works with: OpenAI, SiliconFlow, MiniMax (OpenAI mode), DeepSeek, Kimi, ERNIE, Ollama
3
+ * Works with: OpenAI, SiliconFlow, DeepSeek, Kimi, Zhipu, MiniMax, Baichuan, Stepfun, Doubao, Ollama, ERNIE
4
+ * Also works with any OpenAI-compatible relay station (中转站)
5
+ *
6
+ * API: POST {baseUrl}/chat/completions
7
+ * Auth: Authorization: Bearer {apiKey}
8
+ * Streaming: SSE with "data: {...}" lines, ends with "data: [DONE]"
4
9
  */
5
10
 
6
11
  import type { ChatMessage, ChatResponse, ChatOptions } from '../types.js';
@@ -9,7 +14,7 @@ import type { LLMProvider, ProviderConfig } from './provider.js';
9
14
 
10
15
  export class OpenAIProvider implements LLMProvider {
11
16
  readonly name = 'openai';
12
- private config: ProviderConfig;
17
+ protected config: ProviderConfig;
13
18
 
14
19
  constructor(config: ProviderConfig) {
15
20
  this.config = config;
@@ -34,47 +39,29 @@ export class OpenAIProvider implements LLMProvider {
34
39
 
35
40
  async chat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResponse> {
36
41
  const body = this.buildRequest(messages, false, options);
37
-
38
- const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
39
- method: 'POST',
40
- headers: {
41
- 'Content-Type': 'application/json',
42
- 'Authorization': `Bearer ${this.config.apiKey}`,
43
- },
44
- body: JSON.stringify(body),
45
- });
46
-
47
- if (!response.ok) {
48
- const text = await response.text().catch(() => '');
49
- throw new Error(`OpenAI API error ${response.status}: ${text}`);
50
- }
51
-
42
+ const response = await this.doRequest(body);
52
43
  const data = await response.json();
53
44
  const choice = data.choices?.[0];
54
45
 
55
46
  return {
56
47
  content: choice?.message?.content || '',
57
48
  role: 'assistant',
58
- usage: data.usage,
49
+ usage: data.usage ? {
50
+ prompt_tokens: data.usage.prompt_tokens || 0,
51
+ completion_tokens: data.usage.completion_tokens || 0,
52
+ total_tokens: data.usage.total_tokens || 0,
53
+ } : undefined,
59
54
  tool_calls: choice?.message?.tool_calls,
60
55
  };
61
56
  }
62
57
 
63
58
  async *chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncGenerator<string, ChatResponse> {
64
59
  const body = this.buildRequest(messages, true, options);
65
-
66
- const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
67
- method: 'POST',
68
- headers: {
69
- 'Content-Type': 'application/json',
70
- 'Authorization': `Bearer ${this.config.apiKey}`,
71
- },
72
- body: JSON.stringify(body),
73
- });
60
+ const response = await this.doRequest(body);
74
61
 
75
62
  if (!response.ok || !response.body) {
76
63
  const text = await response.text().catch(() => '');
77
- throw new Error(`OpenAI API error ${response.status}: ${text}`);
64
+ throw new Error(`API error ${response.status}: ${text}`);
78
65
  }
79
66
 
80
67
  const reader = response.body.getReader();
@@ -93,14 +80,20 @@ export class OpenAIProvider implements LLMProvider {
93
80
 
94
81
  for (const line of lines) {
95
82
  const trimmed = line.trim();
96
- if (!trimmed || !trimmed.startsWith('data: ') || trimmed === 'data: [DONE]') continue;
83
+ if (!trimmed) continue;
84
+ if (trimmed === 'data: [DONE]') continue;
85
+ if (!trimmed.startsWith('data: ')) continue;
97
86
 
98
87
  try {
99
88
  const data = JSON.parse(trimmed.slice(6));
100
- const content = data.choices?.[0]?.delta?.content;
101
- if (content) {
102
- fullContent += content;
103
- yield content;
89
+ const delta = data.choices?.[0]?.delta;
90
+ if (delta?.content) {
91
+ fullContent += delta.content;
92
+ yield delta.content;
93
+ }
94
+ // Handle tool calls in streaming
95
+ if (delta?.tool_calls) {
96
+ // Store tool calls for final response
104
97
  }
105
98
  } catch {
106
99
  // Skip invalid JSON lines
@@ -114,7 +107,10 @@ export class OpenAIProvider implements LLMProvider {
114
107
  return { content: fullContent, role: 'assistant' };
115
108
  }
116
109
 
117
- private buildRequest(messages: ChatMessage[], stream: boolean, options?: ChatOptions) {
110
+ /**
111
+ * Build the request body for OpenAI-compatible API
112
+ */
113
+ protected buildRequest(messages: ChatMessage[], stream: boolean, options?: ChatOptions) {
118
114
  return {
119
115
  model: this.config.model,
120
116
  messages: messages.map(m => ({
@@ -127,6 +123,22 @@ export class OpenAIProvider implements LLMProvider {
127
123
  temperature: options?.temperature ?? this.config.temperature,
128
124
  max_tokens: options?.maxTokens ?? this.config.maxTokens,
129
125
  stream,
126
+ ...(stream && { stream_options: { include_usage: true } }),
130
127
  };
131
128
  }
129
+
130
+ /**
131
+ * Execute the HTTP request
132
+ */
133
+ protected async doRequest(body: any): Promise<Response> {
134
+ const url = `${this.config.baseUrl}/chat/completions`;
135
+ return fetch(url, {
136
+ method: 'POST',
137
+ headers: {
138
+ 'Content-Type': 'application/json',
139
+ 'Authorization': `Bearer ${this.config.apiKey}`,
140
+ },
141
+ body: JSON.stringify(body),
142
+ });
143
+ }
132
144
  }
package/src/tui/output.ts CHANGED
@@ -60,7 +60,7 @@ export class REPLOutput {
60
60
  printBanner(): void {
61
61
  console.log(chalk.cyan(`
62
62
  ╔═══════════════════════════════════════╗
63
- ║ 🤖 Thatgfsj Code v0.3.0 ║
63
+ ║ 🤖 Thatgfsj Code v0.4.0 ║
64
64
  ║ AI Coding Assistant ║
65
65
  ╚═══════════════════════════════════════╝
66
66
  `));