前言

刚参加工作时,领导要求使用日程类的工具记录每日的工作,用于后续回顾在各项工作上的时间占比。

最早使用的是 EssentialPIM,在开始将 Obsidian 作为自己的主要笔记工具后,找到了 obsidian-full-calendar 这个插件,基本可以实现类似的功能,且开源免费。

full-calendar 插件日程效果(内容为元宝生成)

每天记录日程一开始是领导要求,后面即使不再要求,我也养成了习惯。核心在于我每日的工作日渐繁杂,借助日程工具可以很快帮我回顾日、周、月做了哪些工作。

我们工作上每天需要开晨会,对齐前一天的工作进展以及问题;每周我们需要写周报,整理这一周的工作进展细节;每月还需要整理当月在各个业务上主要工作内容以及人力投入。

这些都需要回顾过去的工作,而每天的日程记录给予了我很大的帮助。因此,即使每天需要预留一定的时间记录自己的工作,但我也一直坚持了下来。

周报,月人力投入的整理都需要花时间去整理,而且都是很无聊的事情。在有了大模型后,这些琐事显然都可以靠大模型来做了。

实现

数据准备

在准备数据前,先看下周报、月报具体都需要哪些内容。

周报的内容主要有几方面,

  1. 在各个业务上,都有哪些工作以及工作的进展如何?
  2. 工作是否有遇到什么问题,最终的结果如何?
  3. 下周有哪些工作规划?

其中 1、2 点在日程记录完备详细(业务分类、工作进展、问题及处理结果)的情况下可以得到满足,而下周工作规划暂时还得靠人工编写。

月报的内容主要有这几方面。

  1. 各个业务上主要有哪些工作项。
  2. 各个业务上投入的人力时间占比。

工作项已经满足了,人力时间也可以通过每项工作的起始时间和结束时间计算得出。

整体来看,在保证日程工作项内容完整记录的情况下,周报、月报所依赖的数据都可以通过日程表获取。

前面提到我现在使用 Obsidian 的「obsidian-full-calendar」插件作为日程记录工具。

使用 「obsidian-full-calendar」 时需要先创建日程分类,每个例如「代码开发」、「运营事项」等,这些分类对应 Obsidian 下的一个目录,后续创建日程时,日程将放在所属分类的文件夹下。对于每个分类,可以选择指定的颜色,这样,日程表将会更加美观。

日程分类

full-calendar 插件设置

由于我负责的业务比较多,因此也希望将工作项所属的业务标记在日程上。我负责的业务不断变化,且比较多,将业务作为日程分类并不合适。为了简单,我在每个日程项的前面使用【xxx】 表示所负责的业务,在【xxx】后记录日程项的实际内容。

记录的日程最终都会在建立的分类文件夹下作为 Obsidian 笔记的形式存在,笔记的标题就是日程工作项说明,而笔记的标签则记录了日程项的开始、结束时间。

接下来的问题是,如何将这周、这月的日程项筛选出来并给到大模型?

日程筛选

之前在 Obsidian 使用的相关文章中了解到了「dataview」插件,通过「dataview」插件可以对 Obsidian 中的笔记进行复杂的查询和筛选,更为强大的是,它还支持使用 js 查询并对查询的结果进行处理。

显然,可以借助「dataview」插件帮我实现日程筛选。

以下是大模型帮我实现的日程查询 js 脚本,通过这段脚本,可以帮我将 11-11 日所在周的所有日程项列出来。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 获取当前日期并计算本周的周一和周日
const today = dv.date('2025-11-11'); // 示例中的特定周五,实际使用可改为 dv.date('today')
const dayOfWeek = today.weekday; // 1=周一, 7=周日
const startOfWeek = today.minus({ days: dayOfWeek - 1 }); // 本周一
const endOfWeek = today.plus({ days: 7 - dayOfWeek });    // 本周日

// 查询 assets/calendar 目录下所有页面
const pages = dv.pages('"fullcalender"');

// 筛选出日期在本周内的文件
const weeklyPages = pages.where(p => {
    const fileDate = p.date; // 确保 p.date 是 Luxon DateTime 对象或可识别的日期字符串
    if (!fileDate) return false;
    const dateObj = dv.date(fileDate);
    return dateObj >= startOfWeek && dateObj <= endOfWeek;
});

// 按日期排序并生成表格
if (weeklyPages.length > 0) {
    dv.table(["文件名", "创建日期", "开始时间", "结束时间"],
             weeklyPages
                 .sort(p => p.date, 'asc')
                 .map(p => [p.file.link, p.date?.toFormat('yyyy-MM-dd') || p.date, p.startTime, p.endTime]));
} else {
    dv.paragraph("本周没有创建文件。");
}

