diff --git a/docs/configuration.md b/docs/configuration.md index eedf611..67b1647 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -147,11 +147,11 @@ email: enabled: true maxConnections: 5 maxMessages: 100 - contact: - to: "Support " + contact: + to: "Support " -# MinIO/S3 configuration (optional) -minio: + # MinIO/S3 configuration (optional) + minio: endpoint: "localhost" port: 9000 useSsl: false @@ -161,14 +161,10 @@ minio: uploads: "gadget-uploads" images: "gadget-images" videos: "gadget-videos" - audios: "gadget-audios" + audios: "gadget-audios" + ``` -# User settings -user: - signupEnabled: false -``` - -## gadget-drone.yaml Reference + ## gadget-drone.yaml Reference ```yaml # Basic settings diff --git a/docs/reasoning-effort.md b/docs/reasoning-effort.md new file mode 100644 index 0000000..98b4881 --- /dev/null +++ b/docs/reasoning-effort.md @@ -0,0 +1,171 @@ +# Reasoning Effort + +**Status:** ✅ **IMPLEMENTED** +**Last Updated:** May 8, 2026 + +## Overview + +Reasoning effort controls how much an AI model "thinks" before responding. Models with reasoning capabilities (like DeepSeek-R1, QwQ, OpenAI o1/o3) can produce internal chain-of-thought tokens before generating their final answer. The reasoning effort setting lets users balance between speed and thoroughness. + +## User Setting + +The reasoning effort is configured per chat session via a dropdown in the Session sidebar: + +| Value | Effect | +|----------|-----------------------------------------------------| +| **Off** | No thinking output. Model responds immediately. | +| **Low** | Minimal thinking. Faster responses, less depth. | +| **Medium** | Balanced thinking. Default reasoning depth. | +| **High** | Maximum thinking. Slower but more thorough. | + +The dropdown is **disabled** when the selected model does not have `hasThinking: true` in its capabilities. + +## Data Flow + +``` +User selects "High" in Reasoning dropdown + → PUT /api/v1/chat-sessions/:id { reasoningEffort: "high" } + → Stored in MongoDB ChatSession.reasoningEffort + → When creating a turn: + ChatTurn.reasoningEffort = ChatSession.reasoningEffort (snapshotted) + → Drone receives work order with populated turn + → agent.ts reads turn.reasoningEffort, maps "off" → false + → Passes to AiService.chat() as params.reasoning + → Provider SDK receives the appropriate parameter +``` + +## Provider Mapping + +Each AI provider uses a different parameter name for reasoning effort. The `@gadget/ai` abstraction handles the translation: + +| Provider | Parameter | Values | +|----------|------------------|---------------------------------| +| Ollama | `think` | `false`, `"low"`, `"medium"`, `"high"` | +| OpenAI | `reasoning_effort` | `"low"`, `"medium"`, `"high"` | + +### Mapping Logic (in `gadget-drone/src/services/agent.ts`) + +```typescript +const reasoningEffort = turn.reasoningEffort || "off"; +const reasoning: boolean | "low" | "medium" | "high" = + reasoningEffort === "off" ? false : reasoningEffort; +``` + +- `"off"` → `false` (disables thinking entirely) +- `"low"` → `"low"` (minimal thinking) +- `"medium"` → `"medium"` (balanced) +- `"high"` → `"high"` (maximum thinking) + +### Ollama Implementation (`packages/ai/src/ollama.ts`) + +```typescript +const response = await this.client.chat({ + model: model.modelId, + messages, + stream: true, + think: model.params.reasoning, // boolean | "low" | "medium" | "high" +}); +``` + +When `think` is `false`, the Ollama SDK disables thinking. When set to a string level, the model allocates corresponding effort. + +### OpenAI Implementation (`packages/ai/src/openai.ts`) + +```typescript +const response = await this.client.chat.completions.create({ + model: model.modelId, + messages, + tools, + stream: true, + ...(typeof model.params.reasoning === "string" + ? { reasoning_effort: model.params.reasoning } + : {}), +}); +``` + +The `reasoning_effort` parameter is only passed when the value is a string (`"low"`, `"medium"`, `"high"`). When `false`, the parameter is omitted — standard non-reasoning models would reject it. + +## Streaming Thinking Content + +When reasoning effort is enabled and the model produces thinking tokens, they are streamed back in real-time: + +1. **Provider SDK** emits thinking tokens in stream chunks +2. **Provider implementation** (`ollama.ts` / `openai.ts`) maps them to `IAiStreamChunk` with `type: 'thinking'` +3. **Drone** forwards via Socket.IO as `thinking(content)` events +4. **Frontend** renders thinking content in distinct muted blocks + +### Thinking Chunk Handling + +**Ollama:** +```typescript +if (chunk.message.thinking) { + await streamCallback({ + type: 'thinking', + data: chunk.message.thinking, + }); +} +``` + +**OpenAI:** +```typescript +if ('reasoning' in delta && delta.reasoning) { + await streamCallback({ + type: 'thinking', + data: delta.reasoning as string, + }); +} +``` + +## Type Definitions + +### `ReasoningEffort` (in `packages/api/src/interfaces/chat-session.ts`) + +```typescript +export type ReasoningEffort = "off" | "low" | "medium" | "high"; +``` + +### `IAiModelConfig.params.reasoning` (in `packages/ai/src/api.ts`) + +```typescript +params: { + reasoning: boolean | "high" | "medium" | "low"; + // ... +} +``` + +Note: The `IAiModelConfig` type uses `boolean | "high" | "medium" | "low"` (no `"off"`). The `"off"` value from the user-facing setting is mapped to `false` before reaching the AI provider layer. + +### Mongoose Schema + +**ChatSession** (`gadget-code/src/models/chat-session.ts`): +```typescript +reasoningEffort: { + type: String, + enum: ["off", "low", "medium", "high"], + default: "off", +} +``` + +**ChatTurn** (`gadget-code/src/models/chat-turn.ts`): +```typescript +reasoningEffort: { + type: String, + enum: ["off", "low", "medium", "high"], + default: "off", +} +``` + +## Model Capability Detection + +The `hasThinking` capability is detected during model probing: + +- **Ollama**: checks if model capabilities array includes `"reasoning"` +- **OpenAI**: checks if model features include `"reasoning_effort"` or fallback detection by model ID (`o1`, `o3`, `reasoning`) + +The frontend uses this capability flag to enable/disable the Reasoning dropdown. + +## Related Documentation + +- [Streaming Responses](./streaming-responses.md) — How thinking tokens are streamed to the IDE +- [Socket Protocol](./socket-protocol.md) — Socket.IO event definitions +- [Architecture](./architecture.md) — Overall system architecture diff --git a/docs/streaming-responses.md b/docs/streaming-responses.md index b1bf9d9..fb218d6 100644 --- a/docs/streaming-responses.md +++ b/docs/streaming-responses.md @@ -426,6 +426,15 @@ marked.setOptions({ - Thinking and response mixed in same block (mode detection broken) - Tool calls not appearing (tool call events not routed) +## Reasoning Effort + +The reasoning effort setting controls how much a model thinks before responding. See [Reasoning Effort](./reasoning-effort.md) for full documentation. + +Key integration points with streaming: +- When reasoning effort is **Off** (`false`), no thinking tokens are produced +- When set to **Low/Medium/High**, the model allocates corresponding thinking depth +- Thinking tokens stream through the same path as response tokens but with `type: 'thinking'` + ## Future Enhancements Potential improvements not yet implemented: @@ -477,3 +486,4 @@ Potential improvements not yet implemented: - [Socket Protocol](./socket-protocol.md) — Socket.IO event definitions - [ChatTurn Interface](../packages/api/src/interfaces/chat-turn.ts) — TypeScript types - [ChatTurn Model](../gadget-code/src/models/chat-turn.ts) — Mongoose schema +- [Reasoning Effort](./reasoning-effort.md) — How thinking/reasoning is controlled diff --git a/gadget-code/README.md b/gadget-code/README.md index 32f6601..f3d5ff7 100644 --- a/gadget-code/README.md +++ b/gadget-code/README.md @@ -112,18 +112,15 @@ DTP_HTTPS_PORT="3443" DTP_HTTPS_KEY_FILE="/path/to/ssl/key.pem" DTP_HTTPS_CRT_FILE="/path/to/ssl/cert.pem" -# Session -DTP_SESSION_TRUST_PROXY="enabled" -DTP_SESSION_COOKIE_SECURE="enabled" -DTP_SESSION_COOKIE_SAMESITE="strict" - -# User Signup -DTP_USER_SIGNUP="enabled" -``` + # Session + DTP_SESSION_TRUST_PROXY="enabled" + DTP_SESSION_COOKIE_SECURE="enabled" + DTP_SESSION_COOKIE_SAMESITE="strict" + ``` ## Features -- User authentication (sign up, sign in, sign out) + - User authentication (sign in, sign out) - JWT-based session management - RESTful API structure - Socket.io real-time communication @@ -153,12 +150,11 @@ DTP_USER_SIGNUP="enabled" ## API Endpoints -| Method | Endpoint | Description | -| ------ | ---------------- | ------------------ | -| POST | `/auth/sign-up` | Create new account | -| POST | `/auth/sign-in` | Authenticate | -| GET | `/auth/sign-out` | Sign out | -| GET | `/api/v1/user` | Get current user | + | Method | Endpoint | Description | + | ------ | ---------------- | ------------------ | + | POST | `/auth/sign-in` | Authenticate | + | GET | `/auth/sign-out` | Sign out | + | GET | `/api/v1/user` | Get current user | ## License diff --git a/gadget-code/frontend/src/App.tsx b/gadget-code/frontend/src/App.tsx index 9b94fcf..0c361c1 100644 --- a/gadget-code/frontend/src/App.tsx +++ b/gadget-code/frontend/src/App.tsx @@ -7,7 +7,6 @@ import StatusBar from './components/StatusBar'; import Home from './pages/Home'; import ProjectManager from './pages/ProjectManager'; import SignIn from './pages/SignIn'; -import SignUp from './pages/SignUp'; import ChatSessionView from './pages/ChatSessionView'; import DroneManager from './pages/DroneManager'; @@ -124,27 +123,17 @@ export default function App() { } /> } /> } /> - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - + + ) : ( + + ) + } + /> + diff --git a/gadget-code/frontend/src/components/Navbar.tsx b/gadget-code/frontend/src/components/Navbar.tsx deleted file mode 100644 index ed2707c..0000000 --- a/gadget-code/frontend/src/components/Navbar.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Link } from 'react-router-dom'; - -interface User { - _id: string; - email: string; - displayName: string; -} - -interface NavbarProps { - user: User | null; - onSignOut: () => void; -} - -export default function Navbar({ user, onSignOut }: NavbarProps) { - return ( - - ); -} \ No newline at end of file diff --git a/gadget-code/frontend/src/lib/api.ts b/gadget-code/frontend/src/lib/api.ts index 86a465a..af34d2a 100644 --- a/gadget-code/frontend/src/lib/api.ts +++ b/gadget-code/frontend/src/lib/api.ts @@ -280,6 +280,7 @@ export interface ChatSession { mode: ChatSessionMode; provider: string | AiProvider; selectedModel: string; + reasoningEffort?: string; stats: ChatSessionStats; pins: Array<{ _id?: string; content: string }>; } diff --git a/gadget-code/frontend/src/pages/ChatSessionView.tsx b/gadget-code/frontend/src/pages/ChatSessionView.tsx index 493b1e5..b2ce064 100644 --- a/gadget-code/frontend/src/pages/ChatSessionView.tsx +++ b/gadget-code/frontend/src/pages/ChatSessionView.tsx @@ -40,12 +40,14 @@ export default function ChatSessionView() { const [isUpdatingMode, setIsUpdatingMode] = useState(false); const [isUpdatingProvider, setIsUpdatingProvider] = useState(false); const [isUpdatingModel, setIsUpdatingModel] = useState(false); + const [isUpdatingReasoning, setIsUpdatingReasoning] = useState(false); const [logExpanded, setLogExpanded] = useState(false); const [logs, setLogs] = useState([]); const [toast, setToast] = useState(null); const [providers, setProviders] = useState([]); const [selectedProviderId, setSelectedProviderId] = useState(''); const [selectedModelId, setSelectedModelId] = useState(''); + const [sessionReasoningEffort, setSessionReasoningEffort] = useState('off'); const messagesEndRef = useRef(null); const inputRef = useRef(null); @@ -104,6 +106,7 @@ export default function ChatSessionView() { : sessionData.provider?._id; setSelectedProviderId(providerId || ''); setSelectedModelId(sessionData.selectedModel || ''); + setSessionReasoningEffort(sessionData.reasoningEffort || 'off'); } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load session'); @@ -455,6 +458,34 @@ export default function ChatSessionView() { } }; + const getSelectedModelCapabilities = useCallback(() => { + const provider = providers.find(p => p._id === selectedProviderId); + if (!provider) return null; + const model = provider.models.find(m => m.id === selectedModelId); + return model?.capabilities || null; + }, [providers, selectedProviderId, selectedModelId]); + + const handleReasoningChange = async (e: React.ChangeEvent) => { + if (!session) return; + + const newValue = e.target.value; + if (newValue === sessionReasoningEffort) return; + + setIsUpdatingReasoning(true); + try { + const updatedSession = await chatSessionApi.update(session._id, { + reasoningEffort: newValue + }); + setSession(updatedSession); + setSessionReasoningEffort(newValue); + showToast(`Reasoning effort set to ${newValue}`); + } catch (err) { + showToast(`Failed to change reasoning effort: ${err instanceof Error ? err.message : 'Unknown error'}`); + } finally { + setIsUpdatingReasoning(false); + } + }; + const handleModelChange = async (e: React.ChangeEvent) => { if (!session) return; @@ -731,6 +762,25 @@ export default function ChatSessionView() { ))} +
+
Reasoning
+ +
diff --git a/gadget-code/frontend/src/pages/SignIn.tsx b/gadget-code/frontend/src/pages/SignIn.tsx index 3f2b0de..6fa2684 100644 --- a/gadget-code/frontend/src/pages/SignIn.tsx +++ b/gadget-code/frontend/src/pages/SignIn.tsx @@ -77,14 +77,8 @@ export default function SignIn({ onSuccess }: SignInProps) { {loading ? 'Signing in...' : 'Sign In'} - -

- Don't have an account?{' '} - - Sign up - -

- - - ); -} \ No newline at end of file + + + + ); + } \ No newline at end of file diff --git a/gadget-code/frontend/src/pages/SignUp.tsx b/gadget-code/frontend/src/pages/SignUp.tsx deleted file mode 100644 index fa6284e..0000000 --- a/gadget-code/frontend/src/pages/SignUp.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useState } from 'react'; -import { Link } from 'react-router-dom'; -import { api, AuthResponse, User } from '../lib/api'; - -interface SignUpProps { - onSuccess: (user: User, token: string) => void; -} - -export default function SignUp({ onSuccess }: SignUpProps) { - const [email, setEmail] = useState(''); - const [displayName, setDisplayName] = useState(''); - const [password, setPassword] = useState(''); - const [passwordVerify, setPasswordVerify] = useState(''); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - - if (password !== passwordVerify) { - setError('Passwords do not match'); - return; - } - - setLoading(true); - try { - const response = await api.post('/auth/sign-up', { - email, - password, - displayName, - }); - onSuccess(response.user, response.token); - } catch (err) { - setError(err instanceof Error ? err.message : 'Sign up failed'); - } finally { - setLoading(false); - } - }; - - return ( -
-
-

Create Account

- {error && ( -
- {error} -
- )} -
-
- - setEmail(e.target.value)} - className="w-full px-4 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-brand" - required - /> -
-
- - setDisplayName(e.target.value)} - className="w-full px-4 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-brand" - required - /> -
-
- - setPassword(e.target.value)} - className="w-full px-4 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-brand" - required - /> -
-
- - setPasswordVerify(e.target.value)} - className="w-full px-4 py-2 bg-bg-tertiary border border-border-default rounded-lg text-text-primary focus:outline-none focus:border-brand" - required - /> -
-
- - Cancel - - -
-
-
-
- ); -} \ No newline at end of file diff --git a/gadget-code/frontend/vite.config.ts b/gadget-code/frontend/vite.config.ts index c12ddab..285c81e 100644 --- a/gadget-code/frontend/vite.config.ts +++ b/gadget-code/frontend/vite.config.ts @@ -47,6 +47,21 @@ export default defineConfig({ build: { outDir: path.join(rootDir, 'dist', 'client'), emptyOutDir: true, + rolldownOptions: { + output: { + codeSplitting: { + minSize: 20000, + groups: [ + { + name: 'vendor', + test: /[\\/]node_modules[\\/]/, + priority: 10, + maxSize: 250000, + }, + ], + }, + }, + }, }, resolve: { alias: { diff --git a/gadget-code/src/config/env.ts b/gadget-code/src/config/env.ts index d0f9cb1..9ba4989 100644 --- a/gadget-code/src/config/env.ts +++ b/gadget-code/src/config/env.ts @@ -124,10 +124,9 @@ export default { audios: yamlConfig.minio?.buckets?.audios || "dtp-audios", }, }, - user: { - signupEnabled: yamlConfig.user?.signupEnabled === true, - passwordSalt: yamlConfig.auth.passwordSalt, - }, + user: { + passwordSalt: yamlConfig.auth.passwordSalt, + }, https: { enabled: yamlConfig.https?.enabled === true, address: yamlConfig.https?.address || "127.0.0.1", diff --git a/gadget-code/src/controllers/api/v1/chat-session.ts b/gadget-code/src/controllers/api/v1/chat-session.ts index 51e7a3a..949b037 100644 --- a/gadget-code/src/controllers/api/v1/chat-session.ts +++ b/gadget-code/src/controllers/api/v1/chat-session.ts @@ -178,6 +178,7 @@ class ChatSessionController extends DtpController { provider: string; selectedModel: string; mode: ChatSessionMode; + reasoningEffort: "off" | "low" | "medium" | "high"; }> = {}; if (updates.name !== undefined) { @@ -196,6 +197,13 @@ class ChatSessionController extends DtpController { throw new Error(`Invalid mode: ${updates.mode}`); } } + if (updates.reasoningEffort !== undefined) { + const validValues = ["off", "low", "medium", "high"] as const; + if (!validValues.includes(updates.reasoningEffort)) { + throw new Error(`Invalid reasoningEffort: ${updates.reasoningEffort}. Must be one of: ${validValues.join(", ")}`); + } + allowedUpdates.reasoningEffort = updates.reasoningEffort; + } const session = await ChatSessionService.update( res.locals.chatSession._id, diff --git a/gadget-code/src/controllers/auth.ts b/gadget-code/src/controllers/auth.ts index 97c3ac6..7a06205 100644 --- a/gadget-code/src/controllers/auth.ts +++ b/gadget-code/src/controllers/auth.ts @@ -26,60 +26,14 @@ export class AuthController extends DtpController { } async start(): Promise { - this.router.post("/sign-up", this.postSignUp.bind(this)); this.router.post("/sign-in", this.postSignIn.bind(this)); this.router.post("/renew-token", this.postRenewToken.bind(this)); this.router.get("/welcome", this.getWelcomeView.bind(this)); - this.router.get("/sign-up", this.getSignUpForm.bind(this)); this.router.get("/sign-in", this.getSignInForm.bind(this)); this.router.get("/sign-out", this.getSignOut.bind(this)); } - async postSignUp( - req: Request, - res: Response, - next: NextFunction - ): Promise { - try { - const user = await UserService.create( - req.body.email, - req.body.password, - req.body.displayName - ); - - req.session.user = { - _id: user._id, - email: user.email, - displayName: user.displayName, - flags: user.flags, - }; - - const token = await SessionService.createJsonWebToken(user); - req.session.token = token; - req.session.type = SessionType.WEB; - - req.session.save((err: Error) => { - if (err) { - return next(err); - } - res.status(201).json({ - success: true, - user: { - _id: user._id.toString(), - email: user.email, - displayName: user.displayName, - flags: user.flags, - }, - token, - }); - }); - } catch (error) { - this.log.error("failed to process new user sign-up", { error }); - return next(error); - } - } - async postSignIn( req: Request, res: Response, @@ -143,12 +97,6 @@ export class AuthController extends DtpController { }); } - async getSignUpForm(_req: Request, res: Response): Promise { - res.status(200).json({ - success: true, - form: "sign-up", - }); - } async getSignInForm(_req: Request, res: Response): Promise { res.status(200).json({ success: true, diff --git a/gadget-code/src/lib/controller.ts b/gadget-code/src/lib/controller.ts index 9bfc51b..5ac8a59 100644 --- a/gadget-code/src/lib/controller.ts +++ b/gadget-code/src/lib/controller.ts @@ -68,12 +68,11 @@ export abstract class DtpController implements DtpComponent { * @param res Response The response being generated. * @param next NextFunction The next function to call when done. */ - middleware(req: Request, res: Response, next: NextFunction) { - res.locals.request = req; - res.locals.currentView = this.slug; - res.locals.signupEnabled = env.user.signupEnabled; - next(); - } + middleware(req: Request, res: Response, next: NextFunction) { + res.locals.request = req; + res.locals.currentView = this.slug; + next(); + } hmacMiddleware() { return async (req: Request, res: Response, next: NextFunction) => { diff --git a/gadget-code/src/models/chat-session.ts b/gadget-code/src/models/chat-session.ts index 81016cf..81a126a 100644 --- a/gadget-code/src/models/chat-session.ts +++ b/gadget-code/src/models/chat-session.ts @@ -25,6 +25,11 @@ export const ChatSessionSchema = new Schema({ }, provider: { type: String, required: true, ref: "AiProvider" }, selectedModel: { type: String, required: true }, + reasoningEffort: { + type: String, + enum: ["off", "low", "medium", "high"], + default: "off", + }, stats: { turnCount: { type: Number, default: 0, required: true }, toolCallCount: { type: Number, default: 0, required: true }, diff --git a/gadget-code/src/models/chat-turn.ts b/gadget-code/src/models/chat-turn.ts index bb6c91a..dc83a29 100644 --- a/gadget-code/src/models/chat-turn.ts +++ b/gadget-code/src/models/chat-turn.ts @@ -63,6 +63,11 @@ export const ChatTurnSchema = new Schema({ session: { type: String, required: true, ref: "ChatSession" }, provider: { type: String, required: true, ref: "AiProvider" }, llm: { type: String, required: true }, // id/name of the model used to process the prompt + reasoningEffort: { + type: String, + enum: ["off", "low", "medium", "high"], + default: "off", + }, mode: { type: String, enum: ChatSessionMode, diff --git a/gadget-code/src/services/chat-session.ts b/gadget-code/src/services/chat-session.ts index bc87e2f..584aef3 100644 --- a/gadget-code/src/services/chat-session.ts +++ b/gadget-code/src/services/chat-session.ts @@ -15,6 +15,7 @@ import { IProject, ChatTurnStatus, ChatTurnDocument, + ReasoningEffort, } from "@gadget/api"; import { DtpService } from "../lib/service.js"; @@ -178,6 +179,7 @@ class ChatSessionService extends DtpService { provider: GadgetId; selectedModel: string; mode: ChatSessionMode; + reasoningEffort: ReasoningEffort; }>, ): Promise { const session = await ChatSession.findById(chatSessionId); @@ -203,6 +205,9 @@ class ChatSessionService extends DtpService { if (updates.mode !== undefined) { session.mode = updates.mode; } + if (updates.reasoningEffort !== undefined) { + session.reasoningEffort = updates.reasoningEffort; + } await session.save(); @@ -248,6 +253,7 @@ class ChatSessionService extends DtpService { session: session._id, provider: session.provider, llm: session.selectedModel, + reasoningEffort: (session as IChatSession).reasoningEffort || "off", mode: session.mode, status: ChatTurnStatus.Processing, prompts: { diff --git a/gadget-code/tests/app.test.ts b/gadget-code/tests/app.test.ts index 4fdb08d..50ef9b8 100644 --- a/gadget-code/tests/app.test.ts +++ b/gadget-code/tests/app.test.ts @@ -150,7 +150,7 @@ describe('Environment Configuration', () => { const content = fs.readFileSync(envPath, 'utf-8'); expect(content).toContain('https'); - expect(content).toContain('port: parseInt'); + expect(content).toContain('port: yamlConfig.https?.port'); expect(content).toContain('keyFile'); expect(content).toContain('crtFile'); }); diff --git a/gadget-code/tests/code-session.test.ts b/gadget-code/tests/code-session.test.ts index 96ced13..abeadde 100644 --- a/gadget-code/tests/code-session.test.ts +++ b/gadget-code/tests/code-session.test.ts @@ -46,20 +46,22 @@ describe("CodeSession", () => { status: "available", } as IDroneRegistration; + mockProject = { + _id: nanoid(), + slug: "test-project", + name: "Test Project", + } as IProject; + mockChatSession = { _id: nanoid(), name: "Test Session", mode: "build", provider: nanoid(), selectedModel: "llama3.1", + user: mockUser, + project: mockProject, } as IChatSession; - mockProject = { - _id: nanoid(), - slug: "test-project", - name: "Test Project", - } as IProject; - codeSession = new CodeSession(mockSocket, mockUser); }); @@ -81,29 +83,32 @@ describe("CodeSession", () => { }); describe("onSubmitPrompt", () => { - it("should throw error if no drone is selected", async () => { + it("should return error if no drone is selected", async () => { codeSession.setChatSession(mockChatSession, mockProject); + const cb = vi.fn(); - await expect(codeSession.onSubmitPrompt("test prompt")).rejects.toThrow( - "No drone selected", - ); + await codeSession.onSubmitPrompt("test prompt", cb); + + expect(cb).toHaveBeenCalledWith(false, { message: "No drone selected" }); }); - it("should throw error if no chat session is active", async () => { + it("should return error if no chat session is active", async () => { codeSession.setSelectedDrone(mockDrone); + const cb = vi.fn(); - await expect(codeSession.onSubmitPrompt("test prompt")).rejects.toThrow( - "No chat session active", - ); + await codeSession.onSubmitPrompt("test prompt", cb); + + expect(cb).toHaveBeenCalledWith(false, { message: "No chat session active" }); }); - it("should throw error if no project is selected", async () => { + it("should return error if no project is selected", async () => { codeSession.setSelectedDrone(mockDrone); codeSession.setChatSession(mockChatSession, undefined as any); + const cb = vi.fn(); - await expect(codeSession.onSubmitPrompt("test prompt")).rejects.toThrow( - "No project selected", - ); + await codeSession.onSubmitPrompt("test prompt", cb); + + expect(cb).toHaveBeenCalledWith(false, { message: "No project selected" }); }); it("should create a ChatTurn and emit processWorkOrder to drone", async () => { @@ -117,6 +122,9 @@ describe("CodeSession", () => { vi.mocked(ChatTurn).mockImplementation(function () { return mockTurn as any; }); + (vi.mocked(ChatTurn) as any).populate = vi + .fn() + .mockResolvedValue(mockTurn); const mockDroneSession = { socket: { @@ -129,7 +137,8 @@ describe("CodeSession", () => { mockDroneSession as any, ); - await codeSession.onSubmitPrompt("test prompt"); + const cb = vi.fn(); + await codeSession.onSubmitPrompt("test prompt", cb); expect(ChatTurn).toHaveBeenCalledWith( expect.objectContaining({ @@ -139,10 +148,9 @@ describe("CodeSession", () => { provider: mockChatSession.provider, llm: mockChatSession.selectedModel, status: ChatTurnStatus.Processing, - prompts: { + prompts: expect.objectContaining({ user: "test prompt", - system: undefined, - }, + }), }), ); @@ -162,12 +170,15 @@ describe("CodeSession", () => { const mockTurn = { _id: nanoid(), status: ChatTurnStatus.Processing, - response: "", + errorMessage: "", save: vi.fn().mockResolvedValue(undefined), }; vi.mocked(ChatTurn).mockImplementation(function () { return mockTurn as any; }); + (vi.mocked(ChatTurn) as any).populate = vi + .fn() + .mockResolvedValue(mockTurn); const mockDroneSession = { socket: { @@ -183,10 +194,11 @@ describe("CodeSession", () => { mockDroneSession as any, ); - await codeSession.onSubmitPrompt("test prompt"); + const cb = vi.fn(); + await codeSession.onSubmitPrompt("test prompt", cb); expect(mockTurn.status).toBe(ChatTurnStatus.Error); - expect(mockTurn.response).toBe("Drone is busy"); + expect(mockTurn.errorMessage).toBe("Drone is busy"); expect(mockTurn.save).toHaveBeenCalled(); }); }); diff --git a/gadget-code/tests/drone-session.test.ts b/gadget-code/tests/drone-session.test.ts index fe3aba9..189d8cd 100644 --- a/gadget-code/tests/drone-session.test.ts +++ b/gadget-code/tests/drone-session.test.ts @@ -100,30 +100,25 @@ describe("DroneSession", () => { }); describe("onThinking", () => { - it("should route thinking event to code session and update ChatTurn", async () => { + it("should route thinking event to code session", async () => { const mockCodeSession = { socket: { emit: vi.fn() }, + onThinking: vi.fn(), }; vi.mocked(SocketService.getCodeSessionByChatSessionId).mockReturnValue( mockCodeSession as any, ); - vi.mocked(ChatTurn.findByIdAndUpdate).mockResolvedValue({} as any); droneSession.setChatSessionId(mockChatSessionId); - droneSession.setCurrentTurnId(mockTurnId); await droneSession.onThinking("thinking content"); expect(SocketService.getCodeSessionByChatSessionId).toHaveBeenCalledWith( mockChatSessionId, ); - expect(mockCodeSession.socket.emit).toHaveBeenCalledWith( - "thinking", + expect(mockCodeSession.onThinking).toHaveBeenCalledWith( "thinking content", ); - expect(ChatTurn.findByIdAndUpdate).toHaveBeenCalledWith(mockTurnId, { - thinking: "thinking content", - }); }); it("should log warning if no chat session is active", async () => { @@ -134,30 +129,25 @@ describe("DroneSession", () => { }); describe("onResponse", () => { - it("should route response event to code session and update ChatTurn", async () => { + it("should route response event to code session", async () => { const mockCodeSession = { socket: { emit: vi.fn() }, + onResponse: vi.fn(), }; vi.mocked(SocketService.getCodeSessionByChatSessionId).mockReturnValue( mockCodeSession as any, ); - vi.mocked(ChatTurn.findByIdAndUpdate).mockResolvedValue({} as any); droneSession.setChatSessionId(mockChatSessionId); - droneSession.setCurrentTurnId(mockTurnId); await droneSession.onResponse("response content"); expect(SocketService.getCodeSessionByChatSessionId).toHaveBeenCalledWith( mockChatSessionId, ); - expect(mockCodeSession.socket.emit).toHaveBeenCalledWith( - "response", + expect(mockCodeSession.onResponse).toHaveBeenCalledWith( "response content", ); - expect(ChatTurn.findByIdAndUpdate).toHaveBeenCalledWith(mockTurnId, { - response: "response content", - }); }); it("should log warning if no chat session is active", async () => { @@ -171,8 +161,10 @@ describe("DroneSession", () => { it("should route toolCall event to code session and update ChatTurn", async () => { const mockCodeSession = { socket: { emit: vi.fn() }, + onToolCall: vi.fn(), }; const mockTurn = { + blocks: [], toolCalls: [], stats: { toolCallCount: 0 }, save: vi.fn().mockResolvedValue(undefined), @@ -195,8 +187,7 @@ describe("DroneSession", () => { expect(SocketService.getCodeSessionByChatSessionId).toHaveBeenCalledWith( mockChatSessionId, ); - expect(mockCodeSession.socket.emit).toHaveBeenCalledWith( - "toolCall", + expect(mockCodeSession.onToolCall).toHaveBeenCalledWith( "call-123", "readFile", '{"path":"test.ts"}', @@ -225,6 +216,7 @@ describe("DroneSession", () => { it("should update ChatTurn status and emit to code session on success", async () => { const mockCodeSession = { socket: { emit: vi.fn() }, + onWorkOrderComplete: vi.fn(), }; const mockTurn = { status: ChatTurnStatus.Processing, @@ -242,8 +234,7 @@ describe("DroneSession", () => { expect(ChatTurn.findById).toHaveBeenCalledWith(mockTurnId); expect(mockTurn.status).toBe(ChatTurnStatus.Finished); expect(mockTurn.save).toHaveBeenCalled(); - expect(mockCodeSession.socket.emit).toHaveBeenCalledWith( - "workOrderComplete", + expect(mockCodeSession.onWorkOrderComplete).toHaveBeenCalledWith( mockTurnId, true, undefined, @@ -254,10 +245,11 @@ describe("DroneSession", () => { it("should update ChatTurn to Error status on failure", async () => { const mockCodeSession = { socket: { emit: vi.fn() }, + onWorkOrderComplete: vi.fn(), }; const mockTurn = { status: ChatTurnStatus.Processing, - response: "", + errorMessage: "", save: vi.fn().mockResolvedValue(undefined), }; vi.mocked(SocketService.getCodeSessionByChatSessionId).mockReturnValue( @@ -274,7 +266,7 @@ describe("DroneSession", () => { ); expect(mockTurn.status).toBe(ChatTurnStatus.Error); - expect(mockTurn.response).toBe("Agent crashed"); + expect(mockTurn.errorMessage).toBe("Agent crashed"); expect(mockTurn.save).toHaveBeenCalled(); }); diff --git a/gadget-code/tests/e2e/auth.test.ts b/gadget-code/tests/e2e/auth.test.ts index 815b6f6..1e49509 100644 --- a/gadget-code/tests/e2e/auth.test.ts +++ b/gadget-code/tests/e2e/auth.test.ts @@ -32,7 +32,7 @@ test.describe('Authentication Flow', () => { const userData = await page.evaluate(() => localStorage.getItem('dtp_user')); expect(userData).toBeNull(); - const content = await page.content(); - expect(content).toContain('Sign Up Today!'); - }); -}); \ No newline at end of file + const content = await page.content(); + expect(content).toContain('Sign In'); + }); + }); \ No newline at end of file diff --git a/gadget-code/vitest.config.ts b/gadget-code/vitest.config.ts index 1586d5b..f16d1f5 100644 --- a/gadget-code/vitest.config.ts +++ b/gadget-code/vitest.config.ts @@ -5,6 +5,11 @@ import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, test: { globals: true, environment: 'node', diff --git a/gadget-drone/src/services/agent.ts b/gadget-drone/src/services/agent.ts index ef84c11..ee9f1b1 100644 --- a/gadget-drone/src/services/agent.ts +++ b/gadget-drone/src/services/agent.ts @@ -131,12 +131,16 @@ class AgentService extends GadgetService { } try { + const reasoningEffort = turn.reasoningEffort || "off"; + const reasoning: boolean | "low" | "medium" | "high" = + reasoningEffort === "off" ? false : reasoningEffort; + const response = await AiService.chat( turn.provider, { modelId: turn.llm, params: { - reasoning: false, + reasoning, temperature: 0.8, topP: 0.9, topK: 40, diff --git a/gadget-drone/src/services/ai.ts b/gadget-drone/src/services/ai.ts index d265ed5..5ca2e8d 100644 --- a/gadget-drone/src/services/ai.ts +++ b/gadget-drone/src/services/ai.ts @@ -36,7 +36,7 @@ export interface IDroneModelConfig { provider: DbAiProvider | GadgetId; modelId: string; params: { - reasoning: boolean; + reasoning: boolean | "low" | "medium" | "high"; temperature: number; topP: number; topK: number; diff --git a/packages/ai/src/ollama.test.ts b/packages/ai/src/ollama.test.ts index a897584..c11bc4b 100644 --- a/packages/ai/src/ollama.test.ts +++ b/packages/ai/src/ollama.test.ts @@ -372,4 +372,75 @@ describe('OllamaAiApi', () => { expect(response.response).toBe('Here are the results'); }); }); + + describe('probeModel', () => { + it('should detect thinking capability from "thinking" (Ollama convention)', async () => { + mockOllamaClient.show.mockResolvedValue({ + capabilities: ['completion', 'vision', 'tools', 'thinking'], + details: { family: 'gemma4' }, + model_info: {}, + modified_at: '2026-04-04T06:20:40.211Z', + }); + + const result = await api.probeModel('gemma4:e4b'); + expect(result.capabilities.hasThinking).toBe(true); + expect(result.capabilities.canCallTools).toBe(true); + expect(result.capabilities.hasVision).toBe(true); + }); + + it('should detect thinking capability from "reasoning" (OpenAI convention)', async () => { + mockOllamaClient.show.mockResolvedValue({ + capabilities: ['completion', 'reasoning'], + details: { family: 'deepseek' }, + model_info: {}, + modified_at: '2026-04-04T06:20:40.211Z', + }); + + const result = await api.probeModel('deepseek-r1'); + expect(result.capabilities.hasThinking).toBe(true); + }); + + it('should set hasThinking false when neither thinking nor reasoning in capabilities', async () => { + mockOllamaClient.show.mockResolvedValue({ + capabilities: ['completion'], + details: { family: 'llama' }, + model_info: {}, + modified_at: '2026-04-04T06:20:40.211Z', + }); + + const result = await api.probeModel('llama3.2'); + expect(result.capabilities.hasThinking).toBe(false); + }); + + it('should detect vision, tools, and embedding capabilities', async () => { + mockOllamaClient.show.mockResolvedValue({ + capabilities: ['completion', 'vision', 'tools', 'embeddings'], + details: { family: 'llama' }, + model_info: {}, + modified_at: '2026-04-04T06:20:40.211Z', + }); + + const result = await api.probeModel('some-model'); + expect(result.capabilities.hasVision).toBe(true); + expect(result.capabilities.canCallTools).toBe(true); + expect(result.capabilities.hasEmbedding).toBe(true); + }); + + it('should extract settings from Modelfile parameters', async () => { + mockOllamaClient.show.mockResolvedValue({ + capabilities: ['completion'], + details: { family: 'llama' }, + model_info: {}, + parameters: 'temperature 0.7\ntop_k 40\ntop_p 0.9\nnum_ctx 4096', + modified_at: '2026-04-04T06:20:40.211Z', + }); + + const result = await api.probeModel('llama3.2'); + expect(result.settings).toBeDefined(); + expect(result.settings!.temperature).toBe(0.7); + expect(result.settings!.topK).toBe(40); + expect(result.settings!.topP).toBe(0.9); + expect(result.settings!.numCtx).toBe(4096); + }); + }); }); diff --git a/packages/ai/src/ollama.ts b/packages/ai/src/ollama.ts index a9c1075..f31dc6b 100644 --- a/packages/ai/src/ollama.ts +++ b/packages/ai/src/ollama.ts @@ -108,7 +108,7 @@ export class OllamaAiApi extends AiApi { !!modelInfo?.["vision_model"] || !!modelInfo?.["clip"], hasEmbedding: capabilities.includes("embeddings"), - hasThinking: capabilities.includes("reasoning"), + hasThinking: capabilities.includes("thinking") || capabilities.includes("reasoning"), isInstructTuned: modelId.toLowerCase().includes("instruct") || modelId.toLowerCase().includes("chat") || diff --git a/packages/ai/src/openai.ts b/packages/ai/src/openai.ts index 954e845..c3d0576 100644 --- a/packages/ai/src/openai.ts +++ b/packages/ai/src/openai.ts @@ -203,6 +203,9 @@ export class OpenAiApi extends AiApi { { role: "user" as const, content: options.prompt }, ], stream: true, + ...(typeof model.params.reasoning === "string" + ? { reasoning_effort: model.params.reasoning as "low" | "medium" | "high" } + : {}), }); let accumulatedResponse = ""; @@ -316,6 +319,9 @@ export class OpenAiApi extends AiApi { messages, tools, stream: true, + ...(typeof model.params.reasoning === "string" + ? { reasoning_effort: model.params.reasoning as "low" | "medium" | "high" } + : {}), }); let accumulatedResponse = ""; diff --git a/packages/api/src/interfaces/chat-session.ts b/packages/api/src/interfaces/chat-session.ts index 3e86fe2..4bf2ef5 100644 --- a/packages/api/src/interfaces/chat-session.ts +++ b/packages/api/src/interfaces/chat-session.ts @@ -22,6 +22,8 @@ export interface IChatSessionPin { content: string; } +export type ReasoningEffort = "off" | "low" | "medium" | "high"; + export interface IChatSession { _id: GadgetId; createdAt: Date; @@ -32,6 +34,7 @@ export interface IChatSession { mode: ChatSessionMode; provider: IAiProvider | GadgetId; selectedModel: string; + reasoningEffort?: ReasoningEffort; stats: { turnCount: number; toolCallCount: number; diff --git a/packages/api/src/interfaces/chat-turn.ts b/packages/api/src/interfaces/chat-turn.ts index 7360487..bf8689e 100644 --- a/packages/api/src/interfaces/chat-turn.ts +++ b/packages/api/src/interfaces/chat-turn.ts @@ -9,7 +9,7 @@ import type { IProject } from "./project.js"; import type { IChatSession } from "./chat-session.js"; import type { IAiProvider } from "./ai-provider.js"; -import { ChatSessionMode } from "./chat-session.js"; +import { ChatSessionMode, ReasoningEffort } from "./chat-session.js"; import { GadgetId } from "../lib/gadget-id.ts"; export enum ChatTurnStatus { @@ -80,6 +80,7 @@ export interface IChatTurn { session: IChatSession | GadgetId; provider: IAiProvider | GadgetId; llm: string; // id/name of the model used to process the prompt + reasoningEffort?: ReasoningEffort; mode: ChatSessionMode; status: ChatTurnStatus; prompts: IChatTurnPrompts; diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 5764162..59ca35f 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -112,10 +112,7 @@ export interface GadgetCodeConfig { audios?: string; }; }; - user?: { - signupEnabled?: boolean; - }; -} + } /** * Gadget Drone Worker Configuration