v2.2.19: Cloud Model Providers Support (OpenRouter, Anthropic, Gemini)
This commit is contained in:
@@ -3,15 +3,15 @@
|
||||
<!-- ASTRA:AUTO-START -->
|
||||
|
||||
## Snapshot
|
||||
- **Workspace**: `ConnectAI` `v2.2.17` _(absolute path varies by environment; resolved from the active VS Code workspace)_
|
||||
- **Workspace**: `ConnectAI` `v2.2.18` _(absolute path varies by environment; resolved from the active VS Code workspace)_
|
||||
- **Description**: The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.
|
||||
- **Stack**: TypeScript, Node.js, VS Code Extension, LM Studio SDK, Test runner
|
||||
- **Stats**: 250 source files, ~51,189 lines across 5 top-level modules.
|
||||
- **Stats**: 257 source files, ~52,121 lines across 5 top-level modules.
|
||||
|
||||
## Last Refresh
|
||||
- **Time**: 2026-05-16T13:51:21.324Z
|
||||
- **Files newly analysed**: 8
|
||||
- **Files reused from cache**: 242
|
||||
- **Time**: 2026-05-16T14:34:03.239Z
|
||||
- **Files newly analysed**: 3
|
||||
- **Files reused from cache**: 254
|
||||
|
||||
## Directory Map
|
||||
```mermaid
|
||||
@@ -37,7 +37,7 @@ mindmap
|
||||
> Arrows: which top-level module imports from which.
|
||||
```mermaid
|
||||
flowchart LR
|
||||
src["src/<br/>127 files"]
|
||||
src["src/<br/>134 files"]
|
||||
media["media/<br/>6 files"]
|
||||
tests["tests/<br/>33 files"]
|
||||
core_py["core_py/<br/>6 files"]
|
||||
@@ -64,10 +64,10 @@ flowchart LR
|
||||
|
||||
## Modules
|
||||
|
||||
### `src/` — 127 files, ~34,824 lines
|
||||
### `src/` — 134 files, ~35,756 lines
|
||||
|
||||
**Sub-directories**
|
||||
- `src/features/` (54) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H
|
||||
- `src/features/` (61) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H
|
||||
- `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토
|
||||
- `src/memory/` (8) — Episodic Memory (일화 기억) 과거 대화/회의/결정의 맥락 흐름을 저장합니다. 세션 종료 시 자동으로 에피소드를 요약하여 저장합니다. "왜 이렇게 결정했는지", "어떤 흐름으로 진행했는지" 기록. 저장
|
||||
- `src/retrieval/` (8) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-I
|
||||
@@ -87,25 +87,25 @@ flowchart LR
|
||||
- `src/core/services.ts` (164 lines)
|
||||
- `src/lib/paths.ts` (151 lines)
|
||||
- `src/features/company/companyConfig.ts` (896 lines) — State + config plumbing for 1인 기업 모드. Two surfaces: - CompanyState (runtime data: enabled flag, company name, which agents are active, per-agent model overrides). Persisted in VS Code's globalState so
|
||||
- `src/sidebarProvider.ts` (4149 lines)
|
||||
- `src/sidebarProvider.ts` (4165 lines)
|
||||
- `src/memory/types.ts` (126 lines) — Memory Type Definitions (메모리 타입 정의) Astra의 5-Layer Cognitive Memory System의 모든 타입을 정의합니다. ① Short-Term ② Long-Term ③ Project ④ Procedural ⑤ Episodic
|
||||
- `src/retrieval/scoring.ts` (518 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다.
|
||||
- `src/skills/agentKnowledgeMap.ts` (374 lines)
|
||||
- `src/retrieval/lessonHelpers.ts` (325 lines) — Lesson / Experience Memory — pure helpers (no vscode dependency) "Lesson" = a markdown file in the active brain that captures a past mistake/risk and how to avoid repeating it. Identified by a lessons
|
||||
- `src/agent.ts` (3509 lines)
|
||||
- `src/agent.ts` (3579 lines)
|
||||
- `src/features/providers/types.ts` (63 lines) — Cloud LLM provider routing — model id prefix → provider id 매핑. Prefix 규칙: openrouter:anthropic/claude-3.5-sonnet → { provider: 'openrouter', model: 'anthropic/claude-3.5-sonnet' } anthropic:claude-3-5
|
||||
- `src/lib/engine.ts` (906 lines)
|
||||
- `src/features/company/dispatcher.ts` (1435 lines) — Sequential dispatcher for 1인 기업 모드. Drives one company "turn": user prompt → CEO planner (JSON {brief, tasks}) → for each task in plan: dispatch one specialist (sequentially) - build specialist prompt
|
||||
- `src/features/providers/providerConfig.ts` (78 lines) — Provider 별 API key + enable 토글 저장소. 설계: - API key 자체는 vscode.SecretStorage (secrets) 에 — settings.json / Settings Sync 침범 안 받음. - enabled 토글은 일반 settings (g1nation.providers.<id>.enabled) — 사용자가 패널에서
|
||||
- `src/features/approval/approvalQueue.ts` (129 lines)
|
||||
- `src/integrations/telegram/telegramClient.ts` (154 lines)
|
||||
- `src/features/astraOffice/view/runtime.ts` (1564 lines) — 자동 분리: src/sidebarProvider.ts 4002-5116 (IIFE 본문) 에서 추출. 동작 동등. ${assets.derivedBase} placeholder 는 panelHtml 에서 .replace() 로 실제 값 주입. 다음 세션에서 OfficeSnapshot 기반으로 단계적으로 잘라낼 예정.
|
||||
- `src/features/astraOffice/view/runtime.ts` (1765 lines) — 자동 분리: src/sidebarProvider.ts 4002-5116 (IIFE 본문) 에서 추출. 동작 동등. ${assets.derivedBase} placeholder 는 panelHtml 에서 .replace() 로 실제 값 주입. 다음 세션에서 OfficeSnapshot 기반으로 단계적으로 잘라낼 예정.
|
||||
- `src/features/company/agents.ts` (211 lines) — 기본 에이전트 로스터 — 1인 기업 모드의 출고 디폴트. 설계 의도: 소프트웨어/게임 개발 IT 회사의 1인 기업 운영을 가정. 한 사람이 기획 → 디자인 → 개발 → QA → 출시 → 운영/마케팅을 모두 책임질 때 필요한 직군을 빠짐없이 커버하되 역할이 겹치지 않게 분리한다. 직군 구분 (혼동 방지): - 기획자(business) : 무엇을 만들지 정의
|
||||
- `src/features/company/pixelOfficeState.ts` (286 lines) — Pixel Office — Agent Work Pipeline 상태를 시각화하는 UI Layer 전용 모듈. ─────────────────── 설계 원칙 ─────────────────── 1. Agent 핵심 판단 로직을 절대 바꾸지 않는다. Pipeline 진행, contract 합의, 검수 cycle, 승인 게이트 — 모두 기존 dispatcher
|
||||
- `src/features/company/sessionStore.ts` (231 lines) — Disk persistence for company-mode session artefacts. Each company turn produces a timestamped directory: <workspaceRoot>/.astra/company/sessions/2026-05-13T21-29/ ├─ brief.md ← CEO's task decompositio
|
||||
- `src/features/projectArchitecture/scanner.ts` (644 lines) — Deep static analyser for the Project Architecture Context generator. Walks the project tree (skipping the usual nodemodules / out / dist noise), pulls the role of each interesting file from its leadin
|
||||
- `src/lib/contextManager.ts` (275 lines) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계
|
||||
- `src/extension.ts` (1202 lines)
|
||||
- `src/features/company/resumeStore.ts` (134 lines) — Disk persistence for company-turn resume state. 각 turn의 sessionDir 안에 resume.json을 두고, dispatcher가 매 의미 있는 시점(plan 확정 / 각 stage 직후 / abort 시점)에 현재 상태를 덮어쓴다. 재개 시점에는 이 파일을 읽어 nextIndex 부터 dispatch 재개.
|
||||
- `src/core/astraPath.ts` (50 lines) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토리를 해결하여, 사용자 프로젝트 루트가 아닌 ConnectAI 패키지 내부에 데이터를 저장합니다. 이 모듈은 AAL(Astra Autonomou
|
||||
|
||||
### `media/` — 6 files, ~7,011 lines
|
||||
|
||||
@@ -224,7 +224,7 @@ flowchart LR
|
||||
- `g1nation.calendar.connect` — Astra: Google Calendar (iCal) 연결 📅
|
||||
- `g1nation.calendar.refresh` — Astra: Google Calendar 새로고침 📅
|
||||
- `g1nation.calendar.connectOAuth` — Astra: Google Calendar OAuth 연결 (쓰기) 🔐
|
||||
- **Configuration** (56 settings):
|
||||
- **Configuration** (62 settings):
|
||||
- `g1nation.multiAgentEnabled` *(boolean)* _(default: `false`)_ — Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks.
|
||||
- `g1nation.memoryEnabled` *(boolean)* _(default: `true`)_ — Enable layered memory injection before each model response.
|
||||
- `g1nation.memoryShortTermMessages` *(number)* _(default: `8`)_ — Number of recent conversation messages included as short-term memory.
|
||||
@@ -281,6 +281,11 @@ flowchart LR
|
||||
- `g1nation.google.defaultEventDurationMinutes` *(number)* _(default: `60`)_ — end / duration 둘 다 없는 일정의 기본 길이 (분). agent 가 회의록에서 시각만 추출하고 종료 시각은 명시 안 했을 때 적용.
|
||||
- `g1nation.google.icalUrl` *(string)* _(default: `""`)_
|
||||
- `g1nation.google.icalDaysAhead` *(number)* _(default: `14`)_ — iCal 캐시에 포함할 다가오는 일정 기간 (일). default 14 = 2주치.
|
||||
- `g1nation.providers.openrouter.enabled` *(boolean)* _(default: `false`)_ — OpenRouter cloud provider 활성화 — Claude/Gemini/GPT 등 100+ 모델을 OpenAI 호환 API 로 사용. API key 는 Astra Settings 패널에서 등록 (Secret Storage 사용, settings.json 비저장).
|
||||
- `g1nation.providers.openrouter.defaultModel` *(string)* _(default: `""`)_ — OpenRouter 의 기본 모델 (예: 'anthropic/claude-3.5-sonnet'). 모델 선택 시 'openrouter:<model>' 형식으로 사이드바 dropdown 에 표시.
|
||||
- `g1nation.providers.anthropic.enabled` *(boolean)* _(default: `false`)_ — Anthropic Claude 직접 API 활성화. OpenRouter 보다 마진 적고 prompt caching 등 native 기능 사용 가능. API key 는 Secret Storage.
|
||||
- `g1nation.providers.anthropic.defaultModel` *(string)* _(default: `"claude-3-5-sonnet-20241022"`)_ — Anthropic 의 기본 모델. 예: 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022'.
|
||||
- _…and 2 more_
|
||||
|
||||
## Dependencies
|
||||
- **Runtime** (2): `@lmstudio/sdk`, `pdf-parse`
|
||||
@@ -328,7 +333,7 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한
|
||||
**Designed for High-Performance Decision Making.**
|
||||
Copyright (C) **g1nation**. All rights reserved.
|
||||
|
||||
_Last auto-scan: 2026-05-16T13:51:21.324Z · signature `ebf5ecaf`_
|
||||
_Last auto-scan: 2026-05-16T14:34:03.239Z · signature `857a73ab`_
|
||||
<!-- ASTRA:AUTO-END -->
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"generatedAt": "2026-05-16T13:51:21.336Z",
|
||||
"generatedAt": "2026-05-16T14:34:03.249Z",
|
||||
"files": {
|
||||
"src/agent.ts": {
|
||||
"mtimeMs": 1778936503000,
|
||||
"size": 201748,
|
||||
"lines": 3509,
|
||||
"mtimeMs": 1778941597000,
|
||||
"size": 205909,
|
||||
"lines": 3579,
|
||||
"role": "",
|
||||
"imports": [
|
||||
"src/utils",
|
||||
@@ -34,7 +34,8 @@
|
||||
"src/core/responseRecovery",
|
||||
"src/lib/contextManager",
|
||||
"src/lmstudio/streamer",
|
||||
"src/features/approval/approvalQueue"
|
||||
"src/features/approval/approvalQueue",
|
||||
"src/features/providers"
|
||||
]
|
||||
},
|
||||
"src/agents/AgentWorkflowManager.ts": {
|
||||
@@ -387,9 +388,9 @@
|
||||
]
|
||||
},
|
||||
"src/features/astraOffice/view/runtime.ts": {
|
||||
"mtimeMs": 1778939265000,
|
||||
"size": 73382,
|
||||
"lines": 1564,
|
||||
"mtimeMs": 1778941900000,
|
||||
"size": 82015,
|
||||
"lines": 1765,
|
||||
"role": "자동 분리: src/sidebarProvider.ts 4002-5116 (IIFE 본문) 에서 추출. 동작 동등. ${assets.derivedBase} placeholder 는 panelHtml 에서 .replace() 로 실제 값 주입. 다음 세션에서 OfficeSnapshot 기반으로 단계적으로 잘라낼 예정.",
|
||||
"imports": []
|
||||
},
|
||||
@@ -731,6 +732,74 @@
|
||||
"role": "",
|
||||
"imports": []
|
||||
},
|
||||
"src/features/providers/anthropic.ts": {
|
||||
"mtimeMs": 1778941456000,
|
||||
"size": 4111,
|
||||
"lines": 108,
|
||||
"role": "Anthropic Messages API adapter. 차이점 (OpenAI 와): 1. base URL: https://api.anthropic.com/v1 2. 인증: x-api-key: <key> + anthropic-version: 2023-06-01 3. system prompt 는 messages 가 아니라 top-level system 필드 ",
|
||||
"imports": [
|
||||
"src/features/providers/types",
|
||||
"src/features/providers/providerConfig",
|
||||
"src/features/providers/streamHelpers"
|
||||
]
|
||||
},
|
||||
"src/features/providers/gemini.ts": {
|
||||
"mtimeMs": 1778941478000,
|
||||
"size": 4326,
|
||||
"lines": 108,
|
||||
"role": "Google Gemini Generative Language API adapter. 차이점 (OpenAI 와): 1. base URL: https://generativelanguage.googleapis.com/v1beta 2. 인증: ?key=<api key> (query parameter) 3. 메시지 형식: contents: [{role: 'user'",
|
||||
"imports": [
|
||||
"src/features/providers/types",
|
||||
"src/features/providers/providerConfig",
|
||||
"src/features/providers/streamHelpers"
|
||||
]
|
||||
},
|
||||
"src/features/providers/index.ts": {
|
||||
"mtimeMs": 1778941521000,
|
||||
"size": 3242,
|
||||
"lines": 69,
|
||||
"role": "Cloud LLM provider public API. 일반 호출 흐름: 1. agent.ts 의 chat 진입부에서 parseModelPrefix(modelId) 호출 2. null → local engine 경로 (옛 로직). 객체 → streamCloudCompletion(context, hit, params) 호출 3. Response 의 body ",
|
||||
"imports": [
|
||||
"src/features/providers/types",
|
||||
"src/features/providers/openrouter",
|
||||
"src/features/providers/anthropic",
|
||||
"src/features/providers/gemini",
|
||||
"src/features/providers/providerConfig"
|
||||
]
|
||||
},
|
||||
"src/features/providers/openrouter.ts": {
|
||||
"mtimeMs": 1778941431000,
|
||||
"size": 2673,
|
||||
"lines": 75,
|
||||
"role": "OpenRouter — OpenAI 호환 API. 별도 transform 없이 fetch Response 를 그대로 반환. Base: https://openrouter.ai/api/v1 POST /chat/completions — OpenAI 형식 그대로. stream:true 면 SSE. GET /models — 사용 가능 모델 목록. 인증: Author",
|
||||
"imports": [
|
||||
"src/features/providers/types",
|
||||
"src/features/providers/providerConfig"
|
||||
]
|
||||
},
|
||||
"src/features/providers/providerConfig.ts": {
|
||||
"mtimeMs": 1778941498000,
|
||||
"size": 2683,
|
||||
"lines": 78,
|
||||
"role": "Provider 별 API key + enable 토글 저장소. 설계: - API key 자체는 vscode.SecretStorage (secrets) 에 — settings.json / Settings Sync 침범 안 받음. - enabled 토글은 일반 settings (g1nation.providers.<id>.enabled) — 사용자가 패널에서 ",
|
||||
"imports": [
|
||||
"src/features/providers/types"
|
||||
]
|
||||
},
|
||||
"src/features/providers/streamHelpers.ts": {
|
||||
"mtimeMs": 1778941413000,
|
||||
"size": 5904,
|
||||
"lines": 144,
|
||||
"role": "Stream transformer — provider 별 SSE 형식을 OpenAI 호환 SSE 로 변환. 이렇게 하면 agent.ts 의 기존 SSE 파서 (data: {...delta...} 형식 가정) 가 변경 없이 모든 provider 출력을 같은 코드 경로로 소비할 수 있다. 신규 provider 가 들어와도 adapter 하나만 추가하면 됨 (c",
|
||||
"imports": []
|
||||
},
|
||||
"src/features/providers/types.ts": {
|
||||
"mtimeMs": 1778941377000,
|
||||
"size": 2370,
|
||||
"lines": 63,
|
||||
"role": "Cloud LLM provider routing — model id prefix → provider id 매핑. Prefix 규칙: openrouter:anthropic/claude-3.5-sonnet → { provider: 'openrouter', model: 'anthropic/claude-3.5-sonnet' } anthropic:claude-3-5",
|
||||
"imports": []
|
||||
},
|
||||
"src/features/secondBrainTrace.ts": {
|
||||
"mtimeMs": 1778248166000,
|
||||
"size": 37475,
|
||||
@@ -1184,9 +1253,9 @@
|
||||
]
|
||||
},
|
||||
"src/sidebarProvider.ts": {
|
||||
"mtimeMs": 1778937445000,
|
||||
"size": 189218,
|
||||
"lines": 4149,
|
||||
"mtimeMs": 1778941620000,
|
||||
"size": 189927,
|
||||
"lines": 4165,
|
||||
"role": "",
|
||||
"imports": [
|
||||
"src/utils",
|
||||
@@ -1861,7 +1930,7 @@
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/chronicle.config.json": {
|
||||
"mtimeMs": 1778937757000,
|
||||
"mtimeMs": 1778940893000,
|
||||
"size": 416,
|
||||
"lines": 11,
|
||||
"role": "JSON configuration",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1778939488449,
|
||||
"createdAt": 1778941922044,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
"createdAt": 1778939488449,
|
||||
"createdAt": 1778941922043,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
||||
"createdAt": 1778939488444,
|
||||
"createdAt": 1778941922042,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "---\nid: stress_conflict_1778939488432\ndate: 2026-05-16T13:51:28.450Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (12ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (1ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (4ms)\n",
|
||||
"createdAt": 1778939488450,
|
||||
"result": "---\nid: stress_conflict_1778941922031\ndate: 2026-05-16T14:32:02.046Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (11ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (1ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (0ms)\n",
|
||||
"createdAt": 1778941922046,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+10
-10
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "stress_conflict_1778939488432",
|
||||
"missionId": "stress_conflict_1778941922031",
|
||||
"status": "completed",
|
||||
"startTime": "2026-05-16T13:51:28.432Z",
|
||||
"totalElapsedMs": 18,
|
||||
"startTime": "2026-05-16T14:32:02.031Z",
|
||||
"totalElapsedMs": 15,
|
||||
"results": {
|
||||
"planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
||||
"researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
@@ -16,30 +16,30 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "planner",
|
||||
"durationMs": 12,
|
||||
"durationMs": 11,
|
||||
"message": "전략 수립 중...",
|
||||
"ts": "2026-05-16T13:51:28.444Z"
|
||||
"ts": "2026-05-16T14:32:02.042Z"
|
||||
},
|
||||
{
|
||||
"from": "planner",
|
||||
"to": "researcher",
|
||||
"durationMs": 1,
|
||||
"message": "핵심 정보 수집 및 분석 중...",
|
||||
"ts": "2026-05-16T13:51:28.445Z"
|
||||
"ts": "2026-05-16T14:32:02.043Z"
|
||||
},
|
||||
{
|
||||
"from": "researcher",
|
||||
"to": "writer",
|
||||
"durationMs": 4,
|
||||
"durationMs": 0,
|
||||
"message": "최종 리포트 작성 및 편집 중...",
|
||||
"ts": "2026-05-16T13:51:28.449Z"
|
||||
"ts": "2026-05-16T14:32:02.043Z"
|
||||
},
|
||||
{
|
||||
"from": "writer",
|
||||
"to": "completed",
|
||||
"durationMs": 1,
|
||||
"durationMs": 3,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-05-16T13:51:28.450Z"
|
||||
"ts": "2026-05-16T14:32:02.046Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
@@ -1,5 +1,17 @@
|
||||
# Astra Patch Notes
|
||||
|
||||
## v2.2.19 (2026-05-16)
|
||||
### ☁️ Cloud Model Providers Support: OpenRouter, Anthropic, Gemini
|
||||
- **클라우드 모델 프로바이더 통합:** 이제 로컬 모델뿐만 아니라 OpenRouter, Anthropic, Gemini 등 주요 클라우드 AI 모델을 직접 사용할 수 있습니다.
|
||||
- **모델 접두사 라우팅(Prefix Routing) 도입:** 모델명 앞에 `openrouter:`, `anthropic:`, `gemini:` 접두사를 붙여 원하는 서비스로 자동 라우팅되는 지능형 엔진을 탑재했습니다.
|
||||
- **OpenAI 호환 스트리밍 어댑터:** 클라우드 각사의 독자적인 응답 형식을 OpenAI 호환 SSE 스트림으로 변환하여, 기존 아스트라의 모든 분석 기능을 클라우드 모델에서도 동일하게 사용할 수 있도록 구현했습니다.
|
||||
- **하이브리드 추론 환경 구축:** 지능이 필요한 작업은 클라우드 대형 모델로, 보안이 중요한 코딩 작업은 로컬 모델로 수행하는 유연한 워크플로우 기반을 마련했습니다.
|
||||
- **신규 패키징:** `astra-2.2.19.vsix` 패키지를 통해 로컬과 클라우드를 아우르는 하이브리드 지능형 레이아웃을 배포합니다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## v2.2.18 (2026-05-16)
|
||||
### 🏗️ Dynamic Office Auto-Layout & Legacy Cleanup
|
||||
- **동적 오피스 레이아웃(Dynamic Auto-Layout) 엔진 도입:** 팀원 수에 따라 책상 배치를 1~3열로 자동 정렬하는 지능형 레이아웃 알고리즘을 탑재했습니다. 이제 팀원 수에 관계없이 최적화된 오피스 뷰를 제공합니다.
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"corePurpose": "",
|
||||
"detailLevel": "standard",
|
||||
"createdAt": "2026-05-13T13:09:33.788Z",
|
||||
"updatedAt": "2026-05-16T13:52:09.001Z"
|
||||
"updatedAt": "2026-05-16T14:14:53.934Z"
|
||||
}
|
||||
|
||||
@@ -212,6 +212,78 @@
|
||||
<div id="googleError" class="error" hidden></div>
|
||||
</section>
|
||||
|
||||
<!-- Cloud LLM Providers -->
|
||||
<section class="section" data-section="providers">
|
||||
<h2>Cloud LLM Providers</h2>
|
||||
<p class="hint">Ollama / LM Studio 로컬 외에 cloud API 를 붙여서 모델 선택지를 확장. API key 는 모두 Secret Storage 에 저장 (settings.json 침범 X). 사이드바 모델 dropdown 에서 활성 provider 의 모델이 함께 표시됩니다.</p>
|
||||
|
||||
<!-- OpenRouter -->
|
||||
<h3 style="margin-top:6px;font-size:13px;color:var(--text)">OpenRouter</h3>
|
||||
<p class="hint">100+ 모델 (Claude / Gemini / GPT / Llama 전부) 을 단일 API 로. <a href="https://openrouter.ai/keys" target="_blank">openrouter.ai/keys</a> 에서 API key 발급.</p>
|
||||
<div class="row toggle">
|
||||
<label><input id="prOpenrouterEnabled" type="checkbox"> OpenRouter 활성화</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="prOpenrouterKey">API Key</label>
|
||||
<div class="input-group">
|
||||
<input id="prOpenrouterKey" type="password" placeholder="sk-or-..." autocomplete="off" spellcheck="false" />
|
||||
<button data-save="providers.openrouter.apiKey">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="prOpenrouterDefault">기본 모델 (선택)</label>
|
||||
<div class="input-group">
|
||||
<input id="prOpenrouterDefault" type="text" placeholder="anthropic/claude-3.5-sonnet" autocomplete="off" />
|
||||
<button data-save="providers.openrouter.defaultModel">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Anthropic -->
|
||||
<h3 style="margin-top:18px;font-size:13px;color:var(--text)">Anthropic Claude (직통)</h3>
|
||||
<p class="hint">Anthropic 직접 API — prompt caching 등 native 기능 활용 가능. <a href="https://console.anthropic.com/settings/keys" target="_blank">console.anthropic.com/settings/keys</a> 에서 API key 발급.</p>
|
||||
<div class="row toggle">
|
||||
<label><input id="prAnthropicEnabled" type="checkbox"> Anthropic 활성화</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="prAnthropicKey">API Key</label>
|
||||
<div class="input-group">
|
||||
<input id="prAnthropicKey" type="password" placeholder="sk-ant-..." autocomplete="off" spellcheck="false" />
|
||||
<button data-save="providers.anthropic.apiKey">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="prAnthropicDefault">기본 모델</label>
|
||||
<div class="input-group">
|
||||
<input id="prAnthropicDefault" type="text" placeholder="claude-3-5-sonnet-20241022" autocomplete="off" />
|
||||
<button data-save="providers.anthropic.defaultModel">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gemini -->
|
||||
<h3 style="margin-top:18px;font-size:13px;color:var(--text)">Google Gemini (직통)</h3>
|
||||
<p class="hint">1M context (gemini-1.5-pro), 무료 tier 사용 가능. <a href="https://aistudio.google.com/app/apikey" target="_blank">aistudio.google.com/app/apikey</a> 에서 발급.</p>
|
||||
<div class="row toggle">
|
||||
<label><input id="prGeminiEnabled" type="checkbox"> Gemini 활성화</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="prGeminiKey">API Key</label>
|
||||
<div class="input-group">
|
||||
<input id="prGeminiKey" type="password" placeholder="AIzaSy..." autocomplete="off" spellcheck="false" />
|
||||
<button data-save="providers.gemini.apiKey">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="prGeminiDefault">기본 모델</label>
|
||||
<div class="input-group">
|
||||
<input id="prGeminiDefault" type="text" placeholder="gemini-2.0-flash-exp" autocomplete="off" />
|
||||
<button data-save="providers.gemini.defaultModel">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="providersFeedback" class="feedback" hidden></div>
|
||||
<div id="providersError" class="error" hidden></div>
|
||||
</section>
|
||||
|
||||
<!-- Advanced -->
|
||||
<section class="section" data-section="advanced">
|
||||
<h2>고급</h2>
|
||||
|
||||
+31
-1
@@ -2,7 +2,7 @@
|
||||
"name": "astra",
|
||||
"displayName": "Astra",
|
||||
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
||||
"version": "2.2.18",
|
||||
"version": "2.2.19",
|
||||
"publisher": "g1nation",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
@@ -527,6 +527,36 @@
|
||||
"minimum": 1,
|
||||
"maximum": 90,
|
||||
"description": "iCal 캐시에 포함할 다가오는 일정 기간 (일). default 14 = 2주치."
|
||||
},
|
||||
"g1nation.providers.openrouter.enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "OpenRouter cloud provider 활성화 — Claude/Gemini/GPT 등 100+ 모델을 OpenAI 호환 API 로 사용. API key 는 Astra Settings 패널에서 등록 (Secret Storage 사용, settings.json 비저장)."
|
||||
},
|
||||
"g1nation.providers.openrouter.defaultModel": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "OpenRouter 의 기본 모델 (예: 'anthropic/claude-3.5-sonnet'). 모델 선택 시 'openrouter:<model>' 형식으로 사이드바 dropdown 에 표시."
|
||||
},
|
||||
"g1nation.providers.anthropic.enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Anthropic Claude 직접 API 활성화. OpenRouter 보다 마진 적고 prompt caching 등 native 기능 사용 가능. API key 는 Secret Storage."
|
||||
},
|
||||
"g1nation.providers.anthropic.defaultModel": {
|
||||
"type": "string",
|
||||
"default": "claude-3-5-sonnet-20241022",
|
||||
"description": "Anthropic 의 기본 모델. 예: 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022'."
|
||||
},
|
||||
"g1nation.providers.gemini.enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Google Gemini 직접 API 활성화. 1M context (gemini-1.5-pro), 무료 tier 등 native 기능 사용. API key 는 Secret Storage."
|
||||
},
|
||||
"g1nation.providers.gemini.defaultModel": {
|
||||
"type": "string",
|
||||
"default": "gemini-2.0-flash-exp",
|
||||
"description": "Gemini 의 기본 모델. 예: 'gemini-2.0-flash-exp', 'gemini-1.5-pro', 'gemini-1.5-flash'."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+78
-2
@@ -731,8 +731,16 @@ export class AgentExecutor {
|
||||
this.abortController?.abort();
|
||||
}, timeout);
|
||||
|
||||
const engine = resolveEngine(ollamaUrl);
|
||||
const useLmStudioSdk = engine === 'lmstudio' && !!this.options.lmStudioStreamer;
|
||||
// Cloud provider 라우팅 — actualModel 의 prefix 가 cloud 면 SDK / 로컬 REST 경로 둘 다 우회.
|
||||
// SSE 파서 입장에서는 동일한 OpenAI 호환 stream 이 들어오므로 consumer 변경 없음.
|
||||
const _cloudHit = (() => {
|
||||
try {
|
||||
const { parseModelPrefix } = require('./features/providers') as typeof import('./features/providers');
|
||||
return parseModelPrefix(actualModel);
|
||||
} catch { return null; }
|
||||
})();
|
||||
const engine = _cloudHit ? 'lmstudio' : resolveEngine(ollamaUrl);
|
||||
const useLmStudioSdk = !_cloudHit && engine === 'lmstudio' && !!this.options.lmStudioStreamer;
|
||||
let apiUrl = '';
|
||||
let aiResponseText = '';
|
||||
let buffer = '';
|
||||
@@ -2754,6 +2762,35 @@ export class AgentExecutor {
|
||||
}): Promise<{ response: Response; engine: 'lmstudio' | 'ollama'; apiUrl: string }> {
|
||||
const { baseUrl, modelName, reqMessages, temperature } = params;
|
||||
const maxTokens = Math.max(256, params.maxTokens ?? 4096);
|
||||
|
||||
// Cloud provider 라우팅 — model id 가 'openrouter:' / 'anthropic:' / 'gemini:' 로 시작하면
|
||||
// 해당 adapter 호출. body 는 OpenAI 호환 SSE 로 transform 되어 반환되므로
|
||||
// 아래 로컬 엔진 경로의 consumer 가 동일하게 처리.
|
||||
try {
|
||||
const { parseModelPrefix, streamCloudCompletion } =
|
||||
require('./features/providers') as typeof import('./features/providers');
|
||||
const hit = parseModelPrefix(modelName);
|
||||
if (hit) {
|
||||
logInfo('AI streaming request (cloud).', { provider: hit.provider, model: hit.model });
|
||||
const response = await streamCloudCompletion(this.context, hit, {
|
||||
messages: reqMessages.map((m) => ({ role: m.role as any, content: m.content })),
|
||||
temperature,
|
||||
maxTokens,
|
||||
signal: this.abortController?.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(`Cloud (${hit.provider}) ${response.status}: ${summarizeText(errText, 300)}`);
|
||||
}
|
||||
return { response, engine: 'lmstudio', apiUrl: `cloud://${hit.provider}/${hit.model}` };
|
||||
}
|
||||
} catch (e) {
|
||||
// 모듈 로드 실패 / 매칭 안 됨 — 로컬 경로로 fall through.
|
||||
// (단, 명시적으로 cloud routing 했는데 실패한 경우는 throw 되어 위에서 catch 됨.)
|
||||
const msg = (e as Error)?.message ?? '';
|
||||
if (msg.startsWith('Cloud (')) throw e;
|
||||
}
|
||||
|
||||
const numCtx = Math.max(2048, params.contextLength ?? 32768);
|
||||
const engine = resolveEngine(baseUrl); // 사용자가 설정한 엔진만 사용
|
||||
const apiUrl = buildApiUrl(baseUrl, engine, 'chat');
|
||||
@@ -2860,6 +2897,45 @@ export class AgentExecutor {
|
||||
}): Promise<{ text: string; stopReason?: string }> {
|
||||
const { baseUrl, modelName, engine, messages, temperature, signal } = params;
|
||||
const maxTokens = Math.max(256, params.maxTokens ?? 4096);
|
||||
|
||||
// Cloud routing — streaming Response 를 받아 끝까지 모아서 텍스트로 환원.
|
||||
// Non-streaming 전용 endpoint 를 따로 두지 않고 stream 결과를 모으는 게 단순.
|
||||
try {
|
||||
const { parseModelPrefix, streamCloudCompletion } =
|
||||
require('./features/providers') as typeof import('./features/providers');
|
||||
const hit = parseModelPrefix(modelName);
|
||||
if (hit) {
|
||||
const response = await streamCloudCompletion(this.context, hit, {
|
||||
messages: messages.map((m) => ({ role: m.role as any, content: m.content })),
|
||||
temperature,
|
||||
maxTokens,
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errText = await response.text().catch(() => '');
|
||||
throw new Error(`Cloud (${hit.provider}) ${response.status}: ${summarizeText(errText, 200)}`);
|
||||
}
|
||||
// OpenAI 호환 SSE 를 통째로 읽어 delta.content 합치기.
|
||||
const raw = await response.text();
|
||||
let acc = '';
|
||||
for (const line of raw.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith('data:')) continue;
|
||||
const payload = t.slice(5).trim();
|
||||
if (!payload || payload === '[DONE]') continue;
|
||||
try {
|
||||
const obj = JSON.parse(payload);
|
||||
const delta = obj?.choices?.[0]?.delta?.content;
|
||||
if (typeof delta === 'string') acc += delta;
|
||||
} catch { /* skip malformed */ }
|
||||
}
|
||||
return { text: acc, stopReason: 'stop' };
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = (e as Error)?.message ?? '';
|
||||
if (msg.startsWith('Cloud (')) throw e;
|
||||
}
|
||||
|
||||
const numCtx = Math.max(2048, params.contextLength ?? 32768);
|
||||
const apiUrl = buildApiUrl(baseUrl, engine, 'chat');
|
||||
const variants = this.buildEngineMessageVariants(messages, engine);
|
||||
|
||||
@@ -496,6 +496,73 @@ setInterval(()=>{
|
||||
walkPath(k,[st.dock,pt,st.dock,[st.seatX,st.seatY]],()=>setSprite(k,'sit'));
|
||||
},9000);
|
||||
function activate(role){Object.keys(chars).forEach(k=>chars[k].classList.toggle('active',k===role))}
|
||||
|
||||
// ── 검수 시나리오: 작성자를 검수자 옆으로 데려가기 + 안전거리 ──
|
||||
// 작성자(writer) 가 작업 끝낸 후 status='reviewing' 진입하면 검수자 책상 옆으로 이동.
|
||||
// 다른 캐릭터들과 겹치지 않도록 안전거리(>= SAFE_DIST px) 유지하면서 자리 후보 탐색.
|
||||
const SAFE_DIST = 36;
|
||||
function _isClearSpot(x, y, ignoreRole){
|
||||
for(const k of Object.keys(chars)){
|
||||
if(k === ignoreRole) continue;
|
||||
const c = chars[k]; if(!c) continue;
|
||||
const cx = parseFloat(c.style.left), cy = parseFloat(c.style.top);
|
||||
const dx = cx - x, dy = cy - y;
|
||||
if(Math.sqrt(dx*dx + dy*dy) < SAFE_DIST) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function _findSpotNear(targetX, targetY, ignoreRole){
|
||||
// 검수자 책상 주변 8방향 원형 탐색 — 60px 부터 시작해 비면 즉시 채택.
|
||||
const offsets = [
|
||||
[60, 0], [-60, 0], [0, -60], [0, 60],
|
||||
[50, -40], [-50, -40], [50, 40], [-50, 40],
|
||||
[80, 0], [-80, 0], [0, -70], [0, 70],
|
||||
];
|
||||
for(const [dx, dy] of offsets){
|
||||
const x = targetX + dx, y = targetY + dy;
|
||||
// stage 경계.
|
||||
if(x < 20 || y < 20 || x > (stage.offsetWidth - 60) || y > (stage.offsetHeight - 80)) continue;
|
||||
if(_isClearSpot(x, y, ignoreRole)) return [x, y];
|
||||
}
|
||||
// 모든 후보 실패 — fallback 으로 그냥 target 옆 60px (겹쳐도 어쩔 수 없음).
|
||||
return [targetX + 60, targetY];
|
||||
}
|
||||
/**
|
||||
* writerRole 캐릭터를 reviewerRole 의 자리 근처로 walk. 도착 후 standing 자세로 face 만 회전.
|
||||
* reviewing 모드 종료 시 _restoreWriterHome 으로 원위치.
|
||||
*/
|
||||
const _movedToReviewer = new Map(); // role → original home {x, y, face}
|
||||
function _walkToReviewer(writerRole, reviewerRole){
|
||||
if(!writerRole || !reviewerRole) return;
|
||||
if(writerRole === reviewerRole) return;
|
||||
const writerCh = chars[writerRole], reviewerCh = chars[reviewerRole];
|
||||
if(!writerCh || !reviewerCh) return;
|
||||
if(_movedToReviewer.has(writerRole)) return; // 이미 가 있음
|
||||
const rx = parseFloat(reviewerCh.style.left), ry = parseFloat(reviewerCh.style.top);
|
||||
const [tx, ty] = _findSpotNear(rx, ry, writerRole);
|
||||
const a = anim[writerRole]; if(!a) return;
|
||||
// home 기억해뒀다가 reviewing 끝나면 복귀.
|
||||
_movedToReviewer.set(writerRole, {
|
||||
x: parseFloat(writerCh.style.left), y: parseFloat(writerCh.style.top), face: a.face,
|
||||
});
|
||||
walkPath(writerRole, [[tx, ty]], () => {
|
||||
// 도착 후 검수자 쪽 face. 좌우 위치만 비교 (목적지가 reviewer 왼쪽인지 오른쪽인지).
|
||||
if(a){ a.face = tx < rx ? 'R' : 'L'; }
|
||||
setSprite(writerRole, 'sit');
|
||||
});
|
||||
}
|
||||
function _restoreWriterHome(writerRole){
|
||||
const saved = _movedToReviewer.get(writerRole);
|
||||
if(!saved){
|
||||
// 옮긴 적 없으면 그냥 sendHome.
|
||||
sendHome(writerRole, 'sit');
|
||||
return;
|
||||
}
|
||||
_movedToReviewer.delete(writerRole);
|
||||
const a = anim[writerRole]; if(a) a.face = saved.face;
|
||||
// station seat 좌표로 복귀 (saved.x/y 는 옛 home 이지만 그 동안 사용자 layout 편집이 있을 수 있어 station 우선).
|
||||
sendHome(writerRole, 'sit');
|
||||
}
|
||||
function bubble(role,text){const ch=chars[role];if(!ch||!text)return;const b=document.createElement('div');b.className='bubble';b.textContent=text;b.style.left=(parseFloat(ch.style.left)+28)+'px';b.style.top=(parseFloat(ch.style.top)-6)+'px';stage.appendChild(b);setTimeout(()=>b.remove(),2400)}
|
||||
|
||||
// ── 로그 → 말풍선 요약기 ──
|
||||
@@ -547,6 +614,104 @@ function _bubbleFromLog(role, raw){
|
||||
_lastBubbleAt[role] = now;
|
||||
bubble(role, txt);
|
||||
}
|
||||
|
||||
// ── 속마음 라이브러리 (refactor: inner-thought bubbles) ──
|
||||
// 각 직군이 작업 중 (mode='work') 무슨 생각을 하는지 — 자동 주기 emitter 가 6~9초마다
|
||||
// 활성 character 중 한 명을 골라 한 줄 띄움. 사용자가 "지금 이 캐릭터가 뭘 고민 중인지"
|
||||
// 느낌으로 알게. 로그 기반 _bubbleFromLog 와는 독립 — 둘 다 같은 throttle gate 통과.
|
||||
const INNER_THOUGHTS = {
|
||||
ceo: {
|
||||
work: ['전체 그림 다시 한번', '리스크는?', '우선순위 재배치', '누구한테 맡길까', '결정 미루지 말자'],
|
||||
sit: ['오늘 일정 확인…', '보고서 정리 시작', '커피 한 모금'],
|
||||
},
|
||||
planner: {
|
||||
work: ['핵심 시나리오 빠진 거 없나', '측정 가능한 기준 추가', '범위 너무 넓어졌어', '한 문단으로 줄여보자', '독자 입장에서…'],
|
||||
sit: ['오늘 뭐부터 시작할까', '레퍼런스 한 번 더', '메모 다시 보자'],
|
||||
},
|
||||
researcher: {
|
||||
work: ['출처 한 번 더 확인', '비슷한 사례 있나', '데이터 vs 추측', '반증 케이스 찾자', '이거 일반론 아냐?'],
|
||||
sit: ['검색 키워드 다듬기', '논문 한 편 더', '북마크 정리'],
|
||||
},
|
||||
designer: {
|
||||
work: ['시각 위계 맞나', '여백 더 줄까', '컬러 톤 일관성', '모바일에선 어떻게', '사용자 흐름 막힘 없나'],
|
||||
sit: ['inspiration 좀…', '컴포넌트 라이브러리 봐야', '피그마 정리'],
|
||||
},
|
||||
developer: {
|
||||
work: ['엣지 케이스 빠짐', '이거 깨질 거 같은데', '함수 너무 길어', '테스트부터 쓸까', '에러 처리 누락'],
|
||||
sit: ['이슈 목록 훑어', '리팩토링 백로그', '커밋 메시지 다듬기'],
|
||||
},
|
||||
qa: {
|
||||
work: ['이 케이스는 어떻게', '입력 비어있으면', '동시성 문제 가능', 'race condition', '경계값 한 번 더'],
|
||||
sit: ['테스트 시나리오 정리', '회귀 케이스 추가', '버그 재현 절차'],
|
||||
},
|
||||
inspector: {
|
||||
work: ['기획 의도 일치 여부', '과한 over-engineering', '시나리오 누락', '측정 기준 명확한가', '재작업 여부 판단'],
|
||||
sit: ['이전 검토 메모…', '체크리스트 정리', '기준 다듬기'],
|
||||
},
|
||||
support: {
|
||||
work: ['일정 충돌 확인', '담당 명확한가', '리마인더 시점', '회의록 정리', '이해관계자 누락'],
|
||||
sit: ['오늘 처리할 것…', '캘린더 한 번 더', '미답 답장 확인'],
|
||||
},
|
||||
};
|
||||
function _innerThoughtFor(agentKey, mode){
|
||||
const bucket = INNER_THOUGHTS[agentKey];
|
||||
if(!bucket) return null;
|
||||
const pool = bucket[mode] || bucket.work || [];
|
||||
if(!pool.length) return null;
|
||||
return pool[Math.floor(Math.random()*pool.length)];
|
||||
}
|
||||
// 검수 중 작성자가 inspector 옆에 있을 때 띄울 "긴장한 작성자" 속마음 pool.
|
||||
const REVIEW_NERVOUS_THOUGHTS = [
|
||||
'괜찮을까…', '재작업 안 나왔으면', '핵심은 다 들어갔지', '여기 빠뜨린 거 있나', '한 라운드면 통과해줘',
|
||||
];
|
||||
// 검수자가 keyword 형식으로 던지는 결론 어휘 — 로그 없을 때도 자체 emit.
|
||||
const REVIEWER_KEYWORD_BANK = [
|
||||
['시나리오 부족', '핵심 누락', '재작업 필요'],
|
||||
['승인', '기준 충족', '진행 OK'],
|
||||
['일반론', '구체화 필요', '예시 부족'],
|
||||
['중복', '간결화', 'over-engineering'],
|
||||
['엣지케이스', '경계값', '실패 케이스'],
|
||||
['측정 기준', '성공 정의', 'KPI'],
|
||||
];
|
||||
|
||||
// 6~9초 random 간격으로 활성 character 중 하나에서 속마음 emit. mode 가 work / sit 인 것만.
|
||||
// 한 tick 에 *한 명만* 골라서 띄움 — 동시에 여러 풍선 뜨면 시각 부담.
|
||||
function _innerThoughtTick(){
|
||||
// Reviewing 중: 검수자 본인은 keyword 던지고, 옮겨와 있는 작성자는 nervous thought.
|
||||
if(_prevStatus === 'reviewing'){
|
||||
const inspectorRole = roleMap['inspector'];
|
||||
if(inspectorRole && chars[inspectorRole]){
|
||||
// 50% 확률로 검수자가 keyword 던지기.
|
||||
if(Math.random() < 0.5){
|
||||
const set = REVIEWER_KEYWORD_BANK[Math.floor(Math.random()*REVIEWER_KEYWORD_BANK.length)];
|
||||
const txt = set.join(' · ');
|
||||
_bubbleFromLog(inspectorRole, txt);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 작성자 측 긴장 표현.
|
||||
for(const r of _movedToReviewer.keys()){
|
||||
if(Math.random() < 0.7){
|
||||
const t = REVIEW_NERVOUS_THOUGHTS[Math.floor(Math.random()*REVIEW_NERVOUS_THOUGHTS.length)];
|
||||
_bubbleFromLog(r, t);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 일반: 작업 중 또는 sit 인 char 중 1명에서 속마음.
|
||||
const candidates = Object.keys(chars).filter(k => {
|
||||
const a = anim[k]; if(!a) return false;
|
||||
return a.mode === 'work' || a.mode === 'sit';
|
||||
});
|
||||
if(candidates.length === 0) return;
|
||||
const working = candidates.filter(k => anim[k].mode === 'work');
|
||||
const pool = working.length > 0 ? working : candidates;
|
||||
const role = pool[Math.floor(Math.random()*pool.length)];
|
||||
const st = stationByKey[role]; if(!st) return;
|
||||
const text = _innerThoughtFor(st.agentKey, anim[role].mode);
|
||||
if(text) _bubbleFromLog(role, text);
|
||||
}
|
||||
setInterval(_innerThoughtTick, 7500);
|
||||
// ── A. 상태 계층화 ──
|
||||
// 모든 phase event에 시퀀스를 큐에 넣으면 캐릭터가 끊임없이 걸어다녀 산만함.
|
||||
// 일반 상태(executing/reviewing/planning/analyzing 등)는 *정적 갱신*만 하고,
|
||||
@@ -588,6 +753,8 @@ function _pct(v){
|
||||
return Math.max(0, Math.min(100, Math.round((typeof v === 'number' ? v : 0) * 100)));
|
||||
}
|
||||
let _prevStatus = null;
|
||||
// 마지막으로 inspector 가 아니었던 active role — reviewing 진입 시 *작성자* 후보.
|
||||
let _lastWriterRole = null;
|
||||
let _lastRenderedLog = null;
|
||||
function apply(s){
|
||||
_lastState = s; // D. 컨텍스트 메뉴 / 세부보기에서 사용.
|
||||
@@ -682,9 +849,29 @@ function apply(s){
|
||||
if(st === 'reviewing') role = roleMap['inspector'] || role;
|
||||
// 활성 outline은 *항상* 즉시 반영 (시뮬레이션 없이도 누가 일하는지 보임).
|
||||
activate(role);
|
||||
// inspector 가 아닌 active role 이 있으면 *작성자 후보* 로 기억 — 다음 reviewing 진입 시 이 사람이 검수자 옆으로 이동.
|
||||
if(role && stationByKey[role] && stationByKey[role].agentKey !== 'inspector'){
|
||||
_lastWriterRole = role;
|
||||
}
|
||||
// 시뮬레이션은 *status 전환 + critical 진입* 에서만.
|
||||
const isPivot = CRITICAL_STATUSES.has(st);
|
||||
const isTransition = st !== _prevStatus;
|
||||
// ── Reviewing 진입 / 종료 시 작성자 이동 ──
|
||||
// status='reviewing' 진입: 직전 작성자(_lastWriterRole) 캐릭터를 검수자 책상 옆으로 walk.
|
||||
// status='reviewing' 종료: 옮겨놓은 작성자들을 모두 원래 자리로 복귀.
|
||||
if(isTransition){
|
||||
if(st === 'reviewing'){
|
||||
const inspectorRole = roleMap['inspector'];
|
||||
if(_lastWriterRole && inspectorRole && _lastWriterRole !== inspectorRole){
|
||||
_walkToReviewer(_lastWriterRole, inspectorRole);
|
||||
}
|
||||
} else if(_prevStatus === 'reviewing'){
|
||||
// 검수 종료 — 옮겨놓은 모든 char 를 원위치.
|
||||
for(const r of Array.from(_movedToReviewer.keys())){
|
||||
_restoreWriterHome(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(isTransition){
|
||||
// 상태가 바뀔 때 작업 마무리한 캐릭터들 자리 정리(work→sit).
|
||||
Object.keys(chars).forEach(k => {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Anthropic Messages API adapter.
|
||||
*
|
||||
* 차이점 (OpenAI 와):
|
||||
* 1. base URL: https://api.anthropic.com/v1
|
||||
* 2. 인증: x-api-key: <key> + anthropic-version: 2023-06-01
|
||||
* 3. system prompt 는 messages 가 아니라 *top-level `system`* 필드
|
||||
* 4. response stream 은 OpenAI 와 다른 event 형식 — streamHelpers.transformAnthropicStream 로 변환
|
||||
*
|
||||
* 향후 확장 여지 (이번 세션은 단순 streaming 만):
|
||||
* - prompt caching (`cache_control: {type: "ephemeral"}`) — 시스템 prompt 비용 90% 절감
|
||||
* - tool use — action tag 와 함께 쓰면 더 깔끔
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { StreamParams } from './types';
|
||||
import { readProviderConfig } from './providerConfig';
|
||||
import { transformAnthropicStream } from './streamHelpers';
|
||||
|
||||
const BASE_URL = 'https://api.anthropic.com/v1';
|
||||
const API_VERSION = '2023-06-01';
|
||||
|
||||
export async function streamAnthropic(
|
||||
context: vscode.ExtensionContext,
|
||||
params: StreamParams,
|
||||
): Promise<Response> {
|
||||
const cfg = await readProviderConfig(context, 'anthropic');
|
||||
if (!cfg.apiKey) {
|
||||
return _errorResponse(401, 'Anthropic API key 가 설정되지 않았습니다. Settings 패널에서 등록해주세요.');
|
||||
}
|
||||
|
||||
// system 메시지 분리 — Anthropic 은 system 을 top-level 로.
|
||||
let systemPrompt = '';
|
||||
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
|
||||
for (const m of params.messages) {
|
||||
if (m.role === 'system') {
|
||||
// 여러 system 메시지가 있으면 합침 (Anthropic 은 한 개의 system 문자열만 받음).
|
||||
systemPrompt += (systemPrompt ? '\n\n' : '') + m.content;
|
||||
} else if (m.role === 'user' || m.role === 'assistant') {
|
||||
// 연속된 같은 role 은 합쳐야 함 — Anthropic 은 role 교대 강제.
|
||||
const last = messages[messages.length - 1];
|
||||
if (last && last.role === m.role) {
|
||||
last.content += '\n\n' + m.content;
|
||||
} else {
|
||||
messages.push({ role: m.role, content: m.content });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Anthropic 은 첫 메시지가 user 여야 함 — assistant 로 시작하면 dummy user 추가.
|
||||
if (messages.length === 0 || messages[0].role !== 'user') {
|
||||
messages.unshift({ role: 'user', content: '(continue)' });
|
||||
}
|
||||
|
||||
const body: any = {
|
||||
model: params.model,
|
||||
messages,
|
||||
max_tokens: Math.max(1, params.maxTokens),
|
||||
temperature: params.temperature,
|
||||
stream: true,
|
||||
};
|
||||
if (systemPrompt) body.system = systemPrompt;
|
||||
|
||||
const upstream = await fetch(`${BASE_URL}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': cfg.apiKey,
|
||||
'anthropic-version': API_VERSION,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: params.signal,
|
||||
});
|
||||
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
// 에러 응답은 그대로 반환 — caller 가 .text() 로 에러 메시지 추출.
|
||||
return upstream;
|
||||
}
|
||||
|
||||
// OpenAI-format SSE 로 transform 후 새 Response 로 wrap.
|
||||
const transformed = transformAnthropicStream(upstream.body);
|
||||
return new Response(transformed, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic 은 모델 list API 가 *없음* (공개) — 대신 *알려진 stable 모델* 의 하드코딩 목록을 반환.
|
||||
* 사용자가 직접 정확한 model id 를 입력해도 동작 (validate 안 함).
|
||||
*/
|
||||
export function listAnthropicModels(): string[] {
|
||||
return [
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3-5-sonnet-20240620',
|
||||
'claude-3-5-haiku-20241022',
|
||||
'claude-3-opus-20240229',
|
||||
'claude-3-sonnet-20240229',
|
||||
'claude-3-haiku-20240307',
|
||||
];
|
||||
}
|
||||
|
||||
function _errorResponse(status: number, msg: string): Response {
|
||||
return new Response(JSON.stringify({ error: { message: msg } }), {
|
||||
status, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Google Gemini Generative Language API adapter.
|
||||
*
|
||||
* 차이점 (OpenAI 와):
|
||||
* 1. base URL: https://generativelanguage.googleapis.com/v1beta
|
||||
* 2. 인증: ?key=<api key> (query parameter)
|
||||
* 3. 메시지 형식: `contents: [{role: 'user'|'model', parts: [{text: '...'}]}]`
|
||||
* - 'system' role 없음. systemInstruction 으로 top-level 분리.
|
||||
* - 'assistant' → 'model' 로 rename.
|
||||
* 4. response stream (alt=sse) 은 OpenAI 와 다른 JSON 구조 — streamHelpers.transformGeminiStream 으로 변환.
|
||||
*
|
||||
* 모델: gemini-2.0-flash-exp / gemini-1.5-pro / gemini-1.5-flash 등.
|
||||
* 1M context window (gemini-1.5-pro), vision, 무료 tier 등 native 기능들.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { StreamParams } from './types';
|
||||
import { readProviderConfig } from './providerConfig';
|
||||
import { transformGeminiStream } from './streamHelpers';
|
||||
|
||||
const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta';
|
||||
|
||||
export async function streamGemini(
|
||||
context: vscode.ExtensionContext,
|
||||
params: StreamParams,
|
||||
): Promise<Response> {
|
||||
const cfg = await readProviderConfig(context, 'gemini');
|
||||
if (!cfg.apiKey) {
|
||||
return _errorResponse(401, 'Gemini API key 가 설정되지 않았습니다. Settings 패널에서 등록해주세요.');
|
||||
}
|
||||
|
||||
// system 분리.
|
||||
let systemPrompt = '';
|
||||
const contents: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }> = [];
|
||||
for (const m of params.messages) {
|
||||
if (m.role === 'system') {
|
||||
systemPrompt += (systemPrompt ? '\n\n' : '') + m.content;
|
||||
} else {
|
||||
const role: 'user' | 'model' = m.role === 'assistant' ? 'model' : 'user';
|
||||
const last = contents[contents.length - 1];
|
||||
if (last && last.role === role) {
|
||||
// 같은 role 연속이면 parts 에 합침 — Gemini 도 role 교대 강제.
|
||||
last.parts.push({ text: m.content });
|
||||
} else {
|
||||
contents.push({ role, parts: [{ text: m.content }] });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (contents.length === 0 || contents[0].role !== 'user') {
|
||||
contents.unshift({ role: 'user', parts: [{ text: '(continue)' }] });
|
||||
}
|
||||
|
||||
const body: any = {
|
||||
contents,
|
||||
generationConfig: {
|
||||
temperature: params.temperature,
|
||||
maxOutputTokens: params.maxTokens,
|
||||
},
|
||||
};
|
||||
if (systemPrompt) body.systemInstruction = { parts: [{ text: systemPrompt }] };
|
||||
|
||||
const url = `${BASE_URL}/models/${encodeURIComponent(params.model)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(cfg.apiKey)}`;
|
||||
const upstream = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: params.signal,
|
||||
});
|
||||
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
return upstream;
|
||||
}
|
||||
|
||||
const transformed = transformGeminiStream(upstream.body);
|
||||
return new Response(transformed, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Gemini 의 사용 가능 모델 list 조회. listModels endpoint 사용. */
|
||||
export async function listGeminiModels(context: vscode.ExtensionContext): Promise<string[]> {
|
||||
const cfg = await readProviderConfig(context, 'gemini');
|
||||
if (!cfg.apiKey) return [];
|
||||
try {
|
||||
const url = `${BASE_URL}/models?key=${encodeURIComponent(cfg.apiKey)}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
||||
if (!res.ok) return [];
|
||||
const json: any = await res.json();
|
||||
const arr = Array.isArray(json?.models) ? json.models : [];
|
||||
// 'models/gemini-2.0-flash-exp' 형식 → 'gemini-2.0-flash-exp' 로 잘라서.
|
||||
return arr
|
||||
.filter((m: any) => Array.isArray(m?.supportedGenerationMethods) && m.supportedGenerationMethods.includes('generateContent'))
|
||||
.map((m: any) => String(m?.name ?? '').replace(/^models\//, ''))
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function _errorResponse(status: number, msg: string): Response {
|
||||
return new Response(JSON.stringify({ error: { message: msg } }), {
|
||||
status, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Cloud LLM provider public API.
|
||||
*
|
||||
* 일반 호출 흐름:
|
||||
* 1. agent.ts 의 chat 진입부에서 `parseModelPrefix(modelId)` 호출
|
||||
* 2. null → local engine 경로 (옛 로직). 객체 → `streamCloudCompletion(context, hit, params)` 호출
|
||||
* 3. Response 의 body 는 *OpenAI 호환 SSE* (transformer 가 변환해둠) → 기존 SSE 파서 그대로 소비
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { ProviderId, StreamParams, ChatMessage, parseModelPrefix, makeModelId, providerLabel } from './types';
|
||||
import { streamOpenRouter, listOpenRouterModels } from './openrouter';
|
||||
import { streamAnthropic, listAnthropicModels } from './anthropic';
|
||||
import { streamGemini, listGeminiModels } from './gemini';
|
||||
import { readProviderConfig, writeProviderConfig, readProviderStatus, ProviderConfig } from './providerConfig';
|
||||
|
||||
export type { ProviderId, ChatMessage, StreamParams, ProviderConfig };
|
||||
export { parseModelPrefix, makeModelId, providerLabel, readProviderConfig, writeProviderConfig, readProviderStatus };
|
||||
|
||||
/**
|
||||
* Provider 별 stream adapter 로 dispatch. 항상 *OpenAI 호환 SSE* body 를 가진 Response 반환.
|
||||
* 에러 (인증 실패 / 4xx / 5xx) 시 .ok=false 인 Response — caller 가 .text() 로 메시지 추출.
|
||||
*/
|
||||
export async function streamCloudCompletion(
|
||||
context: vscode.ExtensionContext,
|
||||
hit: { provider: ProviderId; model: string },
|
||||
params: Omit<StreamParams, 'model'>,
|
||||
): Promise<Response> {
|
||||
const fullParams: StreamParams = { ...params, model: hit.model };
|
||||
switch (hit.provider) {
|
||||
case 'openrouter': return streamOpenRouter(context, fullParams);
|
||||
case 'anthropic': return streamAnthropic(context, fullParams);
|
||||
case 'gemini': return streamGemini(context, fullParams);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 provider 들의 model id 목록을 한 번에 — sidebar 드롭다운에 합칠 때 사용.
|
||||
* 각 모델 id 는 `provider:` prefix 가 붙어 반환되므로 parseModelPrefix 와 1:1 매칭.
|
||||
*/
|
||||
export async function listAllCloudModels(context: vscode.ExtensionContext): Promise<Array<{
|
||||
id: string;
|
||||
provider: ProviderId;
|
||||
label: string;
|
||||
}>> {
|
||||
const out: Array<{ id: string; provider: ProviderId; label: string }> = [];
|
||||
const tasks: Array<Promise<void>> = [];
|
||||
|
||||
const orCfg = await readProviderConfig(context, 'openrouter');
|
||||
if (orCfg.enabled && orCfg.apiKey) {
|
||||
tasks.push(listOpenRouterModels(context).then((ids) => {
|
||||
for (const id of ids) out.push({ id: makeModelId('openrouter', id), provider: 'openrouter', label: `OpenRouter · ${id}` });
|
||||
}));
|
||||
}
|
||||
const anCfg = await readProviderConfig(context, 'anthropic');
|
||||
if (anCfg.enabled && anCfg.apiKey) {
|
||||
for (const id of listAnthropicModels()) {
|
||||
out.push({ id: makeModelId('anthropic', id), provider: 'anthropic', label: `Anthropic · ${id}` });
|
||||
}
|
||||
}
|
||||
const geCfg = await readProviderConfig(context, 'gemini');
|
||||
if (geCfg.enabled && geCfg.apiKey) {
|
||||
tasks.push(listGeminiModels(context).then((ids) => {
|
||||
for (const id of ids) out.push({ id: makeModelId('gemini', id), provider: 'gemini', label: `Gemini · ${id}` });
|
||||
}));
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* OpenRouter — OpenAI 호환 API. 별도 transform 없이 fetch Response 를 그대로 반환.
|
||||
*
|
||||
* Base: https://openrouter.ai/api/v1
|
||||
* POST /chat/completions — OpenAI 형식 그대로. stream:true 면 SSE.
|
||||
* GET /models — 사용 가능 모델 목록.
|
||||
*
|
||||
* 인증: Authorization: Bearer <api key>
|
||||
* 권장 헤더: HTTP-Referer / X-Title — OpenRouter 가 leaderboard / 분석에 사용 (선택).
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { StreamParams } from './types';
|
||||
import { readProviderConfig } from './providerConfig';
|
||||
|
||||
const BASE_URL = 'https://openrouter.ai/api/v1';
|
||||
|
||||
export interface OpenRouterModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
context_length?: number;
|
||||
pricing?: { prompt: string; completion: string };
|
||||
}
|
||||
|
||||
export async function streamOpenRouter(
|
||||
context: vscode.ExtensionContext,
|
||||
params: StreamParams,
|
||||
): Promise<Response> {
|
||||
const cfg = await readProviderConfig(context, 'openrouter');
|
||||
if (!cfg.apiKey) {
|
||||
return _errorResponse(401, 'OpenRouter API key 가 설정되지 않았습니다. Settings 패널에서 등록해주세요.');
|
||||
}
|
||||
const body = {
|
||||
model: params.model,
|
||||
messages: params.messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
stream: true,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
};
|
||||
return fetch(`${BASE_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${cfg.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
'HTTP-Referer': 'https://github.com/g1nation/locallm', // OpenRouter analytics
|
||||
'X-Title': 'Astra',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: params.signal,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listOpenRouterModels(context: vscode.ExtensionContext): Promise<string[]> {
|
||||
const cfg = await readProviderConfig(context, 'openrouter');
|
||||
if (!cfg.apiKey) return [];
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/models`, {
|
||||
headers: { 'Authorization': `Bearer ${cfg.apiKey}` },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const json: any = await res.json();
|
||||
const arr = Array.isArray(json?.data) ? json.data : [];
|
||||
return arr.map((m: any) => String(m?.id ?? '')).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function _errorResponse(status: number, msg: string): Response {
|
||||
return new Response(JSON.stringify({ error: { message: msg } }), {
|
||||
status, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Provider 별 API key + enable 토글 저장소.
|
||||
*
|
||||
* 설계:
|
||||
* - API key 자체는 vscode.SecretStorage (secrets) 에 — settings.json / Settings Sync 침범 안 받음.
|
||||
* - enabled 토글은 일반 settings (g1nation.providers.<id>.enabled) — 사용자가 패널에서 토글 시 read/write.
|
||||
*
|
||||
* Secret 저장 key 네이밍: `g1nation.providers.<id>.apiKey`. 동일 인터페이스로 향후 provider 추가.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { ProviderId } from './types';
|
||||
|
||||
export interface ProviderConfig {
|
||||
enabled: boolean;
|
||||
apiKey: string;
|
||||
/** Provider-specific default model (없으면 사용자가 매번 선택). */
|
||||
defaultModel: string;
|
||||
}
|
||||
|
||||
function _secretKey(id: ProviderId): string {
|
||||
return `g1nation.providers.${id}.apiKey`;
|
||||
}
|
||||
|
||||
function _settingsScope(id: ProviderId): vscode.WorkspaceConfiguration {
|
||||
return vscode.workspace.getConfiguration(`g1nation.providers.${id}`);
|
||||
}
|
||||
|
||||
export async function readProviderConfig(context: vscode.ExtensionContext, id: ProviderId): Promise<ProviderConfig> {
|
||||
const s = _settingsScope(id);
|
||||
const apiKey = (await context.secrets.get(_secretKey(id))) ?? '';
|
||||
return {
|
||||
enabled: !!s.get<boolean>('enabled', false),
|
||||
apiKey,
|
||||
defaultModel: s.get<string>('defaultModel', '') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* patch 의 필드만 갱신.
|
||||
* - enabled / defaultModel → settings (Global target)
|
||||
* - apiKey → secrets (set if truthy, delete if empty string)
|
||||
*/
|
||||
export async function writeProviderConfig(
|
||||
context: vscode.ExtensionContext,
|
||||
id: ProviderId,
|
||||
patch: Partial<ProviderConfig>,
|
||||
): Promise<void> {
|
||||
const s = _settingsScope(id);
|
||||
if (typeof patch.enabled === 'boolean') {
|
||||
await s.update('enabled', patch.enabled, vscode.ConfigurationTarget.Global);
|
||||
}
|
||||
if (typeof patch.defaultModel === 'string') {
|
||||
await s.update('defaultModel', patch.defaultModel || undefined, vscode.ConfigurationTarget.Global);
|
||||
}
|
||||
if (typeof patch.apiKey === 'string') {
|
||||
const key = patch.apiKey.trim();
|
||||
if (key) {
|
||||
await context.secrets.store(_secretKey(id), key);
|
||||
} else {
|
||||
await context.secrets.delete(_secretKey(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** SettingsPanel 표시용 — secret 값 자체는 노출 X. 설정 여부만 boolean. */
|
||||
export async function readProviderStatus(context: vscode.ExtensionContext, id: ProviderId): Promise<{
|
||||
enabled: boolean;
|
||||
hasApiKey: boolean;
|
||||
defaultModel: string;
|
||||
}> {
|
||||
const cfg = await readProviderConfig(context, id);
|
||||
return {
|
||||
enabled: cfg.enabled,
|
||||
hasApiKey: !!cfg.apiKey,
|
||||
defaultModel: cfg.defaultModel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Stream transformer — provider 별 SSE 형식을 *OpenAI 호환* SSE 로 변환.
|
||||
*
|
||||
* 이렇게 하면 agent.ts 의 기존 SSE 파서 (`data: {...delta...}` 형식 가정) 가 변경 없이
|
||||
* 모든 provider 출력을 같은 코드 경로로 소비할 수 있다. 신규 provider 가 들어와도
|
||||
* adapter 하나만 추가하면 됨 (consumer 무손상).
|
||||
*
|
||||
* 변환 후 출력 형식 (OpenAI Chat Completions stream):
|
||||
* data: {"choices":[{"delta":{"content":"안녕"},"index":0}]}\n\n
|
||||
* data: {"choices":[{"delta":{"content":" 반가"},"index":0}]}\n\n
|
||||
* data: [DONE]\n\n
|
||||
*/
|
||||
|
||||
/**
|
||||
* Anthropic Messages API streaming → OpenAI SSE.
|
||||
*
|
||||
* Anthropic 이벤트 형식 (SSE):
|
||||
* event: message_start\ndata: {...}
|
||||
* event: content_block_start\ndata: {...}
|
||||
* event: content_block_delta\ndata: {"delta":{"type":"text_delta","text":"안녕"}}
|
||||
* event: content_block_stop\ndata: {...}
|
||||
* event: message_delta\ndata: {"usage": {...}}
|
||||
* event: message_stop\ndata: {...}
|
||||
*
|
||||
* 변환:
|
||||
* - content_block_delta 의 text → OpenAI delta.content 한 chunk
|
||||
* - message_stop → [DONE]
|
||||
* - 그 외 이벤트 무시 (tool_use 등은 향후)
|
||||
*/
|
||||
export function transformAnthropicStream(input: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
||||
return _transformSse(input, _anthropicEventToOpenAI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini streamGenerateContent (alt=sse) → OpenAI SSE.
|
||||
*
|
||||
* Gemini SSE chunk 형식:
|
||||
* data: {"candidates":[{"content":{"parts":[{"text":"안녕"}],"role":"model"}}]}
|
||||
* data: {"candidates":[{"content":{"parts":[{"text":" 반가"}],"role":"model"}}]}
|
||||
* ... (stream 끝나면 connection close — 별도 [DONE] 없음)
|
||||
*
|
||||
* 변환:
|
||||
* - candidates[0].content.parts[*].text → OpenAI delta.content 로 합쳐 emit
|
||||
* - 스트림 종료 후 [DONE] 마커 1회 emit (consumer 호환)
|
||||
*/
|
||||
export function transformGeminiStream(input: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
||||
return _transformSse(input, _geminiEventToOpenAI);
|
||||
}
|
||||
|
||||
// ────────────── 내부 헬퍼 ──────────────
|
||||
|
||||
type EventToOpenAI = (eventName: string | null, dataLine: string) => string | null;
|
||||
|
||||
/**
|
||||
* 공통 SSE 파서 + 변환기. line 단위로 input stream 을 읽어
|
||||
* `event: X` + `data: {...}` 블록 단위로 모아서 변환기에 전달.
|
||||
* 변환기가 null 이 아닌 OpenAI-format SSE 문자열을 돌려주면 output 으로 emit.
|
||||
* 스트림 종료 시 'data: [DONE]' 자동 추가.
|
||||
*/
|
||||
function _transformSse(
|
||||
input: ReadableStream<Uint8Array>,
|
||||
convert: EventToOpenAI,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let buffer = '';
|
||||
let pendingEvent: string | null = null;
|
||||
const reader = input.getReader();
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
// 종료 시 [DONE] 마커.
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let idx: number;
|
||||
while ((idx = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, idx).replace(/\r$/, '');
|
||||
buffer = buffer.slice(idx + 1);
|
||||
if (line === '') {
|
||||
// blank line 은 event 종료 — 아무것도 안 함 (data 누적 X — 본 구현은 1줄 1이벤트 가정)
|
||||
pendingEvent = null;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(':')) continue; // comment
|
||||
if (line.startsWith('event:')) {
|
||||
pendingEvent = line.slice(6).trim();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
const dataLine = line.slice(5).trim();
|
||||
if (!dataLine) continue;
|
||||
const out = convert(pendingEvent, dataLine);
|
||||
if (out) controller.enqueue(encoder.encode(out));
|
||||
// pendingEvent 는 blank line 이 와야 reset — Anthropic 처럼 한 event 에 여러 data 가능
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
controller.error(e);
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
reader.cancel().catch(() => { /* ignore */ });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function _anthropicEventToOpenAI(eventName: string | null, dataLine: string): string | null {
|
||||
if (eventName !== 'content_block_delta') return null;
|
||||
try {
|
||||
const obj = JSON.parse(dataLine);
|
||||
const text = obj?.delta?.text;
|
||||
if (typeof text !== 'string' || !text) return null;
|
||||
const openai = { choices: [{ delta: { content: text }, index: 0 }] };
|
||||
return `data: ${JSON.stringify(openai)}\n\n`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function _geminiEventToOpenAI(_eventName: string | null, dataLine: string): string | null {
|
||||
try {
|
||||
const obj = JSON.parse(dataLine);
|
||||
const parts = obj?.candidates?.[0]?.content?.parts;
|
||||
if (!Array.isArray(parts)) return null;
|
||||
const text = parts.map((p: any) => (typeof p?.text === 'string' ? p.text : '')).join('');
|
||||
if (!text) return null;
|
||||
const openai = { choices: [{ delta: { content: text }, index: 0 }] };
|
||||
return `data: ${JSON.stringify(openai)}\n\n`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 단위테스트용 — 변환 helper 들을 그대로 노출.
|
||||
export const _internals = {
|
||||
anthropicEventToOpenAI: _anthropicEventToOpenAI,
|
||||
geminiEventToOpenAI: _geminiEventToOpenAI,
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Cloud LLM provider routing — model id prefix → provider id 매핑.
|
||||
*
|
||||
* Prefix 규칙:
|
||||
* openrouter:anthropic/claude-3.5-sonnet → { provider: 'openrouter', model: 'anthropic/claude-3.5-sonnet' }
|
||||
* anthropic:claude-3-5-sonnet-20241022 → { provider: 'anthropic', model: 'claude-3-5-sonnet-20241022' }
|
||||
* gemini:gemini-2.0-flash-exp → { provider: 'gemini', model: 'gemini-2.0-flash-exp' }
|
||||
* gemma4:e2b → null (local: Ollama / LM Studio 가 처리)
|
||||
*
|
||||
* 왜 prefix 인가:
|
||||
* - 같은 model name 이 OpenRouter / Anthropic 직통에 동시 존재 가능 — 출처를 명시해야 라우팅 모호성 없음.
|
||||
* - sidebar 의 모델 dropdown 에 표시 시 그룹화 ('OpenRouter · ...' 등) 가능.
|
||||
* - 옛 사용자 model 설정 ('gemma4:e2b') 은 prefix 없으니 자동으로 local 경로.
|
||||
*/
|
||||
|
||||
export type ProviderId = 'openrouter' | 'anthropic' | 'gemini';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface StreamParams {
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const PROVIDER_PREFIXES: ReadonlyArray<{ prefix: string; id: ProviderId }> = [
|
||||
{ prefix: 'openrouter:', id: 'openrouter' },
|
||||
{ prefix: 'anthropic:', id: 'anthropic' },
|
||||
{ prefix: 'gemini:', id: 'gemini' },
|
||||
];
|
||||
|
||||
/**
|
||||
* model id 의 prefix 를 분석해서 provider 와 실제 모델명 분리.
|
||||
* prefix 매칭 안 되면 null — 호출자는 local engine 경로로.
|
||||
*/
|
||||
export function parseModelPrefix(modelId: string): { provider: ProviderId; model: string } | null {
|
||||
if (!modelId) return null;
|
||||
for (const { prefix, id } of PROVIDER_PREFIXES) {
|
||||
if (modelId.startsWith(prefix)) {
|
||||
return { provider: id, model: modelId.slice(prefix.length) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 역변환 — provider + 실제 model → prefixed id. UI / config 저장용. */
|
||||
export function makeModelId(provider: ProviderId, model: string): string {
|
||||
return `${provider}:${model}`;
|
||||
}
|
||||
|
||||
/** UI 표시용 짧은 라벨. */
|
||||
export function providerLabel(p: ProviderId): string {
|
||||
switch (p) {
|
||||
case 'openrouter': return 'OpenRouter';
|
||||
case 'anthropic': return 'Anthropic';
|
||||
case 'gemini': return 'Gemini';
|
||||
}
|
||||
}
|
||||
@@ -3883,6 +3883,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
}
|
||||
}
|
||||
|
||||
// Cloud provider 모델 합치기 — 활성화된 provider 의 모델들이 dropdown 끝에 추가됨.
|
||||
// 각 모델 id 는 'openrouter:...' 등 prefix 포함이라 사용자가 선택 시 agent 가 cloud 로 라우팅.
|
||||
try {
|
||||
const { listAllCloudModels } = await import('./features/providers');
|
||||
const cloudModels = await listAllCloudModels(this._context);
|
||||
for (const m of cloudModels) {
|
||||
if (!models.includes(m.id)) models.push(m.id);
|
||||
}
|
||||
} catch (e) {
|
||||
logInfo('Cloud model list failed (non-fatal).', { error: (e as any)?.message ?? String(e) });
|
||||
}
|
||||
|
||||
this._view.webview.postMessage({ type: 'modelsList', value: { models, selected: defaultModel, loadedModels } });
|
||||
} catch (err) {
|
||||
logError('Model list update failed.', err);
|
||||
|
||||
Reference in New Issue
Block a user