在 Obsidian 中创建一篇新的笔记,然后将上述脚本贴在笔记中,并使用 dataviewjs 包裹。在将笔记切换为阅读模式后,笔记的内容将会变为查询后的日程信息。

使用 dataviewjs 列出日程项

大模型调用

Obsidian 也支持很多 AI 插件,比如「Copilot」、「TextGenerator」等等。一开始的想法是,在插件配置相应的快捷命令以及相应的 prompt,然后将日程信息给到这些插件,最后调用快捷命令将日程和 prompt 一块给到大模型并生成周报。

但尝试后发现体验不好。在这些插件中引用的内容均是原始的 dataviewjs 脚本,而不是查询后的日程内容。为了能够将日程内容给到这些插件,只能采用「复制」、「选中」等方式,不够优雅。

在询问大模型后,既然「dataview」插件支持 js,那么干脆直接在 js 中调用大模型接口好了,也非常简单,就是一个 HTTP 请求。

这是大模型帮我改造后的 dataviewjs 脚本,脚本中的大模型使用本地 ollama 中部署的「qwen2.5」 模型(演示用,可替换其他模型)。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
// 配置参数
const CONFIG = {
    targetFolder: 'fullcalender',
    aiEnabled: true,
    defaultPrompt: `请分析以下文件列表,按主题进行分类并总结本周工作重点:
    
{{fileList}}
    
请提供:
1. 按主题分类的文件分组
2. 主要工作领域的识别
3. 本周工作重点总结
4. 可能的后续行动建议`,
    modelSettings: {
        temperature: 0.7,
        maxTokens: 1000
    }
};

class WeeklyFileAnalyzer {
    constructor(dv) {
        this.dv = dv;
        this.container = dv.container;
        this.files = [];
    }
    
    // 获取当前周的文件
    getCurrentWeekFiles() {
        //const today = dv.date('2025-11-11'); // 示例中的特定周五,实际使用可改为 dv.date('today')
	//const dayOfWeek = today.weekday; // 1=周一, 7=周日
	//const startOfWeek = today.minus({ days: dayOfWeek - 1 }); // 本周一
	//const endOfWeek = today.plus({ days: 7 - dayOfWeek });    // 本周日
	const startOfWeek = dv.date('2025-11-09');
	const endOfWeek = dv.date('2025-11-15');
	
	
	// 查询 assets/calendar 目录下所有页面
	const pages = dv.pages('"fullcalender"');
	
	// 筛选出日期在本周内的文件
	const weeklyPages = pages.where(p => {
	    const fileDate = p.date; // 确保 p.date 是 Luxon DateTime 对象或可识别的日期字符串
	    if (!fileDate) return false;
	    const dateObj = dv.date(fileDate);
	    return dateObj >= startOfWeek && dateObj <= endOfWeek;
	});

        this.files = weeklyPages;
        return this.files;
    }
    
    // 显示文件列表
    displayFileList() {
        if (this.files.length === 0) {
            this.dv.paragraph('本周没有找到文件。');
            return;
        }
        
        this.dv.header(3, `本周文件 (${this.files.length}个)`);
        
        const fileList = this.files.sort(p => p.date, 'asc').map(p => 
            `- [[${p.file.path}|${p.file.name}]] (${p.date ? moment(p.date).format('MM-DD') : moment(p.file.ctime).format('MM-DD')})`
        ).join('\n');
        
        this.dv.paragraph(fileList);
    }
    
    // 创建AI分析界面
    createAIAnalysisInterface() {
        if (!CONFIG.aiEnabled || this.files.length === 0) return;
        
        const aiSection = this.container.createEl('div');
        aiSection.style.margin = '20px 0';
        aiSection.style.padding = '15px';
        aiSection.style.border = '1px solid #e0e0e0';
        aiSection.style.borderRadius = '5px';
        
        // 标题
        const title = aiSection.createEl('h4', { text: 'AI智能分析' });
        
        // 提示词输入框
        const promptLabel = aiSection.createEl('label', { 
            text: '自定义提示词:' 
        });
        promptLabel.style.display = 'block';
        promptLabel.style.margin = '10px 0 5px 0';
        
        const promptTextarea = aiSection.createEl('textarea');
        promptTextarea.style.width = '100%';
        promptTextarea.style.height = '100px';
        promptTextarea.style.margin = '5px 0';
        promptTextarea.value = CONFIG.defaultPrompt.replace('{{fileList}}', 
            this.files.map(f => `- ${f.file.name} (${f.date || f.file.ctime})`).join('\n')
        );
        
        // 分析按钮
        const analyzeBtn = aiSection.createEl('button', {
            text: '开始AI分析'
        });
        analyzeBtn.style.margin = '10px 0';
        analyzeBtn.style.padding = '8px 16px';
        analyzeBtn.style.backgroundColor = '#10b981';
        analyzeBtn.style.color = 'white';
        analyzeBtn.style.border = 'none';
        analyzeBtn.style.borderRadius = '4px';
        analyzeBtn.style.cursor = 'pointer';
        
        // 结果容器
        const resultContainer = aiSection.createEl('div');
        resultContainer.style.marginTop = '15px';
        
        analyzeBtn.addEventListener('click', async () => {
            analyzeBtn.disabled = true;
            analyzeBtn.textContent = '分析中...';
            resultContainer.innerHTML = '<p>AI分析中,请稍候...</p>';
            
            try {
                const result = await this.callAIAnalysis(promptTextarea.value);
                dv.paragraph(result);
                
            } catch (error) {
                resultContainer.innerHTML = `<p style="color: red;">分析失败: ${error.message}</p>`;
            } finally {
                analyzeBtn.disabled = false;
                analyzeBtn.textContent = '开始AI分析';
            }
        });
    }
    
    // 调用AI分析
    async callAIAnalysis(prompt) {
        // 使用Text Generator插件[8](@ref)
        return await this.callCustomAPI(prompt)
        // return "分析完成"
        
        const tgApi = app.plugins.plugins.textgenerator.api;
        
        if (false) {
            return tgApi.generate(prompt);
        }
        // 使用自定义API调用
        else if (await this.callCustomAPI(prompt)) {
            return "分析完成(通过自定义API)";
        } else {
            throw new Error('请安装Text Generator插件或配置AI API');
        }
    }
    
    // 自定义API调用示例
    async callCustomAPI(prompt) {
        // 这里可以实现直接调用大模型API[9,10](@ref)
        // 例如调用OpenAI API或本地部署的模型
        const response = await fetch('http://localhost:11434/v1/chat/completions', {
            method: 'POST',
            // headers: {
            //    'Authorization': 'Bearer YOUR_API_KEY',
            //    'Content-Type': 'application/json'
            //},
            body: JSON.stringify({
                model: "qwen2.5",
                messages: [{role: "user", content: prompt}]
            })
        });
        
		const res =  await response.json();
		return res.choices[0].message.content;
        //return false; // 返回false表示不使用此方法
    }
}

// 执行主程序
const analyzer = new WeeklyFileAnalyzer(dv);
analyzer.getCurrentWeekFiles();
analyzer.displayFileList();
analyzer.createAIAnalysisInterface();

将改造后的脚本贴到笔记后,直接就是一个网页工具了。

直接点击「分析」按钮,之后就会请求到本地的「qwen2」模型,等待一段时间,就会生成相应的周报。

效果(日程内容有省略)

这里的 prompt 是由大模型自己生成的,可以基于实际的情况进行修改。

到这里,核心流程已经都串起来了。但完整流程还差一步,获取的日程的起始日期和结束日期是写死在代码里的,需要在修改下 dataviewjs,允许输入起始日期和结束日期,然后基于日期范围进行日程查询。

这样,就相当于在 Obsidian 中内嵌了一个日报、周报生成工具。

流程优化

但经过思考后,我又否掉了「工具」的这种方式。「工具」的问题在于,每次生成新的周报后,上次的周报内容就不在了。我还是希望可以在将每次生成的周报作为一篇笔记存储在 Obsidian 中。

再次与大模型沟通后,我改为使用了「Templater」插件。「Templater」插件是一个模板插件,创建新笔记后,可以快速基于「Templater」中的模板生成一篇新的笔记。

「Templater」插件同样可以使用 js,而且「dataview」插件还提供了接口,允许被其他插件调用。那么流程上,直接创建一个新的「Templater」模板,然后在模板中使用 js 做这几件事。

  1. 获取用户输入的日程筛选起始、结束日期。
  2. 基于起始、结束日期调用 dataview 插件功能获时间范围内的笔记。
  3. 使用 js 请求大模型接口生成周报。
  4. 将周报内容展示在笔记中。

最终的「Templater」周报模板内容如下,月报内容类似,只是 prompt 有修改。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
<%*
function calculateDuration(startTime, endTime) {
    // 解析时间字符串 "16:00" -> {hours: 16, minutes: 0}
    function parseTime(timeStr) {
        const [hours, minutes] = timeStr.split(':').map(Number);
        return { hours, minutes };
    }
    
    // 将时间转换为分钟数
    function timeToMinutes(timeObj) {
        return timeObj.hours * 60 + timeObj.minutes;
    }
    
    const start = parseTime(startTime);
    const end = parseTime(endTime);
    
    const startMinutes = timeToMinutes(start);
    const endMinutes = timeToMinutes(end);
    
    // 计算时长(分钟)
    let durationMinutes = endMinutes - startMinutes;
    
    // 处理跨天情况(如果结束时间小于开始时间)
    if (durationMinutes < 0) {
        durationMinutes += 24 * 60; // 加上一天的分钟数
    }
    
    // 转换为小时(保留一位小数)
    const durationHours = Math.round((durationMinutes / 60) * 10) / 10;
    
    return durationHours;
}
// 1. 获取用户输入的日期范围
const startDate = await tp.system.prompt(" 请输入开始日期 (格式: YYYY-MM-DD)");
const endDate = await tp.system.prompt(" 请输入结束日期 (格式: YYYY-MM-DD)");
// 2. 验证日期格式
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(startDate) || !dateRegex.test(endDate)) {
    throw new Error("日期格式不正确,请使用 YYYY-MM-DD 格式");
}
let files = []; 
let fileList = ""; 
let fileContents = "";
const start = new Date(startDate);
const end = new Date(endDate);
try {
    // 检查 Dataview 插件是否可用
    if (app.plugins.plugins.dataview?.api) {
        const dv = app.plugins.plugins.dataview.api;
        
        // 获取所有文件,然后手动过滤
        const allPages = dv.pages('"fullcalender"');
        
        files = allPages.values.filter(p => {
            if (!p.date) return false;
            const noteDate = new Date(p.date);
            const compareRes = noteDate >= start && noteDate <= end ;
            return compareRes;
        });
        
        // 排序
        files.sort((a, b) => a.date.toString().localeCompare(b.date.toString()));
        
    } else {
        throw new Error("Dataview 插件未启用或不可用");
    }
} catch (error) {
    fileList = `获取文件列表时出错: ${error.message}`;
}
// 生成文件列表和内容
let totalUsedTime = 0
for (let file of files) {
    const usedTime = calculateDuration(file.startTime, file.endTime);
    totalUsedTime += usedTime;
    fileList += `- [[${file.file.name}]]  ${usedTime}h\n`;
    fileContents += `- ${file.file.name} ——耗时:${usedTime}h\n`;
}
fileList += `\n 总共用时 ${totalUsedTime}h`;
// 5. 调用 Ollama API
async function callOllama(prompt, content) {
    const response = await fetch('http://localhost:11434/api/generate', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        
        body: JSON.stringify({
            model: 'qwen2.5', // 根据你的模型名称调整
            prompt: `${prompt}\n\n${content}\n /no_think`,
            enable_thinking: false,
            stream: false
        })
    });
    
    const data = await response.json();
    return data.response;
}
// 6. 自定义 prompt
const customPrompt = `你是一个周报总结助手。
我将提供给你一周的工作项,工作项内容行如以下格式。

- YYYY-MM-DD 【xxx】....... ——耗时: x.xh
- YYYY-MM-DD 【xxx】....... ——耗时:x.xh
其中 YYYY-MM-DD 是工作项的日期,【xxx】中的 xxx 是工作项的分类,.......则是工作项的具体说明,而最后的 ——耗时:x.xh 则是该项工作的耗时,单位是小时(h),比如1.5h、0.5h等
现在需要基于分类,将同样分类的工作内容提取出来,然后以以下格式编写周报。
【分类 1】——共耗时:x.xh,周耗时占比:x%
a. 工作说明 1
b. 工作说明 2
【分类 2】————共耗时:x.xh,周耗时占比:x%
a. 工作说明 1
b. 工作说明 2
除了按以上格式编写周报外,还有以下要求,需要严格遵守。

1. 对于工作项的说明,如果同一个分类下有同一项工作的多个表述,那么可对这多个表述的内容汇总为一项工作描述,语言风格需要与其他工作项说明保持一致。
2. 每个分类后给出该分类工作项的耗时和以及耗时占该月所有工作的百分比。
3. 只输出格式要求内的内容,其他内容都不要输出。
以下是本周的主要工作项内容,请基于这些内容,严格按照格式要求编写周报。`;
// 7. 调用大模型生成周报
let weeklyReport = "";
if (fileContents.trim()) {
    try {
        weeklyReport = await callOllama(customPrompt, fileContents);
    } catch (error) {
        weeklyReport = `调用大模型时出错: ${error.message}`;
    }
} else {
    weeklyReport = "指定时间范围内没有找到相关工作文件。";
}
// 8. 设置文档标题
const title = `周报 ${startDate}-${endDate}`;
await tp.file.rename(title);
-%>
## 本周工作
<% fileList || " 本周没有记录的工作文件。" %>
## 工作周报
<% weeklyReport %>
---
*生成时间: <% tp.date.now("YYYY-MM-DD HH:mm") %>*

最终效果

以下是基于「Templater 插件周报模板」生成的周报内容。

周报效果(内容有省略)

后续再需要整理周报、月报时,直接创建模板并生成周、月报,然后将内容粘贴复制即可,不再需要自己动手动脑去写了